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:
Yvv
2026-11-03 05:00:41 +01:00
co-authored by Claude Opus 5.5
parent 37147d1f59
commit 01b7841472
20 changed files with 1205 additions and 95 deletions
+33 -19
View File
@@ -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(&params).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!(