forked from yvv/decision
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:
@@ -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` ;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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, ¶ms_for(unit, p.kind, scale))?;
|
||||||
p: &'a ProposalState,
|
|
||||||
electorate: u32,
|
|
||||||
floor: f64,
|
|
||||||
) -> Result<Scored<'a>, ThresholdError> {
|
|
||||||
let e = evaluate(p.tally, electorate, ¶ms_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, ¶ms_for(unit, ProposalKind::Text, floor))?;
|
let e = evaluate(v.tally, scale.electorate, ¶ms_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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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(¶ms).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
@@ -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
|
||||||
|
|||||||
@@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(¶ms).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");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user