forked from yvv/decision
Lot S, phase 2 : le quorum dans le serveur (ADR 0013), vu rouge puis vert
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ 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_engine::threshold::{judged_voters, required_ratio};
|
||||
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
@@ -95,6 +95,9 @@ fn level(k: u8) -> InertiaLevel {
|
||||
/// The level seed proposals start at (ADR 0011).
|
||||
const LOW: u8 = ld_server::seed::SEED_PROPOSAL_LEVEL;
|
||||
|
||||
/// The quorums of Monnaie libre by level (ADR 0013, Yvv, 2026-11-02).
|
||||
const QUORUMS: [u32; 5] = [12, 12, 50, 50, 50];
|
||||
|
||||
/// The seed production runs on 2026-11-02 (3f10a0e): I1, E1, E2 in force, E1 and E2 at 4.
|
||||
fn production_seed_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02")
|
||||
@@ -137,22 +140,26 @@ async fn the_five_levels_and_the_scale_are_served() {
|
||||
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 };
|
||||
// The quorums by level (ADR 0013, Yvv: « 12 · 12 · 50 · 50 · 50 »).
|
||||
assert_eq!(numbers(levels, "quorum"), [12.0, 12.0, 50.0, 50.0, 50.0]);
|
||||
let scale = Scale { electorate: 5745, participation: ratio, quorums: QUORUMS };
|
||||
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.
|
||||
// At the usual participation, above every quorum: s_k of the voters, nothing in front.
|
||||
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()));
|
||||
let q = scale.quorum(k, None);
|
||||
assert_eq!(l["required"].as_u64(), Some(u64::from(scale.required(100, k, q).expect("valid"))));
|
||||
assert_eq!(l["required"].as_f64(), Some(k.share_pct().ceil()));
|
||||
}
|
||||
assert_eq!(numbers(levels, "required"), [60.0, 67.0, 75.0, 83.0, 90.0]);
|
||||
|
||||
// A version: the five levels at its own participation, and the level it is voted at.
|
||||
let e1 = unit(&doc, "E1")["proposals"][0]["id"].as_str().expect("e1").to_owned();
|
||||
let d = c.get(&format!("/api/proposals/{e1}")).await;
|
||||
assert_eq!(d["proposal"]["votedAtInertia"], LOW);
|
||||
assert_eq!(numbers(&d["levels"], "cast"), [0.0; 5]);
|
||||
assert_eq!(numbers(&d["levels"], "required"), [50.0; 5], "nothing passes under C");
|
||||
// No vote: judged on the quorum, the voters missing counted as against (ADR 0013).
|
||||
assert_eq!(numbers(&d["levels"], "required"), [8.0, 9.0, 39.0, 43.0, 46.0], "the quorum, not C");
|
||||
assert_eq!(d["participation"]["source"], "reference");
|
||||
assert_eq!(d["electorateSource"], "wot");
|
||||
}
|
||||
@@ -180,9 +187,9 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled(
|
||||
(d["proposal"]["inertiaTarget"].as_u64(), d["proposal"]["votedAtInertia"].as_u64()),
|
||||
(Some(5), Some(u64::from(LOW)))
|
||||
);
|
||||
// W(essai) = 2: both vote for, the article goes to level 5.
|
||||
a.vote(&p, "for").await;
|
||||
let v = b.vote(&p, "for").await;
|
||||
// W(essai) = 2, the quorum 1 everywhere in the test group (ADR 0013): one voice
|
||||
// passes, the article goes to level 5.
|
||||
let v = a.vote(&p, "for").await;
|
||||
assert_eq!(v["entered"][0]["kind"], "inertia");
|
||||
let doc = a.doc("essai").await;
|
||||
let e2u = unit(&doc, "E2");
|
||||
@@ -200,11 +207,12 @@ async fn a_change_of_inertia_is_voted_at_the_level_of_its_article_and_journaled(
|
||||
.await
|
||||
.expect("adoption");
|
||||
let params: Value = serde_json::from_str(¶ms).expect("json");
|
||||
let judged = Scale { electorate: 2, floor: 1.0, participation: 0.5 }.params(level(LOW));
|
||||
let judged = Scale { electorate: 2, participation: 0.5, quorums: [1; 5] }.params(level(LOW), 1);
|
||||
assert_eq!(
|
||||
(params["level"].as_u64(), params["majority_pct"].as_f64(), params["share_pct"].as_f64()),
|
||||
(Some(u64::from(LOW)), Some(judged.majority_pct), Some(level(LOW).share_pct()))
|
||||
);
|
||||
assert_eq!((params["quorum"].as_u64(), params.get("constant")), (Some(1), None), "the quorum, no C");
|
||||
assert_eq!(
|
||||
(params["participation"].as_f64(), params["participation_source"].as_str()),
|
||||
(Some(0.5), Some("reference"))
|
||||
@@ -254,9 +262,9 @@ async fn the_usual_participation_is_the_median_of_five_voted_proposals() {
|
||||
}
|
||||
// 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 scale = Scale { electorate: 10, participation: 0.3, quorums: [1; 5] };
|
||||
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
|
||||
assert!((ratio - required_ratio(10, 4, &scale.params(level(3)))).abs() < 1e-12);
|
||||
assert!((ratio - required_ratio(10, 4, &scale.params(level(3), 1))).abs() < 1e-12);
|
||||
|
||||
// A new vote does not move it within the day…
|
||||
people[4].vote(&open[0], "against").await;
|
||||
@@ -623,10 +631,13 @@ async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4()
|
||||
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);
|
||||
// One voter, under the quorum of level 4 (50): judged on 50 voters, 49 counted against.
|
||||
let scale = Scale { electorate: 5745, participation: p, quorums: QUORUMS };
|
||||
let voters = judged_voters(1, 50);
|
||||
assert!((ratio - required_ratio(5745, voters, &scale.params(level(4), 50))).abs() < 1e-12);
|
||||
assert!((ratio - required_ratio(5745, voters, &scale.params(level(3), 50))).abs() > 1e-6, "not at the old level");
|
||||
assert_eq!(d["proposal"]["evaluation"]["quorum"], 50);
|
||||
assert_eq!(d["proposal"]["evaluation"]["required"], 43);
|
||||
// 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
|
||||
@@ -666,8 +677,11 @@ async fn an_inertia_changed_by_a_vote_keeps_its_level() {
|
||||
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");
|
||||
// One voice of the two: before the quorum amendment, the C = 1 of the test group is
|
||||
// read as the quorum of every level (ADR 0013).
|
||||
assert_eq!(a.vote(&p, "for").await["entered"][0]["kind"], "inertia");
|
||||
let (s, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("not_votable")), "already decided");
|
||||
|
||||
let state = restart(&pool).await;
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user