Fusion du lot C : inertie à cinq crans, échelle calée sur la participation médiane, réglages de la formule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CLAUDE.md
This commit is contained in:
Yvv
2026-10-30 18:20:49 +01:00
40 changed files with 2686 additions and 357 deletions
+12 -8
View File
@@ -33,17 +33,20 @@ développer.
``` ```
crates/ crates/
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle, presets), edit (coquille vs fond, ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle), inertia (cinq crans, médiane,
# diff), assist (découpage, similarité, longueur), compose (adoption, aperçus) # référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité,
# longueur), compose (adoption, aperçus)
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut
ld-server/ ld-server/
migrations/ # schéma SQLite — additif seulement migrations/ # schéma SQLite — additif seulement
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations, src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler, # dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
# traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W # traduire) · adoption.rs (balayage, verrou) · participation.rs (p̃, une fois par
# sur la chaîne) · seed.rs · web.rs (sert web/build) # jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
# seed_amend.rs / inertia_amend.rs (amendements gardés) · web.rs (sert web/build)
tests/api.rs # scénarios bout en bout tests/api.rs # scénarios bout en bout
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur) seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur ;
# inertie 3, E1/E2 à 4)
# upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage # upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage
web/src/ web/src/
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
@@ -52,7 +55,7 @@ web/src/
# g/[group]/docs · g/[group]/docs/[doc] (lecteur, # g/[group]/docs · g/[group]/docs/[doc] (lecteur,
# panneau dans l'URL ?onglet=&article=) · g/[group]/decisions · g/[group]/mandats · # panneau dans l'URL ?onglet=&article=) · g/[group]/decisions · g/[group]/mandats ·
# g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent # g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent
web/e2e/ # Playwright C1–C19 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 web/e2e/ # Playwright C1–C20 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
docker/ # app.Dockerfile · app-compose.yml (sonic) docker/ # app.Dockerfile · app-compose.yml (sonic)
.woodpecker/ # app.yml · maquette.yml .woodpecker/ # app.yml · maquette.yml
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/ docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
@@ -73,7 +76,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
```bash ```bash
export PATH=$HOME/.cargo/bin:$PATH export PATH=$HOME/.cargo/bin:$PATH
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 20 contrôles cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 21 contrôles
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
cd web && npm run dev # :3002 à chaud cd web && npm run dev # :3002 à chaud
@@ -89,7 +92,8 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`. `ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ; - **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
une coquille ne demande pas de vote, un changement de fond si ; un signalement de une coquille ne demande pas de vote, un changement de fond si ; un signalement de
contradiction bloque ; l'inertie se vote au cran au-dessus ; le groupe d'essai ne publie jamais. contradiction bloque ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
le groupe d'essai ne publie jamais.
- **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie - **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie
`ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`). `ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`).
- **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ; - **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ;
+2 -1
View File
@@ -48,7 +48,8 @@ Chantier en cours : **Acte d'engagement Certification** (Monnaie libre), en bin
ouvre une nouvelle révision, dont les votes antérieurs sont à reconfirmer. ouvre une nouvelle révision, dont les votes antérieurs sont à reconfirmer.
- Deux propositions qui **se contredisent** ne passent pas ensemble : un signalement bloque la - Deux propositions qui **se contredisent** ne passent pas ensemble : un signalement bloque la
seconde tant qu'il n'est pas levé. seconde tant qu'il n'est pas levé.
- L'**inertie** de chaque article se vote aussi (par défaut : un cran au-dessus). - L'**inertie** de chaque article (cinq crans, 3 par défaut, calés sur la participation
habituelle du groupe) se vote aussi, au cran de l'article.
- Tout ce qui entre en vigueur est **publié automatiquement** dans un dépôt git - Tout ce qui entre en vigueur est **publié automatiquement** dans un dépôt git
(fr/en/es + JSON) lu par les applications clientes. (fr/en/es + JSON) lu par les applications clientes.
+62 -55
View File
@@ -18,9 +18,11 @@
//! - which proposals enter into force now (`adoptions`); //! - which proposals enter into force now (`adoptions`);
//! - what the document would read if the well-rated, or all, proposals passed //! - what the document would read if the well-rated, or all, proposals passed
//! (`preview`); //! (`preview`);
//! - which formula a proposal is judged by (`params_for`). //! - which formula a proposal is judged by (`params_for`): the level of
//! inertia of its unit, on the scale of its group (`inertia::Scale`).
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate}; use crate::inertia::{InertiaLevel, Scale};
use crate::threshold::{FormulaParams, Tally, ThresholdError, evaluate};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -37,7 +39,7 @@ pub struct VersionText {
pub struct UnitState { pub struct UnitState {
pub id: String, pub id: String,
pub position: f64, pub position: f64,
pub inertia: InertiaPreset, pub inertia: InertiaLevel,
pub in_force: Option<VersionText>, pub in_force: Option<VersionText>,
} }
@@ -49,7 +51,7 @@ pub enum ProposalKind {
/// Remove the unit from the text in force. /// Remove the unit from the text in force.
Remove, Remove,
/// Change the inertia of the unit. /// Change the inertia of the unit.
Inertia(InertiaPreset), Inertia(InertiaLevel),
} }
impl ProposalKind { impl ProposalKind {
@@ -72,13 +74,18 @@ pub struct ProposalState {
pub blocked: bool, pub blocked: bool,
} }
/// A change of inertia is voted one level above the unit (« inertie + »). /// The level a proposal is voted at. A change of inertia inherits the level of
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams { /// its unit, as its text does (Yvv, 2026-10-30: « par défaut l'inertie de
let preset = match kind { /// l'indice d'inertie lui-même hérite de l'inertie de l'article ») — this
ProposalKind::Inertia(_) => unit.inertia.raised(), /// replaces « inertie + », one level above (2026-10-21).
ProposalKind::Text | ProposalKind::Remove => unit.inertia, pub fn voting_level(unit: &UnitState, kind: ProposalKind) -> InertiaLevel {
}; match kind {
FormulaParams::for_inertia(preset, floor) ProposalKind::Text | ProposalKind::Remove | ProposalKind::Inertia(_) => unit.inertia,
}
}
pub fn params_for(unit: &UnitState, kind: ProposalKind, scale: &Scale) -> FormulaParams {
scale.params(voting_level(unit, kind))
} }
/// A proposal that changes nothing cannot enter into force. /// A proposal that changes nothing cannot enter into force.
@@ -98,13 +105,8 @@ struct Scored<'a> {
well_rated: bool, well_rated: bool,
} }
fn score<'a>( fn score<'a>(unit: &UnitState, p: &'a ProposalState, scale: &Scale) -> Result<Scored<'a>, ThresholdError> {
unit: &UnitState, let e = evaluate(p.tally, scale.electorate, &params_for(unit, p.kind, scale))?;
p: &'a ProposalState,
electorate: u32,
floor: f64,
) -> Result<Scored<'a>, ThresholdError> {
let e = evaluate(p.tally, electorate, &params_for(unit, p.kind, floor))?;
Ok(Scored { Ok(Scored {
proposal: p, proposal: p,
margin: i64::from(p.tally.for_votes) - i64::from(e.required), margin: i64::from(p.tally.for_votes) - i64::from(e.required),
@@ -126,9 +128,9 @@ fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
} }
/// Margin of the version in force, judged like a text proposal on its unit. /// Margin of the version in force, judged like a text proposal on its unit.
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> { fn in_force_margin(unit: &UnitState, scale: &Scale) -> Result<Option<i64>, ThresholdError> {
let Some(v) = &unit.in_force else { return Ok(None) }; let Some(v) = &unit.in_force else { return Ok(None) };
let e = evaluate(v.tally, electorate, &params_for(unit, ProposalKind::Text, floor))?; let e = evaluate(v.tally, scale.electorate, &params_for(unit, ProposalKind::Text, scale))?;
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required))) Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
} }
@@ -138,19 +140,18 @@ fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Opti
pub fn adoptions( pub fn adoptions(
units: &[UnitState], units: &[UnitState],
proposals: &[ProposalState], proposals: &[ProposalState],
electorate: u32, scale: &Scale,
floor: f64,
) -> Result<Vec<String>, ThresholdError> { ) -> Result<Vec<String>, ThresholdError> {
let mut out = Vec::new(); let mut out = Vec::new();
for unit in units { for unit in units {
let incumbent = in_force_margin(unit, electorate, floor)?; let incumbent = in_force_margin(unit, scale)?;
for content in [true, false] { for content in [true, false] {
let mut rivals = Vec::new(); let mut rivals = Vec::new();
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) { for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
if p.blocked || !is_effective(unit, p) { if p.blocked || !is_effective(unit, p) {
continue; continue;
} }
let s = score(unit, p, electorate, floor)?; let s = score(unit, p, scale)?;
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m); let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
if s.reached && beats_incumbent { if s.reached && beats_incumbent {
rivals.push(s); rivals.push(s);
@@ -200,8 +201,7 @@ pub struct PreviewUnit {
pub fn preview( pub fn preview(
units: &[UnitState], units: &[UnitState],
proposals: &[ProposalState], proposals: &[ProposalState],
electorate: u32, scale: &Scale,
floor: f64,
mode: PreviewMode, mode: PreviewMode,
) -> Result<Vec<PreviewUnit>, ThresholdError> { ) -> Result<Vec<PreviewUnit>, ThresholdError> {
let mut ordered: Vec<&UnitState> = units.iter().collect(); let mut ordered: Vec<&UnitState> = units.iter().collect();
@@ -216,7 +216,7 @@ pub fn preview(
if !is_effective(unit, p) { if !is_effective(unit, p) {
continue; continue;
} }
let s = score(unit, p, electorate, floor)?; let s = score(unit, p, scale)?;
if mode == PreviewMode::All || s.well_rated { if mode == PreviewMode::All || s.well_rated {
rivals.push(s); rivals.push(s);
} }
@@ -263,7 +263,7 @@ mod tests {
UnitState { UnitState {
id: id.into(), id: id.into(),
position, position,
inertia: InertiaPreset::Standard, inertia: InertiaLevel::DEFAULT,
in_force: in_force.map(|b| VersionText { in_force: in_force.map(|b| VersionText {
id: format!("{id}-v"), id: format!("{id}-v"),
title: id.into(), title: id.into(),
@@ -286,23 +286,27 @@ mod tests {
} }
} }
const W: u32 = 5745; /// Monnaie libre before any median: W 5745, C 50, the reference p0 = 2C/W.
const C: f64 = 50.0; const SCALE: Scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 };
fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
#[test] #[test]
fn nothing_passes_at_zero_votes() { fn nothing_passes_at_zero_votes() {
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)]; let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)]; let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
assert!(adoptions(&units, &props, W, C).expect("valid").is_empty()); assert!(adoptions(&units, &props, &SCALE).expect("valid").is_empty());
} }
#[test] #[test]
fn the_floor_holds_then_releases() { fn the_floor_holds_then_releases() {
let units = [unit("E3", 7.0, None)]; let units = [unit("E3", 7.0, None)];
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)]; let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty()); assert!(adoptions(&units, &few, &SCALE).expect("valid").is_empty());
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)]; let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]); assert_eq!(adoptions(&units, &enough, &SCALE).expect("valid"), vec!["p1".to_string()]);
} }
#[test] #[test]
@@ -312,7 +316,7 @@ mod tests {
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0), prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0), prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
]; ];
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]); assert_eq!(adoptions(&units, &props, &SCALE).expect("valid"), vec!["bb".to_string()]);
} }
#[test] #[test]
@@ -320,19 +324,22 @@ mod tests {
let units = [unit("E1", 4.0, Some("unicité"))]; let units = [unit("E1", 4.0, Some("unicité"))];
let props = [ let props = [
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0), prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0), prop("i", "E1", ProposalKind::Inertia(level(4)), "", 60, 0),
]; ];
let won = adoptions(&units, &props, W, C).expect("valid"); let won = adoptions(&units, &props, &SCALE).expect("valid");
assert_eq!(won, vec!["t".to_string(), "i".to_string()]); assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
} }
#[test] #[test]
fn inertia_change_is_voted_one_level_up() { fn inertia_change_is_voted_at_the_level_of_the_unit() {
let u = unit("E1", 4.0, Some("x")); let mut u = unit("E1", 4.0, Some("x"));
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C); u.inertia = level(4);
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct()); for target in [1, 3, 5] {
assert_eq!(p.gradient, InertiaPreset::High.gradient()); let kind = ProposalKind::Inertia(level(target));
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient()); assert_eq!(voting_level(&u, kind), level(4), "inherits the level of the article");
assert_eq!(params_for(&u, kind, &SCALE), params_for(&u, ProposalKind::Text, &SCALE));
}
assert_eq!(params_for(&u, ProposalKind::Text, &SCALE).majority_pct, 60.0);
} }
#[test] #[test]
@@ -341,8 +348,8 @@ mod tests {
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0); let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
blocked.blocked = true; blocked.blocked = true;
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0); let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0); let same_inertia = prop("i", "E1", ProposalKind::Inertia(level(3)), "", 80, 0);
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty()); assert!(adoptions(&units, &[blocked, same, same_inertia], &SCALE).expect("valid").is_empty());
} }
#[test] #[test]
@@ -350,20 +357,20 @@ mod tests {
let mut units = [unit("E1", 4.0, Some("unicité"))]; let mut units = [unit("E1", 4.0, Some("unicité"))];
// The seed a minima has no vote: the first rival that reaches its threshold wins. // The seed a minima has no vote: the first rival that reaches its threshold wins.
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)]; let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]); assert_eq!(adoptions(&units, &rival, &SCALE).expect("valid"), vec!["r".to_string()]);
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits. // A version in force by vote, margin 60 − 58 = 2: a rival at 55 (margin 55 − 54 = 1) waits.
if let Some(v) = units[0].in_force.as_mut() { if let Some(v) = units[0].in_force.as_mut() {
v.tally = Tally { for_votes: 60, against: 0 }; v.tally = Tally { for_votes: 60, against: 0 };
} }
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)]; let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty()); assert!(adoptions(&units, &weaker, &SCALE).expect("valid").is_empty());
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)]; let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]); assert_eq!(adoptions(&units, &stronger, &SCALE).expect("valid"), vec!["s".to_string()]);
// The same rule holds for a removal; inertia does not compete with the text. // The same rule holds for a removal; inertia does not compete with the text.
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)]; let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty()); assert!(adoptions(&units, &removal, &SCALE).expect("valid").is_empty());
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)]; let inertia = [prop("i", "E1", ProposalKind::Inertia(level(4)), "", 55, 0)];
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]); assert_eq!(adoptions(&units, &inertia, &SCALE).expect("valid"), vec!["i".to_string()]);
} }
#[test] #[test]
@@ -373,16 +380,16 @@ mod tests {
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0), prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
prop("weak", "E1", ProposalKind::Remove, "", 1, 5), prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
]; ];
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid"); let in_force = preview(&units, &props, &SCALE, PreviewMode::InForce).expect("valid");
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]); assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid"); let likely = preview(&units, &props, &SCALE, PreviewMode::WellRated).expect("valid");
assert_eq!( assert_eq!(
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(), likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),] [("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
); );
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid"); let all = preview(&units, &props, &SCALE, PreviewMode::All).expect("valid");
assert_eq!( assert_eq!(
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(), all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),] [("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
@@ -396,7 +403,7 @@ mod tests {
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0), prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3), prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
]; ];
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid"); let all = preview(&units, &props, &SCALE, PreviewMode::All).expect("valid");
assert_eq!(all[0].proposal_id.as_deref(), Some("bb")); assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
} }
} }
+438
View File
@@ -0,0 +1,438 @@
//! Inertia in five levels, on a scale that follows the group (ADR 0007).
//!
//! Yvv, 2026-10-30: « L'indice d'inertie doit avoir 5 niveaux » ; « une échelle
//! cohérente qui s'adapte aux nombres de certifiés et à la participation
//! médiane aux votes du groupe » — the scale chosen is « calée sur la
//! participation ».
//!
//! The formula stays the Ğ1 one (`threshold`); only M and G depend on the
//! level k:
//!
//! - M_k, the majority at full participation: 50, 50, 55, 60, 66 %;
//! - s_k, the share of *for* votes asked **beyond C** at the group's usual
//! participation p̃: 60, 67, 75, 83, 90 %;
//! - G_k follows from `s_k = M_k + (1 − M_k)(1 − p̃^G_k)`:
//! `G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)`.
//!
//! p̃ is the median number of voters on the group's proposals, over W
//! (`Participation`). Until the group has voted on enough proposals, a
//! reference `p0 = 2C/W` stands in. Both are kept within [1 %, 50 %]: ln(p̃)
//! stays negative and finite, so G is finite and positive, and the threshold
//! rises with the level and relaxes toward M as participation grows.
use crate::threshold::{Evaluation, FormulaParams, Tally, ThresholdError, evaluate};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// One of the five levels of inertia, 1 (lightest) to 5 (heaviest).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct InertiaLevel(u8);
/// M_k, in percent.
const MAJORITY_PCT: [f64; 5] = [50.0, 50.0, 55.0, 60.0, 66.0];
/// s_k, in percent.
const SHARE_PCT: [f64; 5] = [60.0, 67.0, 75.0, 83.0, 90.0];
impl InertiaLevel {
pub const LOWEST: Self = Self(1);
pub const HIGHEST: Self = Self(5);
/// The middle level: every proposal starts there (Yvv, 2026-10-30).
pub const DEFAULT: Self = Self(3);
pub const ALL: [Self; 5] = [Self(1), Self(2), Self(3), Self(4), Self(5)];
pub fn new(k: u8) -> Option<Self> {
(1..=5).contains(&k).then_some(Self(k))
}
pub fn get(self) -> u8 {
self.0
}
fn index(self) -> usize {
usize::from(self.0 - 1)
}
/// M_k, in percent.
pub fn majority_pct(self) -> f64 {
MAJORITY_PCT[self.index()]
}
/// s_k, in percent: the share of *for* asked beyond C at the usual participation.
pub fn share_pct(self) -> f64 {
SHARE_PCT[self.index()]
}
/// How a level is kept in the database: its digit.
pub fn as_db(self) -> String {
self.0.to_string()
}
/// A digit from 1 to 5, or a word of the four levels before 2026-10-30, read
/// as the level it became: low → 2, standard → 3, high → 4, max → 5.
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"low" => Some(Self(2)),
"standard" => Some(Self(3)),
"high" => Some(Self(4)),
"max" => Some(Self(5)),
digits => digits.parse::<u8>().ok().and_then(Self::new),
}
}
}
impl Default for InertiaLevel {
fn default() -> Self {
Self::DEFAULT
}
}
impl Serialize for InertiaLevel {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u8(self.0)
}
}
impl<'de> Deserialize<'de> for InertiaLevel {
/// A number from 1 to 5, or a string `parse` reads (a seed or a client of before).
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Number(u64),
Text(String),
}
let level = match Raw::deserialize(d)? {
Raw::Number(n) => u8::try_from(n).ok().and_then(Self::new),
Raw::Text(s) => Self::parse(&s),
};
level.ok_or_else(|| D::Error::custom("an inertia level is 1 to 5"))
}
}
/// Where p̃ comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParticipationSource {
/// The median of the group's voted proposals.
Median,
/// Too few voted proposals yet: `p0 = 2C/W`.
Reference,
}
/// p̃ — the usual participation of a group, as a share of its electorate.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Participation {
/// p̃, within [`Participation::LOWEST`, `Participation::HIGHEST`].
pub ratio: f64,
pub source: ParticipationSource,
/// Voted proposals the median rests on (counted even when too few).
pub sample: u32,
/// The median number of voters measured, when at least one proposal was voted on.
pub median: Option<f64>,
/// The measured (or reference) share fell outside the bounds and was brought back.
pub bounded: bool,
}
impl Participation {
/// Fewer voted proposals than this, and the reference stands in.
pub const MIN_SAMPLE: u32 = 5;
pub const LOWEST: f64 = 0.01;
pub const HIGHEST: f64 = 0.5;
/// `2C/W`, before the bounds: the floor twice over, a participation the
/// group must reach anyway for C to leave room to the majority.
pub fn reference_ratio(electorate: u32, floor: f64) -> f64 {
2.0 * floor.max(0.0) / f64::from(electorate.max(1))
}
/// p̃ from what was measured: the median when it rests on enough proposals,
/// the reference otherwise; brought back within the bounds.
pub fn of(electorate: u32, floor: f64, median: Option<f64>, sample: u32) -> Self {
let (raw, source) = match median {
Some(m) if sample >= Self::MIN_SAMPLE => (m / f64::from(electorate.max(1)), ParticipationSource::Median),
_ => (Self::reference_ratio(electorate, floor), ParticipationSource::Reference),
};
let ratio = bounded(raw);
Self { ratio, source, sample, median, bounded: ratio != raw }
}
/// p̃ · W — the number of voters p̃ stands for.
pub fn voters(&self, electorate: u32) -> f64 {
self.ratio * f64::from(electorate.max(1))
}
}
/// The median of the numbers of voters, `None` for none.
pub fn median(values: &[u32]) -> Option<f64> {
let mut sorted = values.to_vec();
sorted.sort_unstable();
let n = sorted.len();
let mid = n / 2;
match n {
0 => None,
_ if n % 2 == 1 => Some(f64::from(sorted[mid])),
_ => Some((f64::from(sorted[mid - 1]) + f64::from(sorted[mid])) / 2.0),
}
}
/// Within [1 %, 50 %]; anything that is not a number goes to the lowest bound.
fn bounded(ratio: f64) -> f64 {
if ratio.is_nan() { Participation::LOWEST } else { ratio.clamp(Participation::LOWEST, Participation::HIGHEST) }
}
/// G_k at the usual participation p̃ (brought back within the bounds).
pub fn gradient(level: InertiaLevel, participation: f64) -> f64 {
let m = level.majority_pct() / 100.0;
let s = level.share_pct() / 100.0;
// Both logarithms are negative: 0 < 1 − (s − M)/(1 − M) < 1 since M < s < 1,
// and 0 < p̃ < 1 within the bounds.
(1.0 - (s - m) / (1.0 - m)).ln() / bounded(participation).ln()
}
/// What the votes of a group are judged by: its electorate W, its floor C and
/// its usual participation p̃.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Scale {
pub electorate: u32,
pub floor: f64,
/// p̃.
pub participation: f64,
}
impl Scale {
/// The letters of the formula for a level.
pub fn params(&self, level: InertiaLevel) -> FormulaParams {
FormulaParams {
majority_pct: level.majority_pct(),
gradient: gradient(level, self.participation),
base: FormulaParams::DEFAULT_BASE,
constant: self.floor,
}
}
pub fn evaluate(&self, tally: Tally, level: InertiaLevel) -> Result<Evaluation, ThresholdError> {
evaluate(tally, self.electorate, &self.params(level))
}
/// ⌈R⌉ for a level at `cast` votes.
pub fn required(&self, cast: u32, level: InertiaLevel) -> Result<u32, ThresholdError> {
crate::threshold::wot_threshold(self.electorate, cast, &self.params(level))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::threshold::required_ratio;
const W: u32 = 7224;
const C: f64 = 50.0;
fn at_median(voters: u32) -> Scale {
Scale { electorate: W, floor: C, participation: f64::from(voters) / f64::from(W) }
}
fn row(scale: &Scale, cast: u32) -> Vec<u32> {
InertiaLevel::ALL.iter().map(|k| scale.required(cast, *k).expect("valid")).collect()
}
#[test]
fn five_levels_three_in_the_middle() {
let m: Vec<f64> = InertiaLevel::ALL.iter().map(|k| k.majority_pct()).collect();
let s: Vec<f64> = InertiaLevel::ALL.iter().map(|k| k.share_pct()).collect();
assert_eq!(m, [50.0, 50.0, 55.0, 60.0, 66.0]);
assert_eq!(s, [60.0, 67.0, 75.0, 83.0, 90.0]);
for k in InertiaLevel::ALL {
assert!(k.share_pct() > k.majority_pct(), "s_k above M_k keeps G positive");
assert_eq!(InertiaLevel::parse(&k.as_db()), Some(k));
}
assert_eq!(InertiaLevel::default().get(), 3);
assert_eq!(InertiaLevel::new(0), None);
assert_eq!(InertiaLevel::new(6), None);
}
#[test]
fn the_four_words_of_before_read_as_levels() {
let read = |w: &str| InertiaLevel::parse(w).map(InertiaLevel::get);
assert_eq!([read("low"), read("standard"), read("high"), read("max")], [Some(2), Some(3), Some(4), Some(5)]);
assert_eq!(read("maximale"), None);
let from_json = |v: &str| serde_json::from_str::<InertiaLevel>(v).ok().map(InertiaLevel::get);
assert_eq!([from_json("4"), from_json("\"standard\""), from_json("\"5\"")], [Some(4), Some(3), Some(5)]);
assert_eq!(from_json("0"), None);
assert_eq!(from_json("\"6\""), None);
assert_eq!(serde_json::to_string(&InertiaLevel::HIGHEST).expect("json"), "5");
}
#[test]
fn the_values_shown_to_yvv() {
// W 7224, C 50, a median of 120 voters (the smith vote of 2024).
let scale = at_median(120);
assert_eq!(row(&scale, 120), [92, 97, 103, 109, 113]);
assert_eq!(row(&scale, 500), [306, 329, 362, 397, 432]);
assert_eq!(row(&scale, 2000), [1091, 1145, 1271, 1404, 1549]);
}
#[test]
fn a_median_of_360_voters() {
let scale = at_median(360);
assert_eq!(row(&scale, 120), [95, 101, 106, 112, 116]);
assert_eq!(row(&scale, 360), [236, 258, 283, 308, 329]);
assert_eq!(row(&scale, 500), [316, 345, 381, 416, 449]);
assert_eq!(row(&scale, 2000), [1114, 1184, 1318, 1460, 1608]);
}
#[test]
fn at_the_usual_participation_the_share_beyond_c_is_s_k() {
for (w, c, voters) in [(7224, 50.0, 120), (7224, 50.0, 360), (5745, 50.0, 300), (40, 1.0, 9), (2, 1.0, 1)] {
let scale = Scale { electorate: w, floor: c, participation: f64::from(voters) / f64::from(w) };
for k in InertiaLevel::ALL {
let ratio = required_ratio(w, voters, &scale.params(k));
assert!((ratio - k.share_pct() / 100.0).abs() < 1e-12, "W {w}, k {}: {ratio}", k.get());
}
}
}
#[test]
fn a_threshold_landing_on_a_whole_number_stays_on_it() {
// Level 5 at the median: 90 % of the 70 votes beyond C is exactly 63.
assert_eq!(at_median(120).required(120, InertiaLevel::HIGHEST), Ok(113));
// Level 1: 60 % of 70 is exactly 42.
assert_eq!(at_median(120).required(120, InertiaLevel::LOWEST), Ok(92));
}
#[test]
fn the_threshold_rises_with_the_level() {
for (w, c) in [(7224, 50.0), (5745, 50.0), (40, 1.0), (2, 1.0), (1, 1.0)] {
for voters in [1, 2, 9, 60, 120, 360, 3000] {
let scale = Scale {
electorate: w,
floor: c,
participation: Participation::of(w, c, Some(f64::from(voters)), 9).ratio,
};
for cast in (0..=w.min(4000)).step_by(7).chain([w]) {
let r = row(&scale, cast);
assert!(r.windows(2).all(|p| p[0] <= p[1]), "W {w}, p̃ {voters}, T {cast}: {r:?}");
let ratios: Vec<f64> =
InertiaLevel::ALL.iter().map(|k| required_ratio(w, cast, &scale.params(*k))).collect();
if cast > 0 && cast < w {
assert!(
ratios.windows(2).all(|p| p[0] < p[1]),
"strictly below full participation: {ratios:?}"
);
}
}
}
}
}
#[test]
fn the_share_relaxes_toward_m_as_participation_rises() {
for voters in [120, 360] {
let scale = at_median(voters);
for k in InertiaLevel::ALL {
let p = scale.params(k);
let mut last = 1.0;
for cast in (1..=W).step_by(13).chain([W]) {
let ratio = required_ratio(W, cast, &p);
assert!(ratio <= last, "k {}: the share never rises with participation", k.get());
assert!(ratio >= k.majority_pct() / 100.0 - 1e-12);
last = ratio;
}
assert!((required_ratio(W, W, &p) - k.majority_pct() / 100.0).abs() < 1e-12, "M at full participation");
}
}
}
#[test]
fn c_stays_a_floor_one_vote_never_passes_in_monnaie_libre() {
for voters in [1, 72, 120, 360, 3612] {
let scale = Scale {
electorate: W,
floor: C,
participation: Participation::of(W, C, Some(f64::from(voters)), 9).ratio,
};
for k in InertiaLevel::ALL {
let lone = scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid");
assert!(!lone.reached, "k {}", k.get());
assert_eq!(lone.required, 50);
for cast in 1..=50 {
assert!(scale.required(cast, k).expect("valid") >= 50, "nothing passes under C for");
}
assert!(!scale.evaluate(Tally { for_votes: 49, against: 0 }, k).expect("valid").reached);
assert!(scale.evaluate(Tally { for_votes: 50, against: 0 }, k).expect("valid").reached);
}
}
}
#[test]
fn the_median() {
assert_eq!(median(&[]), None);
assert_eq!(median(&[7]), Some(7.0));
assert_eq!(median(&[3, 1, 2]), Some(2.0));
assert_eq!(median(&[4, 1, 3, 2]), Some(2.5));
assert_eq!(median(&[1, 1, 1, 200, 300]), Some(1.0), "one big vote does not move it");
}
#[test]
fn the_reference_stands_in_until_five_proposals_are_voted_on() {
// Monnaie libre: 2 × 50 / 7224 = 1.38 %, a hundred voters.
assert!((Participation::reference_ratio(W, C) - 100.0 / 7224.0).abs() < 1e-15);
let early = Participation::of(W, C, Some(120.0), 4);
assert_eq!(early.source, ParticipationSource::Reference);
assert!((early.voters(W) - 100.0).abs() < 1e-9);
assert_eq!((early.sample, early.median, early.bounded), (4, Some(120.0), false));
let none = Participation::of(W, C, None, 0);
assert_eq!(none.source, ParticipationSource::Reference);
let measured = Participation::of(W, C, Some(120.0), 5);
assert_eq!(measured.source, ParticipationSource::Median);
assert!((measured.voters(W) - 120.0).abs() < 1e-9);
assert!(!measured.bounded);
}
#[test]
fn participation_is_kept_within_one_and_fifty_percent() {
// Two voters on 7224: brought up to 1 %.
let low = Participation::of(W, C, Some(2.0), 9);
assert_eq!((low.ratio, low.bounded), (0.01, true));
// A group where everyone votes: brought down to half.
let all = Participation::of(12, 1.0, Some(12.0), 9);
assert_eq!((all.ratio, all.bounded), (0.5, true));
// The test group, W = 2, C = 1: the reference is 100 %, kept at half.
let essai = Participation::of(2, 1.0, None, 0);
assert_eq!((essai.ratio, essai.source, essai.bounded), (0.5, ParticipationSource::Reference, true));
// A tiny electorate never breaks G.
for w in [1, 2, 3] {
let p = Participation::of(w, 1.0, Some(1.0), 9);
for k in InertiaLevel::ALL {
let g = gradient(k, p.ratio);
assert!(g.is_finite() && g > 0.0, "W {w}, k {}: {g}", k.get());
}
}
// Whatever reaches it, G stays finite and positive.
for bad in [0.0, 1.0, -3.0, 7.0, f64::NAN, f64::INFINITY] {
let g = gradient(InertiaLevel::DEFAULT, bad);
assert!(g.is_finite() && g > 0.0, "{bad}: {g}");
}
}
#[test]
fn the_test_group_needs_everyone() {
// W = 2, C = 1: both must vote for, at every level.
let scale = Scale { electorate: 2, floor: 1.0, participation: Participation::of(2, 1.0, None, 0).ratio };
for k in InertiaLevel::ALL {
assert!(!scale.evaluate(Tally { for_votes: 1, against: 0 }, k).expect("valid").reached);
assert!(scale.evaluate(Tally { for_votes: 2, against: 0 }, k).expect("valid").reached);
}
}
#[test]
fn gradients_at_a_median_of_120() {
let g: Vec<f64> = InertiaLevel::ALL.iter().map(|k| gradient(*k, 120.0 / 7224.0)).collect();
let expected = [0.054_455, 0.101_404, 0.143_442, 0.208_818, 0.298_651];
for (got, want) in g.iter().zip(expected) {
assert!((got - want).abs() < 1e-5, "{g:?}");
}
}
}
+3 -1
View File
@@ -9,8 +9,10 @@
pub mod assist; pub mod assist;
pub mod compose; pub mod compose;
pub mod edit; pub mod edit;
pub mod inertia;
pub mod names; pub mod names;
pub mod pending; pub mod pending;
pub mod threshold; pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally}; pub use inertia::{InertiaLevel, Participation, Scale};
pub use threshold::{FormulaParams, Tally};
+32 -74
View File
@@ -12,66 +12,13 @@
//! (Yvv, 2026-10-21: « Nombre minimal de votants »). //! (Yvv, 2026-10-21: « Nombre minimal de votants »).
//! //!
//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the //! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the
//! Python reference, with one deliberate guard — the participation ratio is //! Python reference, with two deliberate guards — the participation ratio is
//! capped at 1, so a stale W can never lower the bar below M. //! capped at 1, so a stale W can never lower the bar below M; and a count that
//! lands on a whole number stays on it (`settle`), so a rounding error never
//! adds a vote. Which M and G a text is judged by: `inertia` (five levels).
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The four inertia levels of a modular text. Wiring of the v2 maquette:
/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InertiaPreset {
Low,
Standard,
High,
Max,
}
impl InertiaPreset {
pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max];
pub fn majority_pct(self) -> f64 {
match self {
Self::Low | Self::Standard => 50.0,
Self::High => 60.0,
Self::Max => 66.0,
}
}
pub fn gradient(self) -> f64 {
match self {
Self::Low => 0.1,
Self::Standard => 0.2,
Self::High => 0.4,
Self::Max => 0.6,
}
}
/// « Inertie + » : one level up, capped at max. A change of inertia is
/// itself voted at the raised level of the unit it touches.
pub fn raised(self) -> Self {
match self {
Self::Low => Self::Standard,
Self::Standard => Self::High,
Self::High | Self::Max => Self::Max,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Standard => "standard",
Self::High => "high",
Self::Max => "max",
}
}
pub fn parse(raw: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.as_str() == raw)
}
}
/// The letters of the formula, named. /// The letters of the formula, named.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FormulaParams { pub struct FormulaParams {
@@ -87,10 +34,6 @@ pub struct FormulaParams {
impl FormulaParams { impl FormulaParams {
pub const DEFAULT_BASE: f64 = 0.1; pub const DEFAULT_BASE: f64 = 0.1;
pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self {
Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant }
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -119,12 +62,20 @@ pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Resu
let raw = if cast == 0 { let raw = if cast == 0 {
c + offset c + offset
} else { } else {
c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0) c + offset + settle(required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0))
}; };
// `raw` is finite and non-negative for any valid input; saturate rather than wrap. // `raw` is finite and non-negative for any valid input; saturate rather than wrap.
Ok(clamp_ceil(raw)) Ok(clamp_ceil(raw))
} }
/// A count of votes that lands on a whole number stays on it: a rounding error
/// of the logarithms (a billionth of a vote) must never raise a threshold by
/// one. At the usual participation, 90 % of the 70 votes beyond C is exactly 63.
fn settle(votes: f64) -> f64 {
let whole = votes.round();
if (votes - whole).abs() < 1e-9 { whole } else { votes }
}
fn clamp_ceil(raw: f64) -> u32 { fn clamp_ceil(raw: f64) -> u32 {
let ceiled = raw.ceil(); let ceiled = raw.ceil();
if ceiled <= 0.0 { if ceiled <= 0.0 {
@@ -193,9 +144,16 @@ mod tests {
FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 } FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 }
} }
/// The letters of the « standard » inertia before the five levels: M 50 %, G 0.2.
fn standard(constant: f64) -> FormulaParams {
FormulaParams { constant, ..g1vote(50.0, 0.2) }
}
#[test] #[test]
fn reference_case_of_the_smith_vote() { fn reference_case_of_the_smith_vote() {
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94. // Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94.
// The formula's own reference, letters given: the five levels (ADR 0007)
// choose M and G, the formula that turns them into a threshold is this one.
let p = g1vote(50.0, 0.2); let p = g1vote(50.0, 0.2);
assert_eq!(wot_threshold(7224, 120, &p), Ok(94)); assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid"); let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
@@ -218,9 +176,9 @@ mod tests {
#[test] #[test]
fn one_vote_passes_without_floor_and_never_with_it() { fn one_vote_passes_without_floor_and_never_with_it() {
// The trap of a permanent vote: a lone "for" meets the inertial bar. // The trap of a permanent vote: a lone "for" meets the inertial bar.
let bare = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0); let bare = standard(0.0);
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached); assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached);
let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0); let guarded = standard(50.0);
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid"); let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid");
assert!(!e.reached); assert!(!e.reached);
assert_eq!(e.required, 50); assert_eq!(e.required, 50);
@@ -230,7 +188,7 @@ mod tests {
#[test] #[test]
fn floor_is_a_minimum_of_for_votes() { fn floor_is_a_minimum_of_for_votes() {
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0); let p = standard(50.0);
// 50 unanimous votes reach it, 49 do not. // 50 unanimous votes reach it, 49 do not.
assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached); assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached);
assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached); assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached);
@@ -241,7 +199,7 @@ mod tests {
#[test] #[test]
fn no_vote_is_never_reached() { fn no_vote_is_never_reached() {
let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0); let p = g1vote(50.0, 0.1);
let e = evaluate(Tally::default(), 3, &p).expect("valid"); let e = evaluate(Tally::default(), 3, &p).expect("valid");
assert!(!e.reached); assert!(!e.reached);
assert!(!e.well_rated); assert!(!e.well_rated);
@@ -250,7 +208,7 @@ mod tests {
#[test] #[test]
fn small_group_needs_everyone() { fn small_group_needs_everyone() {
// Test group of two people, C = 1: both must vote for. // Test group of two people, C = 1: both must vote for.
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0); let p = standard(1.0);
assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached); assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached); assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
} }
@@ -266,13 +224,13 @@ mod tests {
} }
#[test] #[test]
fn inertia_levels_rise_and_cap() { fn a_whole_number_of_votes_never_gains_one_by_rounding() {
assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard); // 101 members, everyone votes, M 55 %: 1 + 55 % of 100 = 56. In floating
assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High); // point 0.55 × 100 is 55.000000000000007, which would ask for 57.
assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max); let p = FormulaParams { majority_pct: 55.0, gradient: 0.3, base: 0.1, constant: 1.0 };
for p in InertiaPreset::ALL { assert_eq!(wot_threshold(101, 101, &p), Ok(56));
assert_eq!(InertiaPreset::parse(p.as_str()), Some(p)); // A genuine fraction still rounds up.
} assert_eq!(wot_threshold(101, 100, &FormulaParams { constant: 0.0, ..p }), Ok(56));
} }
#[test] #[test]
@@ -0,0 +1,50 @@
-- Inertia in five levels (2026-10-30, ADR 0007). Additive: nothing is lost.
--
-- The four words of before become levels: low → 2, standard → 3, high → 4,
-- max → 5 (level 1 is new, below them). Each word is first kept as it was, in a
-- column of its own, then its level is written in its place. The adoptions are
-- left as they are: their `params` hold the M and G each was judged by. The
-- server still reads the four words (a row written by an older image).
ALTER TABLE units ADD COLUMN inertia_legacy TEXT; -- the word before 2026-10-30, as it was
UPDATE units SET inertia_legacy = inertia WHERE inertia IN ('low', 'standard', 'high', 'max');
UPDATE units SET inertia = CASE inertia
WHEN 'low' THEN '2' WHEN 'standard' THEN '3' WHEN 'high' THEN '4' WHEN 'max' THEN '5' END
WHERE inertia IN ('low', 'standard', 'high', 'max');
ALTER TABLE proposals ADD COLUMN inertia_target_legacy TEXT; -- the word before 2026-10-30, as it was
UPDATE proposals SET inertia_target_legacy = inertia_target WHERE inertia_target IN ('low', 'standard', 'high', 'max');
UPDATE proposals SET inertia_target = CASE inertia_target
WHEN 'low' THEN '2' WHEN 'standard' THEN '3' WHEN 'high' THEN '4' WHEN 'max' THEN '5' END
WHERE inertia_target IN ('low', 'standard', 'high', 'max');
-- Every change of the inertia of an article from now on, by a vote or by an
-- amendment of the seed: what the article says of its own inertia.
CREATE TABLE inertia_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
unit_id TEXT NOT NULL REFERENCES units(id),
doc_id TEXT NOT NULL REFERENCES documents(id),
from_level TEXT NOT NULL,
to_level TEXT NOT NULL,
cause TEXT NOT NULL, -- vote | amendment
proposal_id TEXT, -- the change of inertia voted
amendment_id TEXT, -- the amendment applied
at TEXT NOT NULL
);
CREATE INDEX inertia_changes_unit ON inertia_changes(unit_id);
-- Inertia amendments (src/inertia_amend.rs): a starting level of the seed raised
-- in an existing base, decided once per document and only on an article whose
-- inertia no vote ever changed. Each decision is kept here: applied, refused
-- (and why), or already present.
CREATE TABLE inertia_amendments (
amendment_id TEXT NOT NULL,
doc_id TEXT NOT NULL REFERENCES documents(id),
unit_code TEXT NOT NULL,
from_level TEXT, -- the level found
to_level TEXT NOT NULL,
outcome TEXT NOT NULL, -- applied | refused | present
reason TEXT, -- why it was refused
at TEXT NOT NULL,
PRIMARY KEY (amendment_id, doc_id)
);
+39 -8
View File
@@ -1,8 +1,9 @@
//! Entering into force — automatic, as soon as a proposal reaches its //! Entering into force — automatic, as soon as a proposal reaches its
//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle //! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle
//! remonte en vigueur »). The human gesture is the vote itself; the engine //! remonte en vigueur »). The human gesture is the vote itself; the engine
//! only counts. Every entry is recorded with its numbers and queued for //! only counts. Every entry is recorded with its numbers — the level of
//! publication to the texts repository. //! inertia, M, G, s_k and the usual participation it was judged by — and
//! queued for publication to the texts repository.
use crate::AppState; use crate::AppState;
use crate::docs::{self, DocRow, DocState}; use crate::docs::{self, DocRow, DocState};
@@ -66,16 +67,16 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
let st = docs::load_state(state, doc_id).await?; let st = docs::load_state(state, doc_id).await?;
confirm_seeds(state, &st).await?; confirm_seeds(state, &st).await?;
let units = st.unit_states(); let units = st.unit_states();
let winners = compose::adoptions(&units, &st.proposal_states(), st.electorate, st.doc.vote_floor) let scale = st.scale();
let winners = compose::adoptions(&units, &st.proposal_states(), &scale)
.map_err(|_| crate::error::AppError::Invalid("electorate"))?; .map_err(|_| crate::error::AppError::Invalid("electorate"))?;
let mut entered = Vec::new(); let mut entered = Vec::new();
for id in winners { for id in winners {
let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue }; let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue };
let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue }; let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue };
let us = units.iter().find(|u| u.id == unit.id);
let tally = st.tally(&p.id); let tally = st.tally(&p.id);
let eval = st.evaluation(unit, p); let eval = st.evaluation(unit, p);
let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor)); let params = kept_params(&st, unit, p);
let now = util::now(); let now = util::now();
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
// The text that enters is the text that was counted: a revision that landed // The text that enters is the text that was counted: a revision that landed
@@ -140,10 +141,22 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?") sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(target.as_str()) .bind(target.as_db())
.bind(&unit.id) .bind(&unit.id)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
sqlx::query(
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, proposal_id, at)
VALUES (?, ?, ?, ?, 'vote', ?, ?)",
)
.bind(&unit.id)
.bind(&st.doc.id)
.bind(docs::inertia_of(unit).as_db())
.bind(target.as_db())
.bind(&p.id)
.bind(&now)
.execute(&mut *tx)
.await?;
"inertia" "inertia"
} }
}; };
@@ -159,7 +172,7 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
.bind(i64::from(tally.against)) .bind(i64::from(tally.against))
.bind(i64::from(st.electorate)) .bind(i64::from(st.electorate))
.bind(eval.map_or(0, |e| i64::from(e.required))) .bind(eval.map_or(0, |e| i64::from(e.required)))
.bind(serde_json::to_string(&params).unwrap_or_default()) .bind(params.to_string())
.bind(&now) .bind(&now)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -174,12 +187,30 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
Ok(entered) Ok(entered)
} }
/// What an adoption keeps of the formula it was judged by. The four letters
/// stay where the rows of before have them (`majority_pct`, `gradient`, `base`,
/// `constant`: read as they were written); the level and the scale join them.
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
let level = st.voting_level(unit, p);
let f = st.scale().params(level);
serde_json::json!({
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "constant": f.constant,
"level": level, "share_pct": level.share_pct(),
"participation": st.participation.ratio, "participation_source": st.participation.source,
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
})
}
/// The electorate changes (the web of trust grows or shrinks): every few /// The electorate changes (the web of trust grows or shrinks): every few
/// minutes, every document is passed through the engine again. /// minutes, every document is passed through the engine again — after the
/// usual participation of each group is measured again, once a day.
pub async fn sweeper(state: AppState) { pub async fn sweeper(state: AppState) {
let mut tick = tokio::time::interval(Duration::from_secs(300)); let mut tick = tokio::time::interval(Duration::from_secs(300));
loop { loop {
tick.tick().await; tick.tick().await;
if let Err(e) = crate::participation::refresh(&state, false).await {
tracing::warn!(error = %e, "usual participation");
}
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await { let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
Ok(ids) => ids, Ok(ids) => ids,
Err(e) => { Err(e) => {
+117 -20
View File
@@ -6,11 +6,13 @@
use crate::AppState; use crate::AppState;
use crate::auth::{IdentityRow, User}; use crate::auth::{IdentityRow, User};
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::participation;
use crate::util::{self, LangMap}; use crate::util::{self, LangMap};
use axum::Json; use axum::Json;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate}; use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
@@ -231,16 +233,17 @@ impl ProposalRow {
pub fn kind(&self) -> ProposalKind { pub fn kind(&self) -> ProposalKind {
match self.kind.as_str() { match self.kind.as_str() {
"remove" => ProposalKind::Remove, "remove" => ProposalKind::Remove,
"inertia" => ProposalKind::Inertia( "inertia" => {
self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard), ProposalKind::Inertia(self.inertia_target.as_deref().and_then(InertiaLevel::parse).unwrap_or_default())
), }
_ => ProposalKind::Text, _ => ProposalKind::Text,
} }
} }
} }
pub fn inertia_of(unit: &UnitRow) -> InertiaPreset { /// The level of a unit: a digit, or one of the four words of before.
InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard) pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
InertiaLevel::parse(&unit.inertia).unwrap_or_default()
} }
pub struct DocState { pub struct DocState {
@@ -253,6 +256,9 @@ pub struct DocState {
pub signal_counts: HashMap<String, i64>, pub signal_counts: HashMap<String, i64>,
pub electorate: u32, pub electorate: u32,
pub electorate_source: &'static str, pub electorate_source: &'static str,
/// p̃, from the group's last measure (or the reference).
pub participation: Participation,
pub measured_at: Option<String>,
} }
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> { pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
@@ -351,10 +357,40 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
} }
} }
let (electorate, electorate_source) = electorate(state, &group).await?; let (electorate, electorate_source) = electorate(state, &group).await?;
Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source }) let measure = participation::stored(state, &group.slug).await?;
let participation = Participation::of(
electorate,
doc.vote_floor,
measure.as_ref().and_then(|m| m.median),
measure.as_ref().map_or(0, |m| m.sample),
);
let measured_at = measure.map(|m| m.measured_at);
Ok(DocState {
doc,
group,
units,
proposals,
tallies,
blocked,
signal_counts,
electorate,
electorate_source,
participation,
measured_at,
})
} }
impl DocState { impl DocState {
/// What this document's votes are judged by: W, C and p̃.
pub fn scale(&self) -> Scale {
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
}
/// p̃ · W, as a whole number of voters.
pub fn usual_voters(&self) -> u32 {
whole(self.participation.voters(self.electorate))
}
pub fn tally(&self, id: &str) -> Tally { pub fn tally(&self, id: &str) -> Tally {
self.tallies.get(id).copied().unwrap_or_default() self.tallies.get(id).copied().unwrap_or_default()
} }
@@ -396,9 +432,45 @@ impl DocState {
} }
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> { pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None }; self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok()
} }
/// The level a proposal is voted at — a change of inertia inherits its article's.
pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel {
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
compose::voting_level(&us, p.kind())
}
/// The usual participation, as the screen tells it.
pub fn participation_json(&self) -> serde_json::Value {
let p = &self.participation;
json!({
"ratio": p.ratio, "source": p.source, "sample": p.sample, "median": p.median, "bounded": p.bounded,
"voters": self.usual_voters(), "minSample": Participation::MIN_SAMPLE,
"windowDays": participation::WINDOW_DAYS, "everyHours": participation::EVERY_HOURS,
"bounds": [Participation::LOWEST, Participation::HIGHEST], "measuredAt": self.measured_at,
})
}
/// The five levels on this scale: M, G, s_k, and the *for* votes asked at `cast` votes.
pub fn levels_json(&self, cast: u32) -> Vec<serde_json::Value> {
let scale = self.scale();
InertiaLevel::ALL
.iter()
.map(|k| {
let params = scale.params(*k);
json!({
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
})
})
.collect()
}
}
/// A non-negative count, rounded to the nearest whole.
fn whole(x: f64) -> u32 {
if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -462,12 +534,9 @@ fn proposal_json(
let (kind, target) = match p.kind() { let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None), ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None), ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())), ProposalKind::Inertia(t) => ("inertia", Some(t)),
};
let voted_at = match p.kind() {
ProposalKind::Inertia(_) => inertia_of(unit).raised(),
_ => inertia_of(unit),
}; };
let voted_at = st.voting_level(unit, p);
json!({ json!({
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target, "id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label, "title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
@@ -480,7 +549,7 @@ fn proposal_json(
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "tally": st.tally(&p.id),
"evaluation": eval, "evaluation": eval,
"votedAtInertia": voted_at.as_str(), "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id), "blocked": st.blocked.contains(&p.id),
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0), "signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()), "myVote": mine.map(|v| v.choice.as_str()),
@@ -514,6 +583,7 @@ pub async fn document(
.map(|v| (v.proposal_id.clone(), v)) .map(|v| (v.proposal_id.clone(), v))
.collect(); .collect();
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st let units: Vec<_> = st
.units .units
.iter() .iter()
@@ -529,7 +599,8 @@ pub async fn document(
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned()); .and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect(); let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
json!({ json!({
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia, "id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u),
"inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(),
"inForce": in_force, "proposals": proposals, "inForce": in_force, "proposals": proposals,
}) })
}) })
@@ -545,15 +616,41 @@ pub async fn document(
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, "sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
"electorate": st.electorate, "electorateSource": st.electorate_source, "electorate": st.electorate, "electorateSource": st.electorate_source,
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE }, "formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
"participation": st.participation_json(),
"publication": publication, "publication": publication,
}, },
"units": units, "units": units,
"inertia": InertiaPreset::ALL.iter().map(|p| json!({ // The five levels, with the *for* votes asked at the usual participation.
"key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(), "levels": st.levels_json(st.usual_voters()),
})).collect::<Vec<_>>(),
}))) })))
} }
/// The past of each article's inertia (unit id → changes, oldest first).
async fn inertia_changes(state: &AppState, doc_id: &str) -> AppResult<HashMap<String, Vec<serde_json::Value>>> {
#[derive(sqlx::FromRow)]
struct Row {
unit_id: String,
from_level: String,
to_level: String,
cause: String,
at: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT unit_id, from_level, to_level, cause, at FROM inertia_changes WHERE doc_id = ? ORDER BY id",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut out: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
for r in rows {
out.entry(r.unit_id).or_default().push(json!({
"from": InertiaLevel::parse(&r.from_level), "to": InertiaLevel::parse(&r.to_level),
"cause": r.cause, "at": r.at,
}));
}
Ok(out)
}
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> { pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
@@ -598,7 +695,7 @@ pub async fn preview(
Some("wellrated") => PreviewMode::WellRated, Some("wellrated") => PreviewMode::WellRated,
_ => PreviewMode::InForce, _ => PreviewMode::InForce,
}; };
let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, mode) let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode)
.map_err(|_| AppError::Invalid("electorate"))?; .map_err(|_| AppError::Invalid("electorate"))?;
// Translations of the versions shown. // Translations of the versions shown.
let shown: HashSet<String> = units let shown: HashSet<String> = units
+309
View File
@@ -0,0 +1,309 @@
//! Inertia amendments — a starting level of the seed raised in an existing base.
//!
//! Yvv, 2026-10-30: « Tu mets par défaut au milieu (3) pour toutes les
//! propositions, (4) pour les 2 engagements structurels » — Unicité du compte
//! (E1) and Réciprocité (E2). New bases get level 4 from the seed. An existing
//! base, where both are in force at the starting level (`standard`, now 3),
//! gets it through an amendment listed here, and **only if no vote ever
//! changed the article's inertia**:
//!
//! - no adoption of a change of inertia on the article (`adoptions`, and the
//! journal `inertia_changes`);
//! - its level is still the one the amendment raises (the seed's 3).
//!
//! An open proposal to change the inertia does not stop it (orch, 2026-10-30:
//! two such proposals exist in production, I1 → 5 and E1 → 5, at zero votes):
//! it stays open, with its target, and is voted at the article's new level,
//! since a change of inertia inherits it (ADR 0007).
//!
//! Like the seed amendments (`seed_amend`), each document is decided once, at
//! the first start that knows the amendment; the decision is kept in
//! `inertia_amendments` (applied, refused and why, or present) and in the
//! logs. Applied, the level is written in one transaction with a line in
//! `inertia_changes` (the article says so) and, where the document publishes,
//! a row in the publication queue (`document.json` carries the levels).
use crate::AppState;
use crate::seed::SeedDoc;
use crate::util;
use ld_engine::inertia::InertiaLevel;
use sqlx::SqliteConnection;
/// One starting level to raise, in every document instantiated from a seed.
pub struct InertiaAmendment {
/// Stable identifier: each document's decision is kept under it.
pub id: &'static str,
/// Slug of the seed document.
pub doc: &'static str,
/// Code of the article.
pub unit: &'static str,
/// The level it must still hold.
pub from: u8,
/// The level it gets: the seed's today, which must say so.
pub to: u8,
/// Reason given to the publication queue, and so to the commit of the texts.
pub reason: &'static str,
}
pub const AMENDMENTS: &[InertiaAmendment] = &[
InertiaAmendment {
id: "2026-10-30-e1-inertie-4",
doc: "acte-engagement-certification",
unit: "E1",
from: 3,
to: 4,
reason: "inertie de départ relevée : E1, 3 → 4",
},
InertiaAmendment {
id: "2026-10-30-e2-inertie-4",
doc: "acte-engagement-certification",
unit: "E2",
from: 3,
to: 4,
reason: "inertie de départ relevée : E2, 3 → 4",
},
];
/// What the database says of the article.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Facts {
/// `units.inertia` as written.
pub raw: String,
/// Changes of its inertia that a vote brought into force.
pub voted_changes: i64,
/// Open proposals to change its inertia (they stay open; told in the logs).
pub open_proposals: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Apply,
/// The level is already there: nothing to do.
Present,
Refuse(&'static str),
}
/// The rule, apart from the database: a vote on the inertia keeps it.
pub fn verdict(facts: Option<&Facts>, a: &InertiaAmendment) -> Verdict {
let Some(f) = facts else { return Verdict::Refuse("no_article") };
let Some(level) = InertiaLevel::parse(&f.raw) else { return Verdict::Refuse("level_unreadable") };
if level.get() == a.to {
return Verdict::Present;
}
if f.voted_changes > 0 {
Verdict::Refuse("changed_by_vote")
} else if level.get() != a.from {
Verdict::Refuse("level_differs")
} else {
Verdict::Apply
}
}
/// A decision taken at this start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub amendment: &'static str,
pub group: String,
pub verdict: Verdict,
}
/// Decides every amendment not yet decided, document by document.
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[InertiaAmendment]) -> anyhow::Result<Vec<Outcome>> {
let mut out = Vec::new();
for a in list {
if !seed_says(seeds, a) {
continue;
}
let docs: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
.bind(a.doc)
.fetch_all(&state.db)
.await?;
for (doc_id, group, publish_path) in docs {
let Some((v, open)) = decide(state, a, &doc_id, publish_path.is_some()).await? else { continue };
match v {
Verdict::Apply => tracing::info!(
amendment = a.id, %group, unit = a.unit, from = a.from, to = a.to, open_inertia_proposals = open,
"inertia amendment applied"
),
Verdict::Present => {
tracing::info!(amendment = a.id, %group, unit = a.unit, "inertia amendment: level already there")
}
Verdict::Refuse(why) => tracing::warn!(
amendment = a.id, %group, unit = a.unit, reason = why,
"inertia amendment refused: the article keeps its inertia"
),
}
if v == Verdict::Apply && publish_path.is_some() {
state.publish_wake.notify_one();
}
out.push(Outcome { amendment: a.id, group, verdict: v });
}
}
Ok(out)
}
/// The seed must hold the level the amendment gives: a seed edited later never
/// reaches a base by this way.
fn seed_says(seeds: &[SeedDoc], a: &InertiaAmendment) -> bool {
let level = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.units.iter().find(|u| u.code == a.unit));
match level {
Some(u) if u.inertia.get() == a.to => true,
_ => {
tracing::error!(
amendment = a.id,
doc = a.doc,
unit = a.unit,
"inertia amendment: the seed does not give this level, nothing decided"
);
false
}
}
}
async fn facts(conn: &mut SqliteConnection, unit_id: &str, raw: String) -> anyhow::Result<Facts> {
let voted_changes: i64 = sqlx::query_scalar(
"SELECT (SELECT COUNT(*) FROM adoptions WHERE unit_id = ?1 AND kind = 'inertia')
+ (SELECT COUNT(*) FROM inertia_changes WHERE unit_id = ?1 AND cause = 'vote')",
)
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
let open_proposals: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM proposals WHERE unit_id = ? AND kind = 'inertia' AND status = 'open'")
.bind(unit_id)
.fetch_one(&mut *conn)
.await?;
Ok(Facts { raw, voted_changes, open_proposals })
}
/// One document, in one transaction: `None` when it was decided at an earlier start.
async fn decide(
state: &AppState,
a: &InertiaAmendment,
doc_id: &str,
publishes: bool,
) -> anyhow::Result<Option<(Verdict, i64)>> {
let mut tx = state.db.begin().await?;
let decided: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_amendments WHERE amendment_id = ? AND doc_id = ?")
.bind(a.id)
.bind(doc_id)
.fetch_one(&mut *tx)
.await?;
if decided > 0 {
return Ok(None);
}
let unit: Option<(String, String)> = sqlx::query_as("SELECT id, inertia FROM units WHERE doc_id = ? AND code = ?")
.bind(doc_id)
.bind(a.unit)
.fetch_optional(&mut *tx)
.await?;
let found = match &unit {
Some((id, raw)) => Some(facts(&mut tx, id, raw.clone()).await?),
None => None,
};
let mut v = verdict(found.as_ref(), a);
let now = util::now();
let to = InertiaLevel::new(a.to).map(InertiaLevel::as_db).unwrap_or_default();
if let (Verdict::Apply, Some((unit_id, raw))) = (v, &unit) {
// Written only over the level that was judged.
let done = sqlx::query("UPDATE units SET inertia = ? WHERE id = ? AND inertia = ?")
.bind(&to)
.bind(unit_id)
.bind(raw)
.execute(&mut *tx)
.await?;
if done.rows_affected() == 1 {
sqlx::query(
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, amendment_id, at)
VALUES (?, ?, ?, ?, 'amendment', ?, ?)",
)
.bind(unit_id)
.bind(doc_id)
.bind(a.from.to_string())
.bind(&to)
.bind(a.id)
.bind(&now)
.execute(&mut *tx)
.await?;
if publishes {
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
.bind(doc_id)
.bind(a.reason)
.bind(&now)
.bind(&now)
.execute(&mut *tx)
.await?;
}
} else {
v = Verdict::Refuse("changed");
}
}
let (outcome, reason) = match v {
Verdict::Apply => ("applied", None),
Verdict::Present => ("present", None),
Verdict::Refuse(why) => ("refused", Some(why)),
};
sqlx::query(
"INSERT INTO inertia_amendments (amendment_id, doc_id, unit_code, from_level, to_level, outcome, reason, at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(a.id)
.bind(doc_id)
.bind(a.unit)
.bind(unit.as_ref().map(|(_, raw)| raw.clone()))
.bind(&to)
.bind(outcome)
.bind(reason)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((v, found.map_or(0, |f| f.open_proposals))))
}
#[cfg(test)]
mod tests {
use super::*;
const A: InertiaAmendment =
InertiaAmendment { id: "test", doc: "doc", unit: "E1", from: 3, to: 4, reason: "inertie de départ relevée" };
fn at(raw: &str) -> Facts {
Facts { raw: raw.into(), ..Facts::default() }
}
#[test]
fn the_starting_level_is_raised() {
assert_eq!(verdict(Some(&at("3")), &A), Verdict::Apply);
// The word of before, if an older image wrote it.
assert_eq!(verdict(Some(&at("standard")), &A), Verdict::Apply);
}
#[test]
fn an_open_change_of_inertia_does_not_stop_it() {
let f = Facts { open_proposals: 1, ..at("3") };
assert_eq!(verdict(Some(&f), &A), Verdict::Apply);
}
#[test]
fn the_level_already_there_is_left_as_it_is() {
assert_eq!(verdict(Some(&at("4")), &A), Verdict::Present);
assert_eq!(verdict(Some(&at("high")), &A), Verdict::Present);
}
#[test]
fn a_vote_on_the_inertia_keeps_it() {
let cases = [
(None, "no_article"),
(Some(Facts { voted_changes: 1, ..at("3") }), "changed_by_vote"),
(Some(at("5")), "level_differs"),
(Some(at("2")), "level_differs"),
(Some(at("haute")), "level_unreadable"),
];
for (facts, reason) in cases {
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
}
}
}
+6
View File
@@ -11,7 +11,9 @@ pub mod auth;
pub mod config; pub mod config;
pub mod docs; pub mod docs;
pub mod error; pub mod error;
pub mod inertia_amend;
pub mod overview; pub mod overview;
pub mod participation;
pub mod proposals; pub mod proposals;
pub mod publish; pub mod publish;
pub mod seed; pub mod seed;
@@ -197,6 +199,10 @@ pub async fn run() -> anyhow::Result<()> {
let state = AppState::new(cfg, db).await?; let state = AppState::new(cfg, db).await?;
seed::ensure(&state).await?; seed::ensure(&state).await?;
// A measure missed leaves the reference in place; the sweeper takes it again.
if let Err(e) = participation::refresh(&state, false).await {
tracing::warn!(error = %e, "usual participation");
}
auth::bootstrap(&state).await?; auth::bootstrap(&state).await?;
if let Err(e) = publish::ensure_key(&state).await { if let Err(e) = publish::ensure_key(&state).await {
tracing::warn!(error = %e, "publication key"); tracing::warn!(error = %e, "publication key");
+132
View File
@@ -0,0 +1,132 @@
//! p̃ — the usual participation of each group, the anchor of the five levels
//! of inertia (ADR 0007, `ld_engine::inertia`).
//!
//! What is measured: the proposals of the group (every document, every kind,
//! drafts aside) that hold at least one vote on their current revision, the
//! last one cast within the past year. For each, T is the number of those
//! votes **at the time of measuring** — the same T the formula sees, since the
//! vote is permanent. The median of these T, and how many proposals it rests
//! on, are kept in `settings` under `participation:<group>`.
//!
//! When: once a day (at start, then by the sweeper), never at each vote — a
//! threshold that moved with every vote cast elsewhere could not be read. W
//! moves on its own (the web of trust, every six hours): p̃ = T̃ / W follows it
//! at once, so the scale still asks s_k of the same usual number of voters.
use crate::AppState;
use crate::util;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// Only proposals voted on within this many days count.
pub const WINDOW_DAYS: i64 = 365;
/// A measure older than this is taken again.
pub const EVERY_HOURS: i64 = 24;
const KEY: &str = "participation:";
/// What was measured for a group.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Measure {
/// The median number of voters, when at least one proposal was voted on.
pub median: Option<f64>,
/// Proposals it rests on.
pub sample: u32,
pub measured_at: String,
}
/// The numbers of voters of the group's proposals voted on since `since`.
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
GROUP BY v.proposal_id
HAVING MAX(v.cast_at) >= ?",
)
.bind(group)
.bind(since)
.fetch_all(&state.db)
.await?;
Ok(rows.into_iter().map(|n| u32::try_from(n).unwrap_or(u32::MAX)).collect())
}
/// Measures the group now.
pub async fn measure(state: &AppState, group: &str) -> sqlx::Result<Measure> {
let now = OffsetDateTime::now_utc();
let since = util::stamp(now - time::Duration::days(WINDOW_DAYS));
let votes = sample(state, group, &since).await?;
Ok(Measure {
median: ld_engine::inertia::median(&votes),
sample: u32::try_from(votes.len()).unwrap_or(u32::MAX),
measured_at: util::stamp(now),
})
}
/// The last measure kept for the group, if any.
pub async fn stored(state: &AppState, group: &str) -> sqlx::Result<Option<Measure>> {
let raw: Option<String> = sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(format!("{KEY}{group}"))
.fetch_optional(&state.db)
.await?;
Ok(raw.and_then(|v| serde_json::from_str(&v).ok()))
}
async fn keep(state: &AppState, group: &str, m: &Measure) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
)
.bind(format!("{KEY}{group}"))
.bind(serde_json::to_string(m)?)
.bind(&m.measured_at)
.execute(&state.db)
.await?;
Ok(())
}
fn is_stale(m: &Measure, now: OffsetDateTime) -> bool {
OffsetDateTime::parse(&m.measured_at, &Rfc3339)
.map_or(true, |at| now - at >= time::Duration::hours(EVERY_HOURS) || at > now)
}
/// Measures again every group whose measure is missing or a day old (all of
/// them with `force`). Returns the groups measured.
pub async fn refresh(state: &AppState, force: bool) -> anyhow::Result<Vec<String>> {
let groups: Vec<String> =
sqlx::query_scalar("SELECT slug FROM groups ORDER BY position").fetch_all(&state.db).await?;
let now = OffsetDateTime::now_utc();
let mut done = Vec::new();
for group in groups {
let due = force || stored(state, &group).await?.is_none_or(|m| is_stale(&m, now));
if !due {
continue;
}
let m = measure(state, &group).await?;
keep(state, &group, &m).await?;
tracing::info!(%group, median = ?m.median, sample = m.sample, "usual participation measured");
done.push(group);
}
Ok(done)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_measure_is_taken_again_after_a_day() {
let now = OffsetDateTime::now_utc();
let at =
|hours: i64| Measure { measured_at: util::stamp(now - time::Duration::hours(hours)), ..Measure::default() };
assert!(!is_stale(&at(1), now));
assert!(!is_stale(&at(23), now));
assert!(is_stale(&at(24), now));
assert!(is_stale(&at(-2), now), "a measure from the future is taken again");
assert!(is_stale(&Measure::default(), now), "unreadable");
}
}
+23 -21
View File
@@ -21,7 +21,7 @@ use axum::Json;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint}; use ld_engine::assist::{self, Hint};
use ld_engine::edit::{self, EditKind}; use ld_engine::edit::{self, EditKind};
use ld_engine::threshold::InertiaPreset; use ld_engine::inertia::InertiaLevel;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@@ -191,13 +191,14 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone()); let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
sqlx::query( sqlx::query(
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at) "INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)", VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
) )
.bind(&id) .bind(&id)
.bind(&st.doc.id) .bind(&st.doc.id)
.bind(format!("N{}", n + 1)) .bind(format!("N{}", n + 1))
.bind(section) .bind(section)
.bind(position_after(&st.units, after)) .bind(position_after(&st.units, after))
.bind(InertiaLevel::DEFAULT.as_db())
.bind(util::now()) .bind(util::now())
.execute(&state.db) .execute(&state.db)
.await?; .await?;
@@ -626,28 +627,33 @@ pub async fn detail(
_ => None, _ => None,
}; };
let eval = st.evaluation(unit, &p); let eval = st.evaluation(unit, &p);
let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) }; let voted_at = st.voting_level(unit, &p);
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
let tr = docs::translations_for(&state, &[&p]).await?; let tr = docs::translations_for(&state, &[&p]).await?;
Ok(Json(json!({ Ok(Json(json!({
"proposal": { "proposal": {
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body, "id": p.id, "kind": p.kind, "inertiaTarget": target, "title": p.title, "body": p.body,
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision, "status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false), "author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
"mine": p.author_id.as_deref() == Some(user.id.as_str()), "mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at, "submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", "seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(), "tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id), "blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision), "myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
"translations": tr.get(&p.id).cloned().unwrap_or_default(), "translations": tr.get(&p.id).cloned().unwrap_or_default(),
}, },
"unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia }, "unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })), "inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
"diff": diff, "diff": diff,
"history": history, "history": history,
"signals": signals, "signals": signals,
"electorate": st.electorate, "electorate": st.electorate,
"electorateSource": st.electorate_source,
"voteFloor": st.doc.vote_floor, "voteFloor": st.doc.vote_floor,
"participation": st.participation_json(),
// The five levels, with the *for* votes each would ask at this version's participation.
"levels": st.levels_json(st.tally(&p.id).cast()),
}))) })))
} }
@@ -783,18 +789,20 @@ pub async fn lift_signal(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct InertiaBody { pub struct InertiaBody {
target: String, /// A level, 1 to 5 (a word of before is read as its level).
target: serde_json::Value,
} }
/// Propose a change of inertia; it is voted one level above the unit. An open /// Propose a change of inertia; it is voted at the level of the unit (it
/// proposal for the same target is reused rather than duplicated. /// inherits it, ADR 0007). An open proposal for the same target is reused
/// rather than duplicated.
pub async fn propose_inertia( pub async fn propose_inertia(
State(state): State<AppState>, State(state): State<AppState>,
User(user): User, User(user): User,
Path(unit_id): Path<String>, Path(unit_id): Path<String>,
Json(b): Json<InertiaBody>, Json(b): Json<InertiaBody>,
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?; let target = serde_json::from_value::<InertiaLevel>(b.target).map_err(|_| AppError::Invalid("inertia"))?;
let doc_id = doc_of_unit(&state, &unit_id).await?; let doc_id = doc_of_unit(&state, &unit_id).await?;
let st = docs::load_state(&state, &doc_id).await?; let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?; docs::require_open(&state, &user, &st.group).await?;
@@ -806,17 +814,18 @@ pub async fn propose_inertia(
p.unit_id == unit_id p.unit_id == unit_id
&& p.kind == "inertia" && p.kind == "inertia"
&& p.status == "open" && p.status == "open"
&& p.inertia_target.as_deref() == Some(target.as_str()) && p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(target)
}) { }) {
return Ok(Json(json!({ "id": existing.id, "existing": true }))); return Ok(Json(json!({ "id": existing.id, "existing": true })));
} }
let title = format!("inertia:{}", target.as_str()); let title = format!("inertia:{}", target.get());
let level = target.as_db();
let id = insert_proposal( let id = insert_proposal(
&state, &state,
NewProposal { NewProposal {
unit_id: &unit_id, unit_id: &unit_id,
kind: "inertia", kind: "inertia",
inertia_target: Some(target.as_str()), inertia_target: Some(&level),
title: &title, title: &title,
body: "", body: "",
author: &user.id, author: &user.id,
@@ -869,14 +878,7 @@ mod tests {
use super::*; use super::*;
fn unit(id: &str, position: f64) -> UnitRow { fn unit(id: &str, position: f64) -> UnitRow {
UnitRow { UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "3".into(), in_force_id: None }
id: id.into(),
code: id.into(),
section: None,
position,
inertia: "standard".into(),
in_force_id: None,
}
} }
#[test] #[test]
+1 -1
View File
@@ -347,7 +347,7 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(Stri
} }
let tally = st.tally(&p.id); let tally = st.tally(&p.id);
json!({ json!({
"code": u.code, "title": title, "body": body, "inertia": u.inertia, "code": u.code, "title": title, "body": body, "inertia": crate::docs::inertia_of(u),
"status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" }, "status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" },
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against }, "enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
}) })
+11 -5
View File
@@ -5,11 +5,15 @@
//! yet**. An existing document is never instantiated again: from its first //! yet**. An existing document is never instantiated again: from its first
//! vote on, it is runtime data. The one way a newer seed text reaches an //! vote on, it is runtime data. The one way a newer seed text reaches an
//! existing document is a seed amendment (`seed_amend`): listed, decided once, //! existing document is a seed amendment (`seed_amend`): listed, decided once,
//! and applied only to an article nobody has touched. //! and applied only to an article nobody has touched; a newer starting level of
//! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia
//! no vote changed.
use crate::AppState; use crate::AppState;
use crate::inertia_amend;
use crate::seed_amend; use crate::seed_amend;
use crate::util::{self, LangMap}; use crate::util::{self, LangMap};
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize; use serde::Deserialize;
use std::path::Path; use std::path::Path;
@@ -37,7 +41,8 @@ pub struct SeedUnit {
pub code: String, pub code: String,
pub section: Option<String>, pub section: Option<String>,
pub position: f64, pub position: f64,
pub inertia: String, /// A level, 1 to 5 (a word of before is read as its level).
pub inertia: InertiaLevel,
pub versions: Vec<SeedVersion>, pub versions: Vec<SeedVersion>,
} }
@@ -67,11 +72,12 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// On start: the groups, the documents missing from them, then the seed /// On start: the groups, the documents missing from them, then the seed and
/// amendments (each decided once per document). /// inertia amendments (each decided once per document).
pub async fn ensure(state: &AppState) -> anyhow::Result<()> { pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
let seeds = documents(state).await?; let seeds = documents(state).await?;
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?; seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?;
Ok(()) Ok(())
} }
@@ -159,7 +165,7 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a
.bind(&unit.code) .bind(&unit.code)
.bind(&unit.section) .bind(&unit.section)
.bind(unit.position) .bind(unit.position)
.bind(&unit.inertia) .bind(unit.inertia.as_db())
.bind(&now) .bind(&now)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
+15 -6
View File
@@ -112,13 +112,15 @@ async fn the_seed_a_minima_and_the_open_proposals() {
assert_eq!(e1["tally"]["for"], 0); assert_eq!(e1["tally"]["for"], 0);
assert_eq!(doc["doc"]["voteFloor"], 50.0); assert_eq!(doc["doc"]["voteFloor"], 50.0);
assert_eq!(doc["doc"]["electorate"], 5745); assert_eq!(doc["doc"]["electorate"], 5745);
// Every other article is an open proposal at zero votes, standard inertia. // Every other article is an open proposal at zero votes, at the middle level of inertia.
let e3 = unit(&doc, "E3"); let e3 = unit(&doc, "E3");
assert!(e3["inForce"].is_null()); assert!(e3["inForce"].is_null());
// In the order of the seed, whatever their random identifiers. // In the order of the seed, whatever their random identifiers.
let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect(); let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect();
assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant"); assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant");
assert_eq!(e3["inertia"], "standard"); assert_eq!(e3["inertia"], 3);
// The two structural commitments start one level up (Yvv, 2026-10-30).
assert_eq!((unit(&doc, "E1")["inertia"].as_u64(), unit(&doc, "E2")["inertia"].as_u64()), (Some(4), Some(4)));
// Translations seeded. // Translations seeded.
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit")); assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
} }
@@ -375,20 +377,23 @@ async fn a_contradiction_signal_blocks_entering_into_force() {
assert_eq!(v["entered"][0]["code"], "K1"); assert_eq!(v["entered"][0]["code"], "K1");
} }
/// Replaces « inertia change is voted one level up » (2026-10-21): since
/// 2026-10-30 a change of inertia inherits the level of its article (ADR 0007).
#[tokio::test] #[tokio::test]
async fn inertia_change_is_voted_one_level_up() { async fn inertia_change_is_voted_at_the_level_of_the_article() {
let (mut a, _) = Client::new().await; let (mut a, _) = Client::new().await;
a.login("Yvv", false).await; a.login("Yvv", false).await;
let id = doc_id(&mut a, "monnaie-libre").await; let id = doc_id(&mut a, "monnaie-libre").await;
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await; let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned(); let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned();
let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await; let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": 5 }))).await;
assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned(); let p = v["id"].as_str().expect("id").to_owned();
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await; // The word of before reads as its level: the same proposal, not a second one.
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "max" }))).await;
assert_eq!(again["id"], p.as_str()); assert_eq!(again["id"], p.as_str());
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await; let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["votedAtInertia"], "high"); assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(5), Some(4)));
} }
#[tokio::test] #[tokio::test]
@@ -769,6 +774,10 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json"); serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json");
assert_eq!(json["units"].as_array().expect("units").len(), 3); assert_eq!(json["units"].as_array().expect("units").len(), 3);
assert_eq!(json["units"][0]["status"], "seed-minimal"); assert_eq!(json["units"][0]["status"], "seed-minimal");
// Levels of inertia are published as numbers, 1 to 5.
let levels: Vec<Option<u64>> =
json["units"].as_array().expect("units").iter().map(|u| u["inertia"].as_u64()).collect();
assert_eq!(levels, [Some(3), Some(4), Some(4)]);
assert!(show("README.md").contains("libreDecision")); assert!(show("README.md").contains("libreDecision"));
let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL") let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
.fetch_one(&state.db) .fetch_one(&state.db)
+549
View File
@@ -0,0 +1,549 @@
//! Inertia in five levels (ADR 0007): the levels and the scale served, the
//! usual participation of a group, the change of inertia voted at the level
//! of its article, the migration of the four words of before, and the
//! amendment that raises E1 and E2 to level 4 in an existing base.
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use http_body_util::BodyExt;
use ld_engine::inertia::{InertiaLevel, Scale, gradient};
use ld_engine::threshold::required_ratio;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
use serde_json::{Value, json};
use sqlx::SqlitePool;
use std::borrow::Cow;
use tower::ServiceExt;
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
fn of(state: &AppState) -> Self {
Self { router: app(state.clone()), cookie: None }
}
async fn new() -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(Config::for_tests(), db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self::of(&state), state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn get(&mut self, uri: &str) -> Value {
let (s, v) = self.call("GET", uri, None).await;
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
v
}
async fn login(&mut self, name: &str) {
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
}
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
let (s, v) =
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
v
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.get(&format!("/api/groups/{group}")).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.get(&format!("/api/docs/{id}")).await
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
fn level(k: u8) -> InertiaLevel {
InertiaLevel::new(k).expect("1 to 5")
}
fn numbers(levels: &Value, key: &str) -> Vec<f64> {
levels.as_array().expect("levels").iter().map(|l| l[key].as_f64().expect(key)).collect()
}
#[tokio::test]
async fn the_five_levels_and_the_scale_are_served() {
let (mut c, _) = Client::new().await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
// 3 everywhere, 4 for the two structural commitments.
let raised: Vec<&str> = doc["units"]
.as_array()
.expect("units")
.iter()
.filter(|u| u["inertia"] != 3)
.map(|u| u["code"].as_str().expect("code"))
.collect();
assert_eq!(raised, ["E1", "E2"]);
assert!(unit(&doc, "E1")["inertia"] == 4 && unit(&doc, "E2")["inertia"] == 4);
// No vote yet: the reference, 2C/W = 100 voters out of 5745.
let p = &doc["doc"]["participation"];
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["voters"].as_u64()),
(Some("reference"), Some(0), Some(100))
);
assert_eq!(
(p["minSample"].as_u64(), p["windowDays"].as_u64(), p["everyHours"].as_u64()),
(Some(5), Some(365), Some(24))
);
let ratio = p["ratio"].as_f64().expect("ratio");
assert!((ratio - 100.0 / 5745.0).abs() < 1e-12);
let levels = &doc["levels"];
assert_eq!(numbers(levels, "level"), [1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(numbers(levels, "majorityPct"), [50.0, 50.0, 55.0, 60.0, 66.0]);
assert_eq!(numbers(levels, "sharePct"), [60.0, 67.0, 75.0, 83.0, 90.0]);
let scale = Scale { electorate: 5745, floor: 50.0, participation: ratio };
for (l, k) in levels.as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
assert!((l["gradient"].as_f64().expect("G") - gradient(k, ratio)).abs() < 1e-12);
// At the usual participation, C plus s_k of the votes beyond C.
assert_eq!(l["cast"], 100);
assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k).expect("valid"))));
let at_usual = 50.0 + k.share_pct() / 100.0 * 50.0;
assert_eq!(l["required"].as_f64(), Some(at_usual.ceil()));
}
// A version: the five levels at its own participation, and the level it is voted at.
let e1 = unit(&doc, "E1")["inForce"]["id"].as_str().expect("e1").to_owned();
let d = c.get(&format!("/api/proposals/{e1}")).await;
assert_eq!(d["proposal"]["votedAtInertia"], 4);
assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]);
assert_eq!(numbers(&d["levels"], "required"), [50.0; 5], "nothing passes under C");
assert_eq!(d["participation"]["source"], "reference");
assert_eq!(d["electorateSource"], "wot");
}
#[tokio::test]
async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled() {
let (mut a, state) = Client::new().await;
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
let doc = a.doc("essai").await;
let e2 = unit(&doc, "E2")["id"].as_str().expect("e2").to_owned();
let to = |t: Value| json!({ "target": t });
for bad in [json!(0), json!(6), json!("haute"), json!(null)] {
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(bad.clone()))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("inertia")), "{bad}");
}
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(4)))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("same_inertia")));
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(to(json!(1)))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
let d = a.get(&format!("/api/proposals/{p}")).await;
assert_eq!((d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()), (Some(1), Some(4)));
// W(essai) = 2: both vote for, the article goes to level 1.
a.vote(&p, "for").await;
let v = b.vote(&p, "for").await;
assert_eq!(v["entered"][0]["kind"], "inertia");
let doc = a.doc("essai").await;
let e2u = unit(&doc, "E2");
assert_eq!(e2u["inertia"], 1);
let changes = e2u["inertiaChanges"].as_array().expect("changes");
assert_eq!(changes.len(), 1);
assert_eq!(
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
(Some(4), Some(1), Some("vote"))
);
// The adoption keeps the level it was judged at, and the scale.
let params: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = ?")
.bind(&p)
.fetch_one(&state.db)
.await
.expect("adoption");
let params: Value = serde_json::from_str(&params).expect("json");
assert_eq!(
(params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()),
(Some(4), Some(60.0), Some(83.0))
);
assert_eq!(
(params["participation"].as_f64(), params["participation_source"].as_str()),
(Some(0.5), Some("reference"))
);
assert!(params["gradient"].as_f64().is_some_and(|g| (g - gradient(level(4), 0.5)).abs() < 1e-12));
}
#[tokio::test]
async fn the_usual_participation_is_the_median_of_five_voted_proposals() {
let (mut first, state) = Client::new().await;
// Ten people, all members of the test group: W = 10, C = 1.
let mut people = Vec::new();
for name in ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10"] {
let mut c = first.other();
c.login(name).await;
people.push(c);
}
first.login("P1").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();
// 1, 2, 3, 3 and 4 voters (against: nothing enters into force, the count is what matters).
for (p, n) in open.iter().zip([1, 2, 3, 3, 4]) {
for c in people.iter_mut().take(n) {
c.vote(p, "against").await;
}
}
// Measured once a day, not at each vote: until then, the reference.
let doc = first.doc("essai").await;
assert_eq!(doc["doc"]["participation"]["source"], "reference");
assert_eq!(doc["doc"]["electorate"], 10);
let measured = ld_server::participation::refresh(&state, false).await.expect("refresh");
assert!(measured.contains(&"essai".to_owned()));
let doc = first.doc("essai").await;
let p = &doc["doc"]["participation"];
assert_eq!(
(p["source"].as_str(), p["sample"].as_u64(), p["median"].as_f64()),
(Some("median"), Some(5), Some(3.0))
);
assert_eq!((p["ratio"].as_f64(), p["voters"].as_u64(), p["bounded"].as_bool()), (Some(0.3), Some(3), Some(false)));
assert!(p["measuredAt"].is_string());
for (l, k) in doc["levels"].as_array().expect("levels").iter().zip(InertiaLevel::ALL) {
assert!((l["gradient"].as_f64().expect("G") - gradient(k, 0.3)).abs() < 1e-12);
}
// Thresholds follow: the proposal with 4 voters is judged on this scale.
let d = first.get(&format!("/api/proposals/{}", open[4])).await;
let scale = Scale { electorate: 10, floor: 1.0, participation: 0.3 };
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
assert!((ratio - required_ratio(10, 4, &scale.params(level(3)))).abs() < 1e-12);
// A new vote does not move it within the day…
people[4].vote(&open[0], "against").await;
assert!(ld_server::participation::refresh(&state, false).await.expect("refresh").is_empty());
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
// …the next measure sees it: 2, 2, 3, 3, 4.
ld_server::participation::refresh(&state, true).await.expect("refresh");
assert_eq!(first.doc("essai").await["doc"]["participation"]["median"], 3.0);
// A proposal last voted on more than a year ago leaves the sample: four are too few.
sqlx::query("UPDATE votes SET cast_at = '2025-01-01T00:00:00Z' WHERE proposal_id = ?")
.bind(&open[2])
.execute(&state.db)
.await
.expect("age");
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(), p["median"].as_f64()),
(Some("reference"), Some(4), Some(2.5))
);
// The reference of the test group: 2C/W = 20 %.
assert_eq!(p["ratio"].as_f64(), Some(0.2));
// Monnaie libre is measured apart, from its own votes (none).
let ml = first.doc("monnaie-libre").await;
assert_eq!(
(ml["doc"]["participation"]["sample"].as_u64(), ml["doc"]["participation"]["source"].as_str()),
(Some(0), Some("reference"))
);
}
// ---------------------------------------------------------------------------
// The base as production holds it on 2026-10-30, then the new version
// ---------------------------------------------------------------------------
/// Migrations up to 0004: the schema production runs before this version.
async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) {
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
let options = SqliteConnectOptions::from_str("sqlite::memory:").expect("url").foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(1).connect_with(options).await.expect("db");
let mut before = sqlx::migrate!("./migrations");
before.migrations = Cow::Owned(before.migrations.iter().filter(|m| m.version <= 4).cloned().collect());
before.run(&pool).await.expect("migrations 1 to 4");
let mut cfg = Config::for_tests();
cfg.data_dir = std::env::temp_dir().join(format!("ld-levels-{tag}-{}", std::process::id()));
let state = AppState::new(cfg, pool.clone()).await.expect("state");
seed::documents(&state).await.expect("documents");
// The words of the time: every article `standard`.
sqlx::query("UPDATE units SET inertia = 'standard'").execute(&pool).await.expect("words");
(pool, state)
}
/// The new version starts: migration 0005, then the seed and its amendments.
async fn start_new_version(pool: &SqlitePool) -> AppState {
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(pool).await.expect("migrations");
restart(pool).await
}
async fn restart(pool: &SqlitePool) -> AppState {
let state = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
seed::ensure(&state).await.expect("seed");
state
}
async fn unit_id(pool: &SqlitePool, group: &str, code: &str) -> String {
sqlx::query_scalar(
"SELECT u.id FROM units u JOIN documents d ON d.id = u.doc_id WHERE d.group_slug = ? AND u.code = ?",
)
.bind(group)
.bind(code)
.fetch_one(pool)
.await
.expect("unit")
}
/// An open change of inertia written as production holds it (a word, no vote).
async fn legacy_inertia_proposal(pool: &SqlitePool, unit: &str, word: &str) -> String {
let id = format!("legacy-{unit}-{word}");
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, 'inertia', ?, ?, '', 'open', 'member', NULL, 1, '2026-10-29T21:54:00Z', '2026-10-29T21:54:00Z',
'2026-10-29T21:54:00Z')",
)
.bind(&id)
.bind(unit)
.bind(word)
.bind(format!("inertia:{word}"))
.execute(pool)
.await
.expect("proposal");
id
}
type Decision = (String, String, String, Option<String>);
async fn inertia_decisions(pool: &SqlitePool) -> Vec<Decision> {
sqlx::query_as(
"SELECT d.group_slug, a.unit_code, a.outcome, a.reason FROM inertia_amendments a
JOIN documents d ON d.id = a.doc_id ORDER BY d.group_slug, a.unit_code",
)
.fetch_all(pool)
.await
.expect("decisions")
}
fn decided(rows: &[Decision]) -> Vec<(&str, &str, &str, Option<&str>)> {
rows.iter().map(|(g, u, o, r)| (g.as_str(), u.as_str(), o.as_str(), r.as_deref())).collect()
}
#[tokio::test]
async fn a_new_base_starts_with_e1_and_e2_at_level_4() {
let (_, state) = Client::new().await;
assert_eq!(
decided(&inertia_decisions(&state.db).await),
[
("essai", "E1", "present", None),
("essai", "E2", "present", None),
("monnaie-libre", "E1", "present", None),
("monnaie-libre", "E2", "present", None),
]
);
let changes: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&state.db).await.expect("n");
assert_eq!(changes, 0, "born at 4: nothing changed");
}
#[tokio::test]
async fn the_migration_turns_the_four_words_into_levels_and_keeps_them() {
let (pool, _) = base_before_levels("migration").await;
let i1 = unit_id(&pool, "monnaie-libre", "I1").await;
let e3 = unit_id(&pool, "monnaie-libre", "E3").await;
for (unit, word) in [(&i1, "low"), (&e3, "high")] {
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
.bind(word)
.bind(unit)
.execute(&pool)
.await
.expect("word");
}
let p = legacy_inertia_proposal(&pool, &e3, "max").await;
// An adoption of before: its params are the letters it was judged by.
let old_params = r#"{"majority_pct":50.0,"gradient":0.2,"base":0.1,"constant":50.0}"#;
sqlx::query(
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
SELECT 'past', ?, doc_id, 'text', 60, 0, 7224, 58, ?, '2026-10-25T00:00:00Z' FROM units WHERE id = ?",
)
.bind(&e3)
.bind(old_params)
.bind(&e3)
.execute(&pool)
.await
.expect("adoption");
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("migration 0005");
let words: Vec<(String, String, Option<String>)> =
sqlx::query_as("SELECT code, inertia, inertia_legacy FROM units WHERE doc_id = (SELECT doc_id FROM units WHERE id = ?) AND code IN ('I1', 'E1', 'E3') ORDER BY code")
.bind(&i1)
.fetch_all(&pool)
.await
.expect("units");
assert_eq!(
words,
[
("E1".into(), "3".into(), Some("standard".into())),
("E3".into(), "4".into(), Some("high".into())),
("I1".into(), "2".into(), Some("low".into())),
]
);
let target: (Option<String>, Option<String>) =
sqlx::query_as("SELECT inertia_target, inertia_target_legacy FROM proposals WHERE id = ?")
.bind(&p)
.fetch_one(&pool)
.await
.expect("proposal");
assert_eq!(target, (Some("5".into()), Some("max".into())));
let kept: String = sqlx::query_scalar("SELECT params FROM adoptions WHERE proposal_id = 'past'")
.fetch_one(&pool)
.await
.expect("params");
assert_eq!(kept, old_params, "the history is never rewritten");
}
/// Production on 2026-10-30 (orch): every article `standard`, no vote, and two open
/// changes of inertia at zero votes, I1 → max and E1 → max.
#[tokio::test]
async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4() {
let (pool, _) = base_before_levels("production").await;
let mut proposals = Vec::new();
for group in ["monnaie-libre", "essai"] {
for code in ["I1", "E1"] {
let u = unit_id(&pool, group, code).await;
proposals.push((group, code, legacy_inertia_proposal(&pool, &u, "max").await));
}
}
let state = start_new_version(&pool).await;
let first = inertia_decisions(&pool).await;
assert_eq!(
decided(&first),
[
("essai", "E1", "applied", None),
("essai", "E2", "applied", None),
("monnaie-libre", "E1", "applied", None),
("monnaie-libre", "E2", "applied", None),
]
);
let mut c = Client::of(&state);
c.login("Yvv").await;
for group in ["monnaie-libre", "essai"] {
let doc = c.doc(group).await;
let levels: Vec<(&str, u64)> = ["I1", "E1", "E2", "E3"]
.iter()
.map(|code| (*code, unit(&doc, code)["inertia"].as_u64().expect("level")))
.collect();
assert_eq!(levels, [("I1", 3), ("E1", 4), ("E2", 4), ("E3", 3)], "{group}");
// The article says so.
let changes = unit(&doc, "E1")["inertiaChanges"].as_array().expect("changes").clone();
assert_eq!(changes.len(), 1);
assert_eq!(
(changes[0]["from"].as_u64(), changes[0]["to"].as_u64(), changes[0]["cause"].as_str()),
(Some(3), Some(4), Some("amendment"))
);
}
// The open changes stay open, their target read as 5, voted at the article's level.
for (group, code, id) in &proposals {
let d = c.get(&format!("/api/proposals/{id}")).await;
assert_eq!(d["proposal"]["status"], "open", "{group} {code}");
assert_eq!(d["proposal"]["inertiaTarget"], 5, "{group} {code}");
let at = if *code == "E1" { 4 } else { 3 };
assert_eq!(d["proposal"]["votedAtInertia"], at, "{group} {code}");
}
// And votable: in Monnaie libre, judged at level 4.
let (_, _, e1_ml) = &proposals[1];
c.vote(e1_ml, "for").await;
let d = c.get(&format!("/api/proposals/{e1_ml}")).await;
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
let p = d["participation"]["ratio"].as_f64().expect("p");
let scale = Scale { electorate: 5745, floor: 50.0, participation: p };
assert!((ratio - required_ratio(5745, 1, &scale.params(level(4)))).abs() < 1e-12);
assert!((ratio - required_ratio(5745, 1, &scale.params(level(3)))).abs() > 1e-6, "not at the old level");
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
// Republished where the document publishes; never from the test group.
let queued: 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.reason LIKE 'inertie%' ORDER BY o.id",
)
.fetch_all(&pool)
.await
.expect("outbox");
assert_eq!(
queued,
[
("monnaie-libre".to_owned(), "inertie de départ relevée : E1, 3 → 4".to_owned()),
("monnaie-libre".to_owned(), "inertie de départ relevée : E2, 3 → 4".to_owned()),
]
);
// Once only: the next start changes nothing.
let again = restart(&pool).await;
assert_eq!(inertia_decisions(&again.db).await, first);
let changes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM inertia_changes").fetch_one(&pool).await.expect("n");
assert_eq!(changes, 4);
}
#[tokio::test]
async fn an_inertia_changed_by_a_vote_keeps_its_level() {
let (pool, _) = base_before_levels("voted").await;
// The migration runs; a vote changes E2's inertia in the test group, before the
// version that knows the amendment starts.
let mut all = sqlx::migrate!("./migrations");
all.set_ignore_missing(true);
all.run(&pool).await.expect("migrations");
let between = AppState::new(Config::for_tests(), pool.clone()).await.expect("state");
let mut a = Client::of(&between);
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
let e2 = unit_id(&pool, "essai", "E2").await;
let (s, v) = a.call("POST", &format!("/api/units/{e2}/inertia"), Some(json!({ "target": 5 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
let p = v["id"].as_str().expect("id").to_owned();
a.vote(&p, "for").await;
assert_eq!(b.vote(&p, "for").await["entered"][0]["kind"], "inertia");
let state = restart(&pool).await;
assert_eq!(
decided(&inertia_decisions(&state.db).await),
[
("essai", "E1", "applied", None),
("essai", "E2", "refused", Some("changed_by_vote")),
("monnaie-libre", "E1", "applied", None),
("monnaie-libre", "E2", "applied", None),
]
);
let mut c = Client::of(&state);
c.login("Yvv").await;
let doc = c.doc("essai").await;
assert_eq!(unit(&doc, "E2")["inertia"], 5, "the vote holds");
}
+6 -1
View File
@@ -1,5 +1,9 @@
# 0002 — Vote permanent : marge du texte en place et plancher C # 0002 — Vote permanent : marge du texte en place et plancher C
> **Complétée par [0007](0007-inertie-cinq-crans.md)** (2026-10-30) : l'inertie a cinq crans,
> calés sur la participation habituelle du groupe, et un changement d'inertie se vote au cran
> de l'article — il n'est plus voté au cran au-dessus.
**Contexte.** Chaque article est sous vote permanent ; « dès qu'une proposition atteint le score, **Contexte.** Chaque article est sous vote permanent ; « dès qu'une proposition atteint le score,
elle remonte en vigueur ». La formule inertielle Ğ1 donne un seuil faible quand peu de gens elle remonte en vigueur ». La formule inertielle Ğ1 donne un seuil faible quand peu de gens
votent : sans garde-fou, une seule voix ferait passer un texte. Sans règle de départage, deux votent : sans garde-fou, une seule voix ferait passer un texte. Sans règle de départage, deux
@@ -12,7 +16,8 @@ versions qui atteignent leur seuil s'échangeraient la place à chaque vote.
(pour − requis) strictement plus large que la version en place. La version déplacée redevient (pour − requis) strictement plus large que la version en place. La version déplacée redevient
une proposition avec ses votes : rien n'est perdu, tout peut revenir. une proposition avec ses votes : rien n'est perdu, tout peut revenir.
- Les textes de départ sont des *seeds a minima* (0 vote, marge −C) : visibles, non adoptés. - Les textes de départ sont des *seeds a minima* (0 vote, marge −C) : visibles, non adoptés.
- Changer l'inertie d'un article est une proposition votée à l'inertie du cran au-dessus. - Changer l'inertie d'un article est une proposition votée à l'inertie du cran au-dessus
(remplacé le 2026-10-30 : elle hérite du cran de l'article, ADR 0007).
- Le balayage tourne après chaque geste et toutes les 5 minutes, sous un verrou global. - Le balayage tourne après chaque geste et toutes les 5 minutes, sous un verrou global.
**Coût.** Une version en place très soutenue est difficile à déloger — c'est voulu. La taille **Coût.** Une version en place très soutenue est difficile à déloger — c'est voulu. La taille
+135
View File
@@ -0,0 +1,135 @@
# 0007 — Inertie à cinq crans, calée sur la participation du groupe
> Complète [0002](0002-vote-permanent.md) : la règle « l'inertie se vote au cran au-dessus »
> y est remplacée par l'héritage décrit ici. Décision d'Yvv du 2026-10-30 (revue, L10).
**Contexte.** Quatre niveaux (`low`, `standard`, `high`, `max`) fixaient M et G une fois pour
toutes. Yvv, 2026-10-30 : « L'indice d'inertie doit avoir 5 niveaux. Tu mets par défaut au
milieu (3) pour toutes les propositions, (4) pour les 2 engagements structurels » ; « par
défaut l'inertie de l'indice d'inertie lui-même hérite de l'inertie de l'article » ; « une
échelle cohérente qui s'adapte aux nombres de certifiés et à la participation médiane aux
votes du groupe ». Au formulaire, il a choisi l'échelle **calée sur la participation** :
chaque cran exige une part fixe de « pour » quand le groupe vote comme d'habitude, puis se
relâche vers la majorité M quand plus de monde vote.
## Décision
**La formule reste celle de la Ğ1**, adoptée si « pour » ≥ ⌈R⌉ :
```
R = C + B^W + (M + (1 − M)·(1 − (T/W)^G)) · max(0, T − C)
```
Seuls M et G dépendent du cran k. M_k est la majorité à pleine participation ; s_k la part de
« pour » exigée **au-delà de C** à la participation habituelle p̃ ; G_k s'en déduit :
```
s_k = M_k + (1 − M_k)·(1 − p̃^G_k) ⇔ G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃)
```
| Cran | Nom | M_k | s_k |
|---|---|---|---|
| 1 | très basse | 50 % | 60 % |
| 2 | basse | 50 % | 67 % |
| 3 | moyenne (par défaut) | 55 % | 75 % |
| 4 | haute (E1, E2 ; le titre du doc, lot D) | 60 % | 83 % |
| 5 | très haute | 66 % | 90 % |
**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
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 ;
- T̃ = la médiane de ces T (moyenne des deux du milieu pour un nombre pair) ; p̃ = T̃ / W, avec le
W du moment : quand la toile grandit, l'échelle demande toujours s_k au même nombre habituel
de votants ;
- il faut **au moins 5** propositions votées ; avant, la **référence** p0 = 2C/W s'applique
(Monnaie libre : 100 votants sur 7 224, 1,38 %) — deux fois le plancher, une participation
que le groupe doit de toute façon atteindre pour que C laisse place à la majorité.
**Les bornes.** p̃ (mesurée ou de référence) est ramenée dans **[1 %, 50 %]**. Ainsi
0 < p̃ < 1, ln(p̃) est négatif et fini ; comme M_k < s_k < 1 pour chaque cran,
0 < 1 − (s_k − M_k)/(1 − M_k) < 1 et G_k est fini et positif (de 0,05 à 1,77). Alors :
- **le seuil croît avec le cran** : la part exigée vaut 1 − (1 − M_k)·(T/W)^G_k, croissante en
M_k et en G_k, et M_k, G_k croissent avec k (à pleine participation, les crans 1 et 2 se
rejoignent à M = 50 %) ;
- **la part exigée décroît vers M quand la participation monte**, et vaut M à T = W ;
- **à p̃, la part au-delà de C vaut s_k** ;
- **C reste un plancher** : à T ≤ C, R = C + B^W ; une seule voix ne fait jamais passer un
texte dans Monnaie libre ;
- le groupe d'essai (W = 2, C = 1) : la référence vaut 100 %, ramenée à 50 % ; G reste fini ;
il faut les deux voix, à tout cran.
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.
**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
démarrage puis par le balayage (toutes les 5 minutes) dès qu'elle a 24 heures. Un seuil qui
bougerait à chaque vote exprimé ailleurs dans le groupe ne se lirait pas ; une fois par jour,
il reste stable dans la journée et suit pourtant la vie du groupe. W, lui, change à son rythme
(la toile, toutes les 6 heures), et p̃ = T̃ / W le suit aussitôt.
**L'inertie de l'inertie hérite du cran de l'article.** Une proposition de changement
d'inertie se vote au cran actuel de l'article (`compose::voting_level`), comme son texte —
plus au cran au-dessus. Le cran se change en proposant l'un des cinq crans.
**Un entier reste un entier.** Le calcul flottant de ln et de la puissance peut porter un
milliardième de voix : un seuil qui tombe juste sur un entier (90 % de 70 = 63) ne doit pas
monter d'une voix pour autant. La part inertielle est ramenée à l'entier quand elle en est à
moins de 10⁻⁹ (`threshold::settle`) ; cas réel : 101 membres qui votent tous, M = 55 %, C = 1,
il faut 56 voix, et non 57.
## Les nombres
W = 7 224, C = 50 ; voix pour exigées (tests `ld-engine/src/inertia.rs`) :
| Médiane | T | cran 1 | cran 2 | cran 3 | cran 4 | cran 5 |
|---|---|---|---|---|---|---|
| 120 votants | 120 | 92 | 97 | 103 | 109 | 113 |
| 120 votants | 500 | 306 | 329 | 362 | 397 | 432 |
| 120 votants | 2 000 | 1 091 | 1 145 | 1 271 | 1 404 | 1 549 |
| 360 votants | 120 | 95 | 101 | 106 | 112 | 116 |
| 360 votants | 360 | 236 | 258 | 283 | 308 | 329 |
| 360 votants | 500 | 316 | 345 | 381 | 416 | 449 |
| 360 votants | 2 000 | 1 114 | 1 184 | 1 318 | 1 460 | 1 608 |
Le cas de référence de la formule (vote forgeron v2.0.0 : W 7 224, 120 votants, M 50 %,
G 0,2, C 0 → 94 voix) reste le test de la formule elle-même, lettres données ; ce ne sont plus
les lettres d'aucun cran.
## Les données
- Migration `0005` : les quatre mots deviennent des crans (low → 2, standard → 3, high → 4,
max → 5 ; le cran 1 est nouveau) dans `units.inertia` et `proposals.inertia_target`. Chaque
mot est d'abord gardé tel quel (`inertia_legacy`, `inertia_target_legacy`) ; les `params`
des `adoptions` passées ne sont pas touchés (ils portent les M et G de leur jugement). Le
serveur lit toujours les quatre mots. Les adoptions nouvelles gardent en plus le cran, s_k
et p̃ (sa source, l'échantillon, la médiane).
- Le seed : 3 partout, 4 pour E1 et E2.
- Dans une base existante, E1 et E2 passent de 3 à 4 par un **amendement d'inertie**
(`crates/ld-server/src/inertia_amend.rs`), dans la lignée des amendements de seed : décidé
une fois par document, seulement si **aucun vote n'a changé l'inertie de l'article** (pas
d'adoption d'inertie, cran encore celui du seed). Une proposition d'inertie ouverte ne
l'arrête pas : elle reste ouverte et se vote au nouveau cran. Chaque décision est gardée
(`inertia_amendments`), écrite dans les journaux, se lit sur l'article (`inertia_changes` :
« passée de 3 à 4 : réglage de départ du texte ») et le document est republié.
## Coût
- Les seuils de Monnaie libre changent. Avec W = 7 224, C = 50 et la référence (100 votants),
le cran 3 demande 103 voix à 120 votants (`standard` : 105), 360 à 500 (369), 1 265 à 2 000
(1 246), 3 996 quand tout le monde vote (3 637, M passant de 50 à 55 %) : un peu moins
jusqu'à un millier de votants, plus au-delà. Le cran 4 demande 108 à 120 votants (`high` :
115), 395 à 500 (439), autant à pleine participation (M 60 %). Rien n'est en vigueur par un
vote aujourd'hui ; les seuils déjà affichés bougent.
- Le seuil d'une proposition dépend désormais des autres votes du groupe, par la médiane : une
fois par jour, un texte peut passer ou ne plus passer sans nouveau vote sur lui (comme quand W
change). Chaque entrée en vigueur garde la médiane qui l'a jugée.
- Le document publié (`document.json`) porte l'inertie en nombre (1 à 5), et non plus en mot.
- Le texte N1.1 du seed (« Niveaux d'inertie », quatre niveaux) est une proposition de la
communauté : il n'est pas modifié et décrit désormais autre chose que la règle en place.
+1
View File
@@ -11,3 +11,4 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan
| [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes | 2026-10-21 | | [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes | 2026-10-21 |
| [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 | | [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 |
| [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 | | [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 |
| [0007](0007-inertie-cinq-crans.md) | Inertie à cinq crans, calée sur la participation du groupe (complète 0002) | 2026-10-30 |
+34 -11
View File
@@ -35,9 +35,11 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
certifiante. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués certifiante. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
*seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets : *seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets :
**Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils **Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils
ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL. ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL. Depuis
l'en-tête du document et depuis chaque vote, **« Réglages de la formule »** : W, C, B, la
participation habituelle du groupe et les cinq crans (onglet « Réglages » du panneau).
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro 5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
vote, inertie standard — rangées par section (introduction, fondamental, technique, vote, inertie 3 / 5 — rangées par section (introduction, fondamental, technique,
conclusion, annexes, formule, inertie, ordonnancement), **une ligne par article** : un conclusion, annexes, formule, inertie, ordonnancement), **une ligne par article** : un
article qui a plusieurs versions (variantes du seed, propositions rivales) montre leur article qui a plusieurs versions (variantes du seed, propositions rivales) montre leur
nombre et la meilleure en tête (niveau d'eau, pour / contre) ; la ligne se déplie sur ses nombre et la meilleure en tête (niveau d'eau, pour / contre) ; la ligne se déplie sur ses
@@ -59,14 +61,31 @@ W = électorat (Monnaie libre : taille de la toile, lue sur la chaîne toutes le
géré : ses membres), T = votes exprimés, M = majorité, G = gradient, B = 0,1, **C = plancher de géré : ses membres), T = votes exprimés, M = majorité, G = gradient, B = 0,1, **C = plancher de
votants « pour »** (Monnaie libre : **50** ; groupe d'essai : 1). Adoptée si « pour » ≥ ⌈R⌉. votants « pour »** (Monnaie libre : **50** ; groupe d'essai : 1). Adoptée si « pour » ≥ ⌈R⌉.
| Inertie | M | G | **Cinq crans d'inertie** (Yvv, 2026-10-30 ; ADR 0007). Seuls M et G dépendent du cran k :
|---|---|---| M_k est la majorité à pleine participation, s_k la part de « pour » exigée **au-delà de C** quand
| basse | 50 % | 0,1 | le groupe vote comme d'habitude (participation p̃), et G_k s'en déduit :
| standard (au départ, partout) | 50 % | 0,2 | G_k = ln(1 − (s_k − M_k)/(1 − M_k)) / ln(p̃).
| haute | 60 % | 0,4 |
| maximale | 66 % | 0,6 |
Moteur : `crates/ld-engine/src/threshold.rs` (testé : 7224 / 120 votants → 94 voix requises). | Cran | Nom | M_k | s_k | Au départ |
|---|---|---|---|---|
| 1 | très basse | 50 % | 60 % | |
| 2 | basse | 50 % | 67 % | |
| 3 | moyenne | 55 % | 75 % | partout |
| 4 | haute | 60 % | 83 % | Unicité du compte, Réciprocité (et le titre du doc, à venir) |
| 5 | très haute | 66 % | 90 % | |
**La participation habituelle p̃** = la médiane des votants des propositions du groupe votées
depuis un an (au moins 5), sur W ; avant, la référence 2C/W (Monnaie libre : 100 votants) ;
toujours ramenée entre 1 % et 50 %. Mesurée une fois par jour. Le seuil croît avec le cran, la
part exigée se relâche vers M quand plus de monde vote, et rien ne passe sous C voix pour.
Avec W = 7 224, C = 50 et une médiane de 120 votants, il faut, à 120 votants, 92, 97, 103, 109
et 113 voix pour ; à 500, 306, 329, 362, 397 et 432 ; à 2 000, 1 091, 1 145, 1 271, 1 404 et
1 549.
Moteur : `crates/ld-engine/src/threshold.rs` (la formule ; son cas de référence, lettres
données : 7224 / 120 votants, M 50 %, G 0,2, C 0 → 94 voix) et `crates/ld-engine/src/inertia.rs`
(les crans, la médiane, la référence, les bornes) ; mesure : `crates/ld-server/src/participation.rs`.
### Le vote permanent ### Le vote permanent
@@ -80,8 +99,12 @@ Moteur : `crates/ld-engine/src/threshold.rs` (testé : 7224 / 120 votants → 94
### L'inertie se vote aussi ### L'inertie se vote aussi
Chaque article porte son inertie. La changer est une proposition, **votée elle-même à Chaque article porte son inertie, un cran de 1 à 5. La changer est une proposition, **votée
l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande plus que modifier. elle-même au cran actuel de l'article** : l'inertie de l'inertie hérite de celle de l'article
(Yvv, 2026-10-30 ; elle était votée au cran au-dessus, « inertie + », depuis le 2026-10-21).
L'article dit le passé de son inertie : passée de tel cran à tel autre, par un vote ou par le
réglage de départ du texte (E1 et E2 relevés de 3 à 4 dans une base existante, seulement si
aucun vote n'a changé leur inertie : `crates/ld-server/src/inertia_amend.rs`).
### Corriger n'est pas voter ### Corriger n'est pas voter
@@ -12,7 +12,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
| C1 | L'accueil, sans session | ouvrir `/` sans être connectée | « Rendre possible la décision collective », puis le leitmotiv en trois lignes : « contexte et cadrage », « processus et modalités », « boîte à outils » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints | | C1 | L'accueil, sans session | ouvrir `/` sans être connectée | « Rendre possible la décision collective », puis le leitmotiv en trois lignes : « contexte et cadrage », « processus et modalités », « boîte à outils » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints |
| C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici | | C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici |
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) | | C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) |
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme | | C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le panneau ; Échap le ferme |
| C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles | | C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles |
| C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » | | C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît | | C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
@@ -21,7 +21,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là | | C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien | | C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance), les trois gestes visibles sans survol, le panneau s'ouvre en feuille | | M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance), les trois gestes visibles sans survol, le panneau s'ouvre en feuille ; « Réglages de la formule » depuis le vote : l'onglet « Réglages » visible, le tableau des cinq crans tient dans la feuille |
| C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis | | C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis |
| C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur | | C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur |
| C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) | | C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) |
@@ -29,6 +29,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
| C17 | Décisions | Monnaie libre → Décisions ; voter sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote », puis « dont 32 » ; la ligne ouvre le lecteur | | C17 | Décisions | Monnaie libre → Décisions ; voter sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote », puis « dont 32 » ; la ligne ouvre le lecteur |
| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » | | C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie | | C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie |
| C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis Unicité du compte → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), C 50, B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois C : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; voix pour à 100 votants 80, 84, 88, 92, 95 ; depuis le vote : « Inertie · 4 / 5 · haute », la ligne du cran 4 marquée « cet article », 50 voix à 0 votant ; le sélecteur montre les cinq crans, 4 « actuelle » et grisé ; la proposition s'ouvre : « inertie vers 5 / 5 · très haute », votée à « inertie 4 / 5 · haute » |
## Au-delà de la liste ## Au-delà de la liste
@@ -56,3 +57,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts | | 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
| 2026-10-30 | automate | local, lot R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts | | 2026-10-30 | automate | local, lot R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts |
| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts | | 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts |
| 2026-10-30 | automate | local, lot C (inertie à cinq crans, réglages de la formule) | C1–C20, M1 | 21 / 21 verts |
+34 -34
View File
@@ -1,5 +1,5 @@
{ {
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote, inertie standard (Yvv, 2026-10-21). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).", "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).",
"slug": "acte-engagement-certification", "slug": "acte-engagement-certification",
"title": { "title": {
"fr": "Acte d'engagement Certification", "fr": "Acte d'engagement Certification",
@@ -29,7 +29,7 @@
"code": "I1", "code": "I1",
"section": "introduction", "section": "introduction",
"position": 1, "position": 1,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -51,7 +51,7 @@
"code": "I2", "code": "I2",
"section": "introduction", "section": "introduction",
"position": 2, "position": 2,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -73,7 +73,7 @@
"code": "T1", "code": "T1",
"section": "fondamental", "section": "fondamental",
"position": 3, "position": 3,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -95,7 +95,7 @@
"code": "E1", "code": "E1",
"section": "fondamental", "section": "fondamental",
"position": 4, "position": 4,
"inertia": "standard", "inertia": 4,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -117,7 +117,7 @@
"code": "E2", "code": "E2",
"section": "fondamental", "section": "fondamental",
"position": 5, "position": 5,
"inertia": "standard", "inertia": 4,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -139,7 +139,7 @@
"code": "T2", "code": "T2",
"section": "technique", "section": "technique",
"position": 6, "position": 6,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -161,7 +161,7 @@
"code": "E3", "code": "E3",
"section": "technique", "section": "technique",
"position": 7, "position": 7,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -197,7 +197,7 @@
"code": "E4", "code": "E4",
"section": "technique", "section": "technique",
"position": 8, "position": 8,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -219,7 +219,7 @@
"code": "E5", "code": "E5",
"section": "technique", "section": "technique",
"position": 9, "position": 9,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -241,7 +241,7 @@
"code": "E6", "code": "E6",
"section": "technique", "section": "technique",
"position": 10, "position": 10,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -263,7 +263,7 @@
"code": "E7", "code": "E7",
"section": "technique", "section": "technique",
"position": 11, "position": 11,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -299,7 +299,7 @@
"code": "T3", "code": "T3",
"section": "technique", "section": "technique",
"position": 12, "position": 12,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -321,7 +321,7 @@
"code": "E8", "code": "E8",
"section": "technique", "section": "technique",
"position": 13, "position": 13,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -343,7 +343,7 @@
"code": "E9", "code": "E9",
"section": "technique", "section": "technique",
"position": 14, "position": 14,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -365,7 +365,7 @@
"code": "E10", "code": "E10",
"section": "technique", "section": "technique",
"position": 15, "position": 15,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -387,7 +387,7 @@
"code": "E11", "code": "E11",
"section": "technique", "section": "technique",
"position": 16, "position": 16,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -409,7 +409,7 @@
"code": "K1", "code": "K1",
"section": "conclusion", "section": "conclusion",
"position": 17, "position": 17,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -431,7 +431,7 @@
"code": "K2", "code": "K2",
"section": "conclusion", "section": "conclusion",
"position": 18, "position": 18,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -453,7 +453,7 @@
"code": "X1", "code": "X1",
"section": "annexe", "section": "annexe",
"position": 19, "position": 19,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -475,7 +475,7 @@
"code": "X1.1", "code": "X1.1",
"section": "annexe", "section": "annexe",
"position": 20, "position": 20,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -497,7 +497,7 @@
"code": "X1.2", "code": "X1.2",
"section": "annexe", "section": "annexe",
"position": 21, "position": 21,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -519,7 +519,7 @@
"code": "X1.3", "code": "X1.3",
"section": "annexe", "section": "annexe",
"position": 22, "position": 22,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -541,7 +541,7 @@
"code": "X2", "code": "X2",
"section": "annexe", "section": "annexe",
"position": 23, "position": 23,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -563,7 +563,7 @@
"code": "X2.1", "code": "X2.1",
"section": "annexe", "section": "annexe",
"position": 24, "position": 24,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -585,7 +585,7 @@
"code": "X2.2", "code": "X2.2",
"section": "annexe", "section": "annexe",
"position": 25, "position": 25,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -607,7 +607,7 @@
"code": "X2.3", "code": "X2.3",
"section": "annexe", "section": "annexe",
"position": 26, "position": 26,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -629,7 +629,7 @@
"code": "F1", "code": "F1",
"section": "formule", "section": "formule",
"position": 27, "position": 27,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -651,7 +651,7 @@
"code": "F1.1", "code": "F1.1",
"section": "formule", "section": "formule",
"position": 28, "position": 28,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -673,7 +673,7 @@
"code": "F1.2", "code": "F1.2",
"section": "formule", "section": "formule",
"position": 29, "position": 29,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -695,7 +695,7 @@
"code": "F1.3", "code": "F1.3",
"section": "formule", "section": "formule",
"position": 30, "position": 30,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -717,7 +717,7 @@
"code": "N1", "code": "N1",
"section": "inertie", "section": "inertie",
"position": 31, "position": 31,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -739,7 +739,7 @@
"code": "N1.1", "code": "N1.1",
"section": "inertie", "section": "inertie",
"position": 32, "position": 32,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
@@ -761,7 +761,7 @@
"code": "O1", "code": "O1",
"section": "ordonnancement", "section": "ordonnancement",
"position": 33, "position": 33,
"inertia": "standard", "inertia": 3,
"versions": [ "versions": [
{ {
"label": "1.0.0", "label": "1.0.0",
+7 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers'; import { DOC, signIn } from './helpers';
test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => { test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille, réglages lisibles', async ({ page }) => {
await signIn(page, 'Chiara'); await signIn(page, 'Chiara');
await page.goto('/'); await page.goto('/');
const brand = page.locator('header.topbar a.brand'); const brand = page.locator('header.topbar a.brand');
@@ -18,4 +18,10 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, pann
const first = page.locator('section.text article.unit').first(); const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click(); await first.getByRole('button', { name: 'Le vote' }).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible(); await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
await page.locator('aside.panel').getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5);
const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(fits).toBe(true);
}); });
+57
View File
@@ -60,6 +60,9 @@ test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', asyn
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible(); await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
await panel.getByText('La formule du seuil').click(); await panel.getByText('La formule du seuil').click();
await expect(panel.getByText(/W — électorat/)).toBeVisible(); await expect(panel.getByText(/W — électorat/)).toBeVisible();
// The level of the article sets M and G: the Raison d'être is at 3 / 5.
await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// The panel lives in the URL. // The panel lives in the URL.
await expect(page).toHaveURL(/onglet=vote/); await expect(page).toHaveURL(/onglet=vote/);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
@@ -242,3 +245,57 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
await expect(e3).toHaveAttribute('aria-expanded', 'false'); await expect(e3).toHaveAttribute('aria-expanded', 'false');
await expect(versions).toHaveCount(0); await expect(versions).toHaveCount(0);
}); });
test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
// From the head of the document: the scale of the group, at its usual turnout.
await page.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(page).toHaveURL(/onglet=reglages/);
const panel = page.locator('aside.panel');
await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible();
await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible();
await expect(panel.getByText('C — minimum de voix pour : 50')).toBeVisible();
// No vote yet: the reference, twice C.
await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois C : la participation de référence/)).toBeVisible();
const rows = panel.locator('table tbody tr');
await expect(rows).toHaveCount(5);
await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]);
await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']);
await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']);
// At 100 voters: C plus each level's share of the 50 votes beyond C.
const required = panel.locator('table tbody td.required');
await expect(required).toHaveText(['80', '84', '88', '92', '95']);
await expect(panel.getByText('celles exigées à la participation habituelle, 100 votants')).toBeVisible();
await page.keyboard.press('Escape');
// From the vote of an article: Unicité du compte is at 4 / 5, its row is marked.
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await panel.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(panel.locator('tr.current')).toHaveCount(1);
await expect(panel.locator('tr.current th')).toContainText('4');
await expect(panel.locator('tr.current th')).toContainText('cet article');
await expect(required).toHaveText(['50', '50', '50', '50', '50']);
await expect(panel.getByText('celles exigées à la participation de cette version, 0 votant.')).toBeVisible();
await page.keyboard.press('Escape');
// The five levels to choose from, in the test group; the change is voted at the article's level.
await page.goto(TEST_DOC);
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
await panel.getByText("Proposer un changement d'inertie").click();
const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await expect(levels).toHaveCount(5);
await expect(levels.nth(3)).toBeDisabled();
await expect(levels.nth(3)).toContainText('actuelle');
await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await levels.nth(4).click();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await panel.getByRole('button', { name: 'Soumettre au vote' }).click();
await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible();
await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
});
+3 -3
View File
@@ -4,7 +4,7 @@
// unfolds the others, each with its label, its water level and its marks — and // unfolds the others, each with its label, its water level and its marks — and
// each opens its own vote. // each opens its own vote.
import { plain } from '$lib/markdown'; import { plain } from '$lib/markdown';
import { relationOf, titleOf } from '$lib/labels'; import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import type { Proposal, Unit } from '$lib/types'; import type { Proposal, Unit } from '$lib/types';
import { excerpts, type Article } from '$lib/versions'; import { excerpts, type Article } from '$lib/versions';
@@ -73,7 +73,7 @@
<span class="meta"> <span class="meta">
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span> <span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if} {#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if} {#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
{@render marks(best)} {@render marks(best)}
</span> </span>
</span> </span>
@@ -94,7 +94,7 @@
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if} {#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span> <span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if} {#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if} {#if unit.inertia !== 3}<span class="faint">{inertiaBadge(unit.inertia)}</span>{/if}
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if} {#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if} {#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if} {#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
+14 -1
View File
@@ -3,6 +3,7 @@
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels'; import { relationOf, titleOf } from '$lib/labels';
import type { Lang } from '$lib/types'; import type { Lang } from '$lib/types';
import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import ProposeView from './ProposeView.svelte'; import ProposeView from './ProposeView.svelte';
import VoteView from './VoteView.svelte'; import VoteView from './VoteView.svelte';
@@ -27,21 +28,30 @@
['vote', t('panel.vote'), !!voteId], ['vote', t('panel.vote'), !!voteId],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit], ['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['propose', t('panel.propose'), true], ['propose', t('panel.propose'), true],
// Reached from the head of the document or from a vote; shown while it is open.
['settings', t('panel.settings'), panel.tab === 'settings'],
] as const ] as const
).filter(([, , show]) => show), ).filter(([, , show]) => show),
); );
let body: HTMLElement | undefined = $state(); let body: HTMLElement | undefined = $state();
let tablist: HTMLElement | undefined = $state();
$effect(() => { $effect(() => {
void panel.tab; void panel.tab;
void panel.proposalId; void panel.proposalId;
body?.scrollTo({ top: 0 }); body?.scrollTo({ top: 0 });
}); });
// On a narrow sheet the tabs scroll: the open one stays in sight.
$effect(() => {
void panel.tab;
tablist?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
</script> </script>
<aside class="panel card" aria-label={t('panel.vote')}> <aside class="panel card" aria-label={t('panel.vote')}>
<div class="bar"> <div class="bar">
<div class="tabs" role="tablist"> <div class="tabs" role="tablist" bind:this={tablist}>
{#each tabs as [key, label] (key)} {#each tabs as [key, label] (key)}
<button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button> <button role="tab" type="button" aria-selected={panel.tab === key} class:on={panel.tab === key} onclick={() => onchange({ ...panel, tab: key, prefill: null })}>{label}</button>
{/each} {/each}
@@ -57,6 +67,7 @@
{lang} {lang}
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })} onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })} onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
onsettings={() => onchange({ ...panel, proposalId: voteId, tab: 'settings' })}
/> />
{:else if panel.tab === 'proposals' && unit} {:else if panel.tab === 'proposals' && unit}
<ul class="versions"> <ul class="versions">
@@ -82,6 +93,8 @@
prefill={panel.prefill ?? null} prefill={panel.prefill ?? null}
ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })} ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })}
/> />
{:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} />
{/if} {/if}
</div> </div>
</aside> </aside>
+28 -8
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
// The threshold formula, typeset with native MathML — no KaTeX needed. // The threshold formula, typeset with native MathML — no KaTeX needed.
import { i18n, t } from '$lib/i18n/index.svelte'; import { i18n, t } from '$lib/i18n/index.svelte';
import type { Evaluation, Inertia, Tally } from '$lib/types'; import { inertiaBadge } from '$lib/labels';
import type { Evaluation, Inertia, LevelInfo, Tally } from '$lib/types';
let { let {
electorate, electorate,
@@ -9,21 +10,24 @@
tally, tally,
evaluation, evaluation,
inertia, inertia,
presets, levels,
floor, floor,
base, base,
onsettings,
}: { }: {
electorate: number; electorate: number;
source: 'wot' | 'group'; source: 'wot' | 'group';
tally: Tally; tally: Tally;
evaluation: Evaluation | null; evaluation: Evaluation | null;
inertia: Inertia; inertia: Inertia;
presets: { key: Inertia; majorityPct: number; gradient: number }[]; levels: LevelInfo[];
floor: number; floor: number;
base: number; base: number;
/** Opens the settings of the formula (the five levels, the usual turnout). */
onsettings?: () => void;
} = $props(); } = $props();
const p = $derived(presets.find((x) => x.key === inertia)); const p = $derived(levels.find((x) => x.level === inertia));
</script> </script>
<details class="formula"> <details class="formula">
@@ -41,18 +45,23 @@
<li>{t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li> <li>{t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li>
<li>{t('formula.T', { n: tally.for + tally.against })}</li> <li>{t('formula.T', { n: tally.for + tally.against })}</li>
{#if p} {#if p}
<li>{t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}</li> <li>{t('formula.M', { n: i18n.number(p.majorityPct) })} — {inertiaBadge(inertia)}</li>
<li>{t('formula.G', { n: p.gradient })}</li> <li>{t('formula.G', { n: i18n.number(Math.round(p.gradient * 1000) / 1000) })}</li>
{/if} {/if}
<li>{t('formula.B', { n: base })}</li> <li>{t('formula.B', { n: i18n.number(base) })}</li>
<li>{t('formula.C', { n: floor })}</li> <li>{t('formula.C', { n: i18n.number(floor) })}</li>
{#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if} {#if evaluation}<li class="r">{t('formula.R', { n: evaluation.required })}</li>{/if}
</ul> </ul>
<p class="faint">{t('formula.reading')}</p> <p class="faint">{t('formula.reading')}</p>
</details> </details>
{#if onsettings}
<button class="settings-link" type="button" onclick={onsettings}>{t('settings.title')}</button>
{/if}
<style> <style>
.formula { .formula {
/* A grid item may shrink below its widest line: the formula scrolls, the panel does not. */
min-width: 0;
border-radius: var(--r-input); border-radius: var(--r-input);
background: var(--surface-2); background: var(--surface-2);
padding: 0.7rem 0.9rem; padding: 0.7rem 0.9rem;
@@ -86,4 +95,15 @@
.faint { .faint {
font-size: 0.85rem; font-size: 0.85rem;
} }
.settings-link {
justify-self: start;
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 750;
font-size: 0.9rem;
text-decoration: underline;
text-underline-offset: 3px;
}
</style> </style>
@@ -0,0 +1,195 @@
<script lang="ts">
// The settings of the formula, read only: W and its source, C, B, the usual
// turnout of the group (median or reference) and the five levels on its
// scale. From the head of the document, the *for* votes asked are those of
// the usual turnout; from a version, those of its own turnout.
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Inertia, LevelInfo } from '$lib/types';
let { doc, proposalId = null }: { doc: DocState; proposalId?: string | null } = $props();
interface Version {
levels: LevelInfo[];
proposal: { votedAtInertia: Inertia };
}
let version = $state<Version | null>(null);
$effect(() => {
const id = proposalId;
void doc.view;
version = null;
if (!id) return;
api
.get<Version>(`/proposals/${id}`)
.then((d) => {
if (proposalId === id) version = d;
})
.catch(() => {
version = null;
});
});
const view = $derived(doc.view);
const p = $derived(view?.doc.participation);
const levels = $derived(version?.levels ?? view?.levels ?? []);
const cast = $derived(levels[0]?.cast ?? 0);
const current = $derived(version?.proposal.votedAtInertia ?? null);
const pct = (ratio: number) => t('common.pct', { n: i18n.number(Math.round(ratio * 10000) / 100) });
const whole = (n: number) => t('common.pct', { n: i18n.number(n) });
const gradient = (g: number) => i18n.number(Math.round(g * 1000) / 1000);
</script>
{#if view && p}
<section class="settings" aria-labelledby="settings-title">
<h3 id="settings-title">{t('settings.title')}</h3>
<p class="muted">{t('settings.intro')}</p>
<ul class="values">
<li>{t('formula.W', { n: i18n.number(view.doc.electorate), source: t(view.doc.electorateSource === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}</li>
<li>{t('formula.C', { n: i18n.number(view.doc.voteFloor) })}</li>
<li>{t('formula.B', { n: i18n.number(view.doc.formula.base) })}</li>
</ul>
<div class="usual">
<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 })}
{:else}
{i18n.plural('settings.usual.reference', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(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}
<p class="faint small">{p.measuredAt ? t('settings.usual.when', { date: i18n.date(p.measuredAt) }) : t('settings.usual.never')}</p>
</div>
<h4>{t('settings.levels')}</h4>
<div class="scroll">
<table>
<thead>
<tr>
<th scope="col">{t('settings.col.level')}</th>
<th scope="col">M</th>
<th scope="col">G</th>
<th scope="col">{t('settings.col.share')}</th>
<th scope="col">{t('settings.col.required')}</th>
</tr>
</thead>
<tbody>
{#each levels as l (l.level)}
<tr class:current={l.level === current}>
<th scope="row">
<span class="lvl">
<span class="k">{l.level}</span>
<span class="name">{t(`inertia.name.${l.level}`)}</span>
{#if l.level === current}<span class="tag water">{t('settings.current')}</span>{/if}
</span>
</th>
<td>{whole(l.majorityPct)}</td>
<td>{gradient(l.gradient)}</td>
<td>{whole(l.sharePct)}</td>
<td class="required">{l.required === null ? '—' : i18n.number(l.required)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="faint small">{i18n.plural('settings.share.note', p.voters)}</p>
<p class="faint small">{i18n.plural(version ? 'settings.required.version' : 'settings.required.usual', cast)}</p>
<p class="faint small">{t('settings.reading')}</p>
</section>
{/if}
<style>
.settings {
display: grid;
gap: 0.8rem;
min-width: 0;
}
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
}
h4 {
margin: 0;
font-size: 0.92rem;
font-weight: 800;
}
.small {
font-size: 0.84rem;
}
.values {
margin: 0;
padding-left: 1.1rem;
font-size: 0.92rem;
display: grid;
gap: 0.15rem;
}
.usual {
display: grid;
gap: 0.3rem;
padding: 0.8rem 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
font-size: 0.92rem;
}
.scroll {
overflow-x: auto;
border-radius: var(--r-input);
background: var(--surface-2);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
font-variant-numeric: tabular-nums;
}
th,
td {
padding: 0.45rem 0.55rem;
text-align: right;
white-space: nowrap;
}
thead th {
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--ink-soft);
}
th[scope='row'],
thead th:first-child {
text-align: left;
}
th[scope='row'] {
font-weight: 600;
}
.lvl {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem;
}
tbody tr:nth-child(odd) {
background: color-mix(in srgb, var(--surface) 55%, transparent);
}
tr.current {
background: var(--water-wash) !important;
}
.k {
font-weight: 800;
font-size: 1rem;
}
.name {
color: var(--ink-soft);
font-size: 0.8rem;
}
.required {
font-weight: 800;
}
.tag {
font-size: 0.68rem;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
// The inertia of an article: its level out of five, its past, the changes
// under vote, and the choice of a new level. A change of inertia is voted at
// the level of the article itself (ADR 0007).
import { api, ApiError } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { inertiaLevel } from '$lib/labels';
import { INERTIA_LEVELS, type Inertia, type Unit } from '$lib/types';
let { doc, unit, onopen }: { doc: DocState; unit: Unit; onopen: (proposalId: string) => void } = $props();
let target = $state<Inertia | null>(null);
let busy = $state(false);
let error = $state('');
const open = $derived(unit.proposals.filter((x) => x.kind === 'inertia' && x.status === 'open'));
const level = (k: Inertia | null) => (k ? inertiaLevel(k) : '?');
async function submit() {
if (!target) return;
busy = true;
try {
const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target });
target = null;
error = '';
await doc.reload();
onopen(r.id);
} catch (e) {
error = t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
} finally {
busy = false;
}
}
</script>
<section class="inertia" aria-labelledby="inertia-{unit.id}">
<h4 id="inertia-{unit.id}">{t('inertia.title')} · <span class="muted">{inertiaLevel(unit.inertia)}</span></h4>
<p class="faint small">{t('inertia.explain')}</p>
{#each unit.inertiaChanges as c (c.at + c.to)}
<p class="faint small">{t(`inertia.history.${c.cause}`, { from: level(c.from), to: level(c.to), date: i18n.date(c.at) })}</p>
{/each}
{#each open as ip (ip.id)}
<button class="link" type="button" onclick={() => onopen(ip.id)}>
{t('inertia.change.open', { level: level(ip.inertiaTarget) })} — {t('tally.short', { for: ip.tally.for, against: ip.tally.against })}
</button>
{/each}
<details>
<summary>{t('inertia.change')}</summary>
<div class="levels" role="radiogroup" aria-label={t('inertia.change.to')}>
{#each INERTIA_LEVELS as k (k)}
<button
type="button"
role="radio"
aria-checked={target === k}
class:on={target === k}
class:current={k === unit.inertia}
disabled={k === unit.inertia || busy}
onclick={() => (target = k)}
>
<span class="k">{k}</span>
<span class="name">{t(`inertia.name.${k}`)}</span>
{#if k === unit.inertia}<span class="cur">{t('inertia.change.current')}</span>{/if}
</button>
{/each}
</div>
<button class="btn small" type="button" disabled={!target || busy} onclick={submit}>{t('inertia.change.submit')}</button>
<p class="faint small">{t('inertia.change.note', { level: inertiaLevel(unit.inertia) })}</p>
</details>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</section>
<style>
.inertia {
display: grid;
gap: 0.5rem;
min-width: 0;
}
h4 {
margin: 0;
font-size: 0.92rem;
font-weight: 800;
}
.small {
font-size: 0.84rem;
}
details {
display: grid;
gap: 0.6rem;
}
details > summary {
cursor: pointer;
font-weight: 750;
color: var(--water);
font-size: 0.92rem;
}
details[open] > summary {
margin-bottom: 0.6rem;
}
details > :not(summary) + :not(summary) {
margin-top: 0.6rem;
}
.levels {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.35rem;
}
.levels button {
display: grid;
justify-items: center;
align-content: start;
gap: 0.1rem;
min-height: 4rem;
padding: 0.5rem 0.25rem;
border: 0;
border-radius: var(--r-input);
background: var(--surface-2);
color: var(--ink-soft);
text-align: center;
transition: background-color 0.15s var(--ease), color 0.15s var(--ease);
}
.levels button:hover:not(:disabled) {
background: var(--water-wash);
color: var(--ink);
}
.levels button.current {
background: transparent;
cursor: default;
}
.levels button.on,
.levels button.on:hover {
background: var(--water);
color: var(--on-water);
}
.k {
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.name {
font-size: 0.74rem;
font-weight: 700;
line-height: 1.15;
}
.cur {
font-size: 0.68rem;
font-weight: 800;
color: var(--water);
}
.btn {
justify-self: start;
}
.link {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 700;
text-align: left;
font-size: 0.9rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
@media (prefers-reduced-motion: reduce) {
.levels button {
transition: none;
}
}
</style>
+9 -57
View File
@@ -2,11 +2,12 @@
import { api, ApiError } from '$lib/api'; import { api, ApiError } from '$lib/api';
import { shown, type DocState } from '$lib/doc.svelte'; import { shown, type DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { titleOf } from '$lib/labels'; import { inertiaBadge, titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte'; import { toasts } from '$lib/toast.svelte';
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types'; import type { DiffPart, Entered, Inertia, Lang, LevelInfo, Proposal } from '$lib/types';
import DiffText from './DiffText.svelte'; import DiffText from './DiffText.svelte';
import Formula from './Formula.svelte'; import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import Prose from './Prose.svelte'; import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte'; import WaterLevel from './WaterLevel.svelte';
@@ -16,12 +17,14 @@
lang, lang,
onopen, onopen,
onpropose, onpropose,
onsettings,
}: { }: {
doc: DocState; doc: DocState;
proposalId: string; proposalId: string;
lang: Lang; lang: Lang;
onopen: (proposalId: string) => void; onopen: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void; onpropose: (prefill: { title: string; body: string }) => void;
onsettings?: () => void;
} = $props(); } = $props();
interface Detail { interface Detail {
@@ -36,13 +39,13 @@
}[]; }[];
electorate: number; electorate: number;
voteFloor: number; voteFloor: number;
levels: LevelInfo[];
} }
let detail = $state<Detail | null>(null); let detail = $state<Detail | null>(null);
let busy = $state(false); let busy = $state(false);
let showDiff = $state(false); let showDiff = $state(false);
let error = $state(''); let error = $state('');
let inertiaTarget = $state<Inertia | ''>('');
let typoOpen = $state(false); let typoOpen = $state(false);
let typoTitle = $state(''); let typoTitle = $state('');
let typoBody = $state(''); let typoBody = $state('');
@@ -58,7 +61,6 @@
const source = $derived(view?.doc.sourceLang ?? 'fr'); const source = $derived(view?.doc.sourceLang ?? 'fr');
const text = $derived(p ? shown(p, lang, source) : null); const text = $derived(p ? shown(p, lang, source) : null);
const votable = $derived(p?.status === 'open' || p?.status === 'in_force'); const votable = $derived(p?.status === 'open' || p?.status === 'in_force');
const inertiaOpen = $derived((unit?.proposals ?? []).filter((x) => x.kind === 'inertia' && x.status === 'open'));
const signalTargets = $derived( const signalTargets = $derived(
(view?.units ?? []).flatMap((u) => (view?.units ?? []).flatMap((u) =>
[u.inForce, ...u.proposals] [u.inForce, ...u.proposals]
@@ -122,21 +124,6 @@
} }
} }
async function proposeInertia() {
if (!unit || !inertiaTarget) return;
busy = true;
try {
const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target: inertiaTarget });
inertiaTarget = '';
await doc.reload();
onopen(r.id);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function saveTypo(e: SubmitEvent) { async function saveTypo(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
busy = true; busy = true;
@@ -250,7 +237,7 @@
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p> <p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p> <p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if} {/if}
<p class="faint small">{t(`inertia.${p.votedAtInertia}`)}</p> <p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div> </div>
</div> </div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if} {#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
@@ -271,30 +258,9 @@
{/if} {/if}
</section> </section>
{#if p.kind !== 'inertia' && unit} {#if p.kind !== 'inertia' && unit}<InertiaPicker {doc} {unit} {onopen} />{/if}
<section class="sub">
<h4>{t('inertia.title')} · <span class="muted">{t(`inertia.level.${unit.inertia}`)}</span></h4>
<p class="faint small">{t('inertia.explain')}</p>
{#each inertiaOpen as ip (ip.id)}
<button class="link" type="button" onclick={() => onopen(ip.id)}>{t('inertia.change.open', { level: t(`inertia.level.${ip.inertiaTarget ?? 'standard'}`) })} — {t('tally.short', { for: ip.tally.for, against: ip.tally.against })}</button>
{/each}
<details>
<summary>{t('inertia.change')}</summary>
<div class="row">
<select class="select" bind:value={inertiaTarget} aria-label={t('inertia.change.to')}>
<option value="" disabled>{t('inertia.change.to')}</option>
{#each ['low', 'standard', 'high', 'max'] as const as level (level)}
{#if level !== unit.inertia}<option value={level}>{t(`inertia.level.${level}`)}</option>{/if}
{/each}
</select>
<button class="btn small" type="button" disabled={!inertiaTarget || busy} onclick={proposeInertia}>{t('inertia.change.submit')}</button>
</div>
<p class="faint small">{t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}</p>
</details>
</section>
{/if}
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} presets={view.inertia} floor={detail.voteFloor} base={view.doc.formula.base} /> <Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} levels={detail.levels} floor={detail.voteFloor} base={view.doc.formula.base} {onsettings} />
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable} {#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
<section class="sub"> <section class="sub">
@@ -474,11 +440,6 @@
color: var(--water); color: var(--water);
font-size: 0.92rem; font-size: 0.92rem;
} }
.row {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.form { .form {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
@@ -490,15 +451,6 @@
.textarea.short { .textarea.short {
min-height: 5rem; min-height: 5rem;
} }
.link {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 700;
text-align: left;
font-size: 0.9rem;
}
.history { .history {
margin: 0; margin: 0;
padding-left: 1rem; padding-left: 1rem;
+1 -1
View File
@@ -7,7 +7,7 @@ import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types'; import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all'; export type View = 'inforce' | 'wellrated' | 'all';
export type Tab = 'vote' | 'proposals' | 'propose'; export type Tab = 'vote' | 'proposals' | 'propose' | 'settings';
export interface PanelState { export interface PanelState {
unitId: string | null; unitId: string | null;
+36 -10
View File
@@ -134,7 +134,7 @@ export const en: Messages = {
'deep.rival': 'would replace “{title}”', 'deep.rival': 'would replace “{title}”',
'deep.addition': 'new article', 'deep.addition': 'new article',
'deep.removal': 'removal of “{title}”', 'deep.removal': 'removal of “{title}”',
'deep.inertia': 'inertia: {level}', 'deep.inertia': 'inertia to {level}',
'deep.blocked': 'blocked', 'deep.blocked': 'blocked',
'deep.mine': 'your proposal', 'deep.mine': 'your proposal',
'deep.stale': 'needs reconfirming', 'deep.stale': 'needs reconfirming',
@@ -163,19 +163,19 @@ export const en: Messages = {
'tally.missing.other': '{n} more votes in favor needed.', 'tally.missing.other': '{n} more votes in favor needed.',
'tally.reached': 'Threshold reached.', 'tally.reached': 'Threshold reached.',
'tally.level': 'Level: {pct}% of the threshold', 'tally.level': 'Level: {pct}% of the threshold',
'inertia.name.1': 'very low',
'inertia.name.2': 'low',
'inertia.name.3': 'medium',
'inertia.name.4': 'high',
'inertia.name.5': 'very high',
'inertia.level': '{k} / 5 · {name}',
'inertia.badge': 'inertia {level}',
'inertia.low': 'low inertia',
'inertia.standard': 'standard inertia',
'inertia.high': 'high inertia',
'inertia.max': 'maximum inertia',
'inertia.level.low': 'low',
'inertia.level.standard': 'standard',
'inertia.level.high': 'high',
'inertia.level.max': 'maximum',
'panel.vote': 'Voting', 'panel.vote': 'Voting',
'panel.proposals': 'Proposals', 'panel.proposals': 'Proposals',
'panel.propose': 'Propose', 'panel.propose': 'Propose',
'panel.settings': 'Settings',
'panel.close': 'Close panel', 'panel.close': 'Close panel',
'panel.inforce': 'In force', 'panel.inforce': 'In force',
'panel.proposal': 'Proposal', 'panel.proposal': 'Proposal',
@@ -205,9 +205,12 @@ export const en: Messages = {
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.', 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
'inertia.change': 'Propose an inertia change', 'inertia.change': 'Propose an inertia change',
'inertia.change.to': 'New inertia', 'inertia.change.to': 'New inertia',
'inertia.change.note': 'An inertia change is itself voted on with the next inertia up: {level}.', 'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.",
'inertia.change.submit': 'Submit to vote', 'inertia.change.submit': 'Submit to vote',
'inertia.change.open': 'Inertia change in progress: to {level}', 'inertia.change.open': 'Inertia change in progress: to {level}',
'inertia.change.current': 'current',
'inertia.history.vote': 'Went from {from} to {to} on {date}, by a vote.',
'inertia.history.amendment': 'Went from {from} to {to} on {date}: starting setting of the text.',
'formula.title': 'The threshold formula', '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 favor required, R, depends on turnout:',
@@ -221,6 +224,29 @@ export const en: Messages = {
'formula.C': 'C — minimum votes in favor: {n}', 'formula.C': 'C — minimum votes in favor: {n}',
'formula.R': 'R — current threshold: {n} votes in favor', 'formula.R': 'R — current threshold: {n} votes in favor',
'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.', 'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.',
'common.pct': '{n}%',
'settings.title': 'Formula settings',
'settings.intro': 'To change a text, the group asks for a number of votes in favour that depends on its electorate, its usual turnout and the inertia of the article.',
'settings.usual': 'Usual turnout',
'settings.usual.median.one': '{n} voter out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.',
'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.when': 'Measured once a day, last on {date}.',
'settings.usual.never': 'Measured once a day.',
'settings.levels': 'The five levels',
'settings.col.level': 'Level',
'settings.col.share': 'Share asked',
'settings.col.required': 'Votes for',
'settings.current': 'this article',
'settings.share.note.one': 'Share asked: the share of votes in favour beyond C when {n} person votes, as usual.',
'settings.share.note.other': 'Share asked: the share of votes in favour beyond C when {n} people vote, as usual.',
'settings.required.version.one': 'Votes for: those asked at the turnout of this version, {n} voter.',
'settings.required.version.other': 'Votes for: those asked at the turnout of this version, {n} voters.',
'settings.required.usual.one': 'Votes for: those asked at the usual turnout, {n} voter.',
'settings.required.usual.other': 'Votes for: those asked at the usual turnout, {n} voters.',
'settings.reading': 'At each level, the group asks for its share of votes in favour beyond C when it votes as usual; the more voters, the closer it comes to M. Nothing passes below C votes in favour.',
'history.title': 'Corrections', 'history.title': 'Corrections',
'history.initial': 'initial text', 'history.initial': 'initial text',
+36 -10
View File
@@ -134,7 +134,7 @@ export const es: Messages = {
'deep.rival': 'reemplazaría «{title}»', 'deep.rival': 'reemplazaría «{title}»',
'deep.addition': 'artículo nuevo', 'deep.addition': 'artículo nuevo',
'deep.removal': 'retirada de «{title}»', 'deep.removal': 'retirada de «{title}»',
'deep.inertia': 'inercia: {level}', 'deep.inertia': 'inercia a {level}',
'deep.blocked': 'bloqueada', 'deep.blocked': 'bloqueada',
'deep.mine': 'tu propuesta', 'deep.mine': 'tu propuesta',
'deep.stale': 'por reconfirmar', 'deep.stale': 'por reconfirmar',
@@ -163,19 +163,19 @@ export const es: Messages = {
'tally.missing.other': 'Faltan {n} votos a favor.', 'tally.missing.other': 'Faltan {n} votos a favor.',
'tally.reached': 'Umbral alcanzado.', 'tally.reached': 'Umbral alcanzado.',
'tally.level': 'Nivel: {pct} % del umbral', 'tally.level': 'Nivel: {pct} % del umbral',
'inertia.name.1': 'muy baja',
'inertia.name.2': 'baja',
'inertia.name.3': 'media',
'inertia.name.4': 'alta',
'inertia.name.5': 'muy alta',
'inertia.level': '{k} / 5 · {name}',
'inertia.badge': 'inercia {level}',
'inertia.low': 'inercia baja',
'inertia.standard': 'inercia estándar',
'inertia.high': 'inercia alta',
'inertia.max': 'inercia máxima',
'inertia.level.low': 'baja',
'inertia.level.standard': 'estándar',
'inertia.level.high': 'alta',
'inertia.level.max': 'máxima',
'panel.vote': 'Votación', 'panel.vote': 'Votación',
'panel.proposals': 'Propuestas', 'panel.proposals': 'Propuestas',
'panel.propose': 'Proponer', 'panel.propose': 'Proponer',
'panel.settings': 'Ajustes',
'panel.close': 'Cerrar el panel', 'panel.close': 'Cerrar el panel',
'panel.inforce': 'En vigor', 'panel.inforce': 'En vigor',
'panel.proposal': 'Propuesta', 'panel.proposal': 'Propuesta',
@@ -205,9 +205,12 @@ export const es: Messages = {
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.', 'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
'inertia.change': 'Proponer un cambio de inercia', 'inertia.change': 'Proponer un cambio de inercia',
'inertia.change.to': 'Nueva inercia', 'inertia.change.to': 'Nueva inercia',
'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia superior: {level}.', 'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia del artículo: {level}.',
'inertia.change.submit': 'Someter a votación', 'inertia.change.submit': 'Someter a votación',
'inertia.change.open': 'Cambio de inercia en curso: a {level}', 'inertia.change.open': 'Cambio de inercia en curso: a {level}',
'inertia.change.current': 'actual',
'inertia.history.vote': 'Pasó de {from} a {to} el {date}, por votación.',
'inertia.history.amendment': 'Pasó de {from} a {to} el {date}: ajuste inicial del texto.',
'formula.title': 'La fórmula del umbral', 'formula.title': 'La fórmula del umbral',
'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:', 'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:',
@@ -221,6 +224,29 @@ export const es: Messages = {
'formula.C': 'C — mínimo de votos a favor: {n}', 'formula.C': 'C — mínimo de votos a favor: {n}',
'formula.R': 'R — umbral actual: {n} votos a favor', 'formula.R': 'R — umbral actual: {n} votos a favor',
'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.', 'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.',
'common.pct': '{n} %',
'settings.title': 'Ajustes de la fórmula',
'settings.intro': 'Para cambiar un texto, el grupo pide un número de votos a favor que depende de su electorado, de su participación habitual y de la inercia del artículo.',
'settings.usual': 'Participación habitual',
'settings.usual.median.one': '{n} votante de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.',
'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.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',
'settings.col.level': 'Nivel',
'settings.col.share': 'Proporción exigida',
'settings.col.required': 'Votos a favor',
'settings.current': 'este artículo',
'settings.share.note.one': 'Proporción exigida: la parte de votos a favor más allá de C cuando vota {n} persona, como de costumbre.',
'settings.share.note.other': 'Proporción exigida: la parte de votos a favor más allá de C cuando votan {n} personas, como de costumbre.',
'settings.required.version.one': 'Votos a favor: los exigidos con la participación de esta versión, {n} votante.',
'settings.required.version.other': 'Votos a favor: los exigidos con la participación de esta versión, {n} votantes.',
'settings.required.usual.one': 'Votos a favor: los exigidos con la participación habitual, {n} votante.',
'settings.required.usual.other': 'Votos a favor: los exigidos con la participación habitual, {n} votantes.',
'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor más allá de C cuando vota como de costumbre; cuantos más votantes, más se acerca a M. Nada pasa con menos de C votos a favor.',
'history.title': 'Correcciones', 'history.title': 'Correcciones',
'history.initial': 'texto inicial', 'history.initial': 'texto inicial',
+36 -10
View File
@@ -137,7 +137,7 @@ export const fr = {
'deep.rival': 'remplacerait « {title} »', 'deep.rival': 'remplacerait « {title} »',
'deep.addition': 'nouvel article', 'deep.addition': 'nouvel article',
'deep.removal': 'retrait de « {title} »', 'deep.removal': 'retrait de « {title} »',
'deep.inertia': 'inertie : {level}', 'deep.inertia': 'inertie vers {level}',
'deep.blocked': 'bloquée', 'deep.blocked': 'bloquée',
'deep.mine': 'ta proposition', 'deep.mine': 'ta proposition',
'deep.stale': 'à reconfirmer', 'deep.stale': 'à reconfirmer',
@@ -166,19 +166,19 @@ export const fr = {
'tally.missing.other': 'Il manque {n} voix pour.', 'tally.missing.other': 'Il manque {n} voix pour.',
'tally.reached': 'Seuil atteint.', 'tally.reached': 'Seuil atteint.',
'tally.level': 'Niveau : {pct} % du seuil', 'tally.level': 'Niveau : {pct} % du seuil',
'inertia.name.1': 'très basse',
'inertia.name.2': 'basse',
'inertia.name.3': 'moyenne',
'inertia.name.4': 'haute',
'inertia.name.5': 'très haute',
'inertia.level': '{k} / 5 · {name}',
'inertia.badge': 'inertie {level}',
'inertia.low': 'inertie faible',
'inertia.standard': 'inertie standard',
'inertia.high': 'inertie haute',
'inertia.max': 'inertie maximale',
'inertia.level.low': 'faible',
'inertia.level.standard': 'standard',
'inertia.level.high': 'haute',
'inertia.level.max': 'maximale',
'panel.vote': 'Le vote', 'panel.vote': 'Le vote',
'panel.proposals': 'Propositions', 'panel.proposals': 'Propositions',
'panel.propose': 'Proposer', 'panel.propose': 'Proposer',
'panel.settings': 'Réglages',
'panel.close': 'Fermer le panneau', 'panel.close': 'Fermer le panneau',
'panel.inforce': 'En vigueur', 'panel.inforce': 'En vigueur',
'panel.proposal': 'Proposition', 'panel.proposal': 'Proposition',
@@ -208,9 +208,12 @@ export const fr = {
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.", 'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
'inertia.change': "Proposer un changement d'inertie", 'inertia.change': "Proposer un changement d'inertie",
'inertia.change.to': 'Nouvelle inertie', 'inertia.change.to': 'Nouvelle inertie',
'inertia.change.note': "Un changement d'inertie se vote lui-même avec l'inertie au-dessus : {level}.", 'inertia.change.note': "Un changement d'inertie se vote lui-même à l'inertie de l'article : {level}.",
'inertia.change.submit': 'Soumettre au vote', 'inertia.change.submit': 'Soumettre au vote',
'inertia.change.open': "Changement d'inertie en cours : vers {level}", 'inertia.change.open': "Changement d'inertie en cours : vers {level}",
'inertia.change.current': 'actuelle',
'inertia.history.vote': 'Passée de {from} à {to} le {date}, par un vote.',
'inertia.history.amendment': 'Passée de {from} à {to} le {date} : réglage de départ du texte.',
'formula.title': 'La formule du seuil', 'formula.title': 'La formule du seuil',
'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :', 'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :',
@@ -224,6 +227,29 @@ export const fr = {
'formula.C': 'C — minimum de voix pour : {n}', 'formula.C': 'C — minimum de voix pour : {n}',
'formula.R': 'R — seuil actuel : {n} voix pour', 'formula.R': 'R — seuil actuel : {n} voix pour',
'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.", 'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.",
'common.pct': '{n} %',
'settings.title': 'Réglages de la formule',
'settings.intro': "Pour changer un texte, le groupe demande un nombre de voix pour qui dépend de son électorat, de sa participation habituelle et de l'inertie de l'article.",
'settings.usual': 'Participation habituelle',
'settings.usual.median.one': '{n} votant sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.',
'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.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',
'settings.col.level': 'Cran',
'settings.col.share': 'Part exigée',
'settings.col.required': 'Voix pour',
'settings.current': 'cet article',
'settings.share.note.one': "Part exigée : la part de « pour » au-delà de C quand {n} personne vote, comme d'habitude.",
'settings.share.note.other': "Part exigée : la part de « pour » au-delà de C quand {n} personnes votent, comme d'habitude.",
'settings.required.version.one': 'Voix pour : celles exigées à la participation de cette version, {n} votant.',
'settings.required.version.other': 'Voix pour : celles exigées à la participation de cette version, {n} votants.',
'settings.required.usual.one': 'Voix pour : celles exigées à la participation habituelle, {n} votant.',
'settings.required.usual.other': 'Voix pour : celles exigées à la participation habituelle, {n} votants.',
'settings.reading': "À chaque cran, le groupe demande sa part de « pour » au-delà de C quand il vote comme d'habitude ; plus il y a de votants, plus elle se rapproche de M. Rien ne passe sous C voix pour.",
'history.title': 'Corrections', 'history.title': 'Corrections',
'history.initial': 'texte initial', 'history.initial': 'texte initial',
+12 -2
View File
@@ -1,10 +1,20 @@
import { t } from './i18n/index.svelte'; import { t } from './i18n/index.svelte';
import type { Proposal, Unit } from './types'; import type { Inertia, Proposal, Unit } from './types';
/** « 3 / 5 · moyenne ». */
export function inertiaLevel(k: Inertia): string {
return t('inertia.level', { k, name: t(`inertia.name.${k}`) });
}
/** « inertie 3 / 5 · moyenne ». */
export function inertiaBadge(k: Inertia): string {
return t('inertia.badge', { level: inertiaLevel(k) });
}
/** What a proposal is, in words, whatever its kind. */ /** What a proposal is, in words, whatever its kind. */
export function titleOf(p: Proposal, unit: Unit | undefined): string { export function titleOf(p: Proposal, unit: Unit | undefined): string {
if (p.kind === 'inertia' && p.inertiaTarget) { if (p.kind === 'inertia' && p.inertiaTarget) {
return t('deep.inertia', { level: t(`inertia.level.${p.inertiaTarget}`) }); return t('deep.inertia', { level: inertiaLevel(p.inertiaTarget) });
} }
if (p.kind === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title }); if (p.kind === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title });
return p.title; return p.title;
+39 -2
View File
@@ -1,6 +1,40 @@
export type Lang = 'fr' | 'en' | 'es'; export type Lang = 'fr' | 'en' | 'es';
export type LangMap = Partial<Record<Lang, string>>; export type LangMap = Partial<Record<Lang, string>>;
export type Inertia = 'low' | 'standard' | 'high' | 'max'; /** The five levels of inertia, 1 (lightest) to 5 (heaviest); 3 by default. */
export type Inertia = 1 | 2 | 3 | 4 | 5;
export const INERTIA_LEVELS: Inertia[] = [1, 2, 3, 4, 5];
/** One level on the group's scale: M and G of the formula, s_k, and the *for* votes asked at `cast` votes. */
export interface LevelInfo {
level: Inertia;
majorityPct: number;
gradient: number;
sharePct: number;
cast: number;
required: number | null;
}
/** p̃ — the usual turnout of the group, measured once a day (or the reference 2C/W). */
export interface ParticipationInfo {
ratio: number;
source: 'median' | 'reference';
sample: number;
median: number | null;
bounded: boolean;
voters: number;
minSample: number;
windowDays: number;
everyHours: number;
bounds: [number, number];
measuredAt: string | null;
}
export interface InertiaChange {
from: Inertia | null;
to: Inertia | null;
cause: 'vote' | 'amendment';
at: string;
}
export interface Me { export interface Me {
id: string; id: string;
@@ -75,6 +109,7 @@ export interface Unit {
section: string | null; section: string | null;
position: number; position: number;
inertia: Inertia; inertia: Inertia;
inertiaChanges: InertiaChange[];
inForce: Proposal | null; inForce: Proposal | null;
proposals: Proposal[]; proposals: Proposal[];
} }
@@ -103,10 +138,12 @@ export interface DocView {
electorate: number; electorate: number;
electorateSource: 'wot' | 'group'; electorateSource: 'wot' | 'group';
formula: { base: number }; formula: { base: number };
participation: ParticipationInfo;
publication: Publication; publication: Publication;
}; };
units: Unit[]; units: Unit[];
inertia: { key: Inertia; majorityPct: number; gradient: number }[]; /** The five levels, with the *for* votes asked at the usual turnout. */
levels: LevelInfo[];
} }
export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string }; export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string };
@@ -20,8 +20,8 @@
}); });
// The panel lives in the URL: a link (or a feedback report) reopens it. // The panel lives in the URL: a link (or a feedback report) reopens it.
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose' }; const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer' }; const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' };
let prefill = $state<PanelState['prefill']>(null); let prefill = $state<PanelState['prefill']>(null);
const panel = $derived.by((): PanelState | null => { const panel = $derived.by((): PanelState | null => {
const q = page.url.searchParams; const q = page.url.searchParams;
@@ -77,6 +77,7 @@
<p class="facts faint"> <p class="facts faint">
{i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)} {i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)}
{#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if} {#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if}
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
</p> </p>
<div class="controls"> <div class="controls">
+22
View File
@@ -0,0 +1,22 @@
// The five levels of inertia, as the screen names them (ADR 0007).
import { describe, expect, it } from 'vitest';
import { en } from '../../src/lib/i18n/en';
import { es } from '../../src/lib/i18n/es';
import { fr } from '../../src/lib/i18n/fr';
import { INERTIA_LEVELS } from '../../src/lib/types';
describe('five levels of inertia', () => {
it('go from 1 to 5, 3 in the middle', () => {
expect(INERTIA_LEVELS).toEqual([1, 2, 3, 4, 5]);
});
it('each level has its own short name, in the three languages', () => {
expect(INERTIA_LEVELS.map((k) => fr[`inertia.name.${k}`])).toEqual(['très basse', 'basse', 'moyenne', 'haute', 'très haute']);
expect(INERTIA_LEVELS.map((k) => en[`inertia.name.${k}`])).toEqual(['very low', 'low', 'medium', 'high', 'very high']);
expect(INERTIA_LEVELS.map((k) => es[`inertia.name.${k}`])).toEqual(['muy baja', 'baja', 'media', 'alta', 'muy alta']);
});
it('a change of inertia is said to be voted at the level of the article, no longer one above', () => {
for (const cat of [fr, en, es]) expect(cat['inertia.change.note']).toContain('{level}');
expect(fr['inertia.change.note']).toContain("à l'inertie de l'article");
expect(fr['inertia.change.note']).not.toContain('au-dessus');
});
});
+2 -2
View File
@@ -8,13 +8,13 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false }, evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
votedAtInertia: 'standard', blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {}, votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {},
...over, ...over,
}; };
} }
function unit(id: string, proposals: Proposal[]): Unit { function unit(id: string, proposals: Proposal[]): Unit {
return { id, code: id, section: null, position: 0, inertia: 'standard', inForce: null, proposals }; return { id, code: id, section: null, position: 0, inertia: 3, inertiaChanges: [], inForce: null, proposals };
} }
/** The reader's list: every open proposal, unit by unit, in document order. */ /** The reader's list: every open proposal, unit by unit, in document order. */