forked from yvv/decision
Fusion de chantier/acte-certification (lots H et S) dans le lot V : le tampon et ses états, avec le quorum et les Propositions seed
- conflits résolus des deux côtés : kept_params garde le quorum (S) et la part simulée, la version déplacée, le cran d'avant (V) ; proposals.rs garde seedMinimal / seedProposal (S) et tallyPending / tallySimulated / myVoteState (V) ; - simulation::reexamine juge sur le quorum gardé par l'adoption (params.quorum, à défaut celui de l'article) ; - l'acte répété n'examine plus une adoption qui tient (params.reexamined) ; - tests V repris sans texte en vigueur au départ (I1, E1 : la Proposition seed) et sur un quorum 2 (small_web) ; le test overview d'une personne invitée dans Monnaie libre suit la simulation (elle vote, chaîne fermée). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+101
-20
@@ -16,7 +16,7 @@ use ld_engine::ballot::Choice;
|
||||
use ld_engine::buffer::{self, Rules, Stage};
|
||||
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
|
||||
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
|
||||
use ld_engine::threshold::{Evaluation, Tally};
|
||||
use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
@@ -197,8 +197,21 @@ pub struct DocRow {
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub source_lang: String,
|
||||
/// The reference quorum Q₀ (C before 2026-11-03): read for the reference
|
||||
/// participation p0 = 2Q₀/W only, and as every level's quorum while `quorums` is NULL.
|
||||
pub vote_floor: f64,
|
||||
pub publish_path: Option<String>,
|
||||
/// The quorum of each level, JSON `[Q1, …, Q5]` (ADR 0013); NULL before the quorum
|
||||
/// amendment (`quorum_amend`).
|
||||
pub quorums: Option<String>,
|
||||
}
|
||||
|
||||
impl DocRow {
|
||||
/// The quorum of each level, 1 to 5: the document's, or, in a base the quorum
|
||||
/// amendment has not reached yet, its reference quorum at every level — the C of before.
|
||||
pub fn quorums(&self) -> [u32; 5] {
|
||||
self.quorums.as_deref().and_then(|raw| serde_json::from_str(raw).ok()).unwrap_or([whole(self.vote_floor); 5])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
@@ -209,6 +222,16 @@ pub struct UnitRow {
|
||||
pub position: f64,
|
||||
pub inertia: String,
|
||||
pub in_force_id: Option<String>,
|
||||
/// Its own quorum, set by the author of its first version before the first vote
|
||||
/// (`quorum`); NULL: the quorum of its level.
|
||||
pub quorum: Option<i64>,
|
||||
}
|
||||
|
||||
impl UnitRow {
|
||||
/// Its own quorum, if its author set one.
|
||||
pub fn own_quorum(&self) -> Option<u32> {
|
||||
self.quorum.and_then(|q| u32::try_from(q).ok())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
@@ -233,15 +256,29 @@ pub struct ProposalRow {
|
||||
pub submitted_at: Option<String>,
|
||||
pub entered_at: Option<String>,
|
||||
pub confirmed_at: Option<String>,
|
||||
/// Put forward by the seed to start the vote (ADR 0011): see `seed_proposal`.
|
||||
pub seed_featured: bool,
|
||||
}
|
||||
|
||||
pub const PROPOSAL_SELECT: &str =
|
||||
"SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin,
|
||||
p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at,
|
||||
p.entered_at, p.confirmed_at
|
||||
p.entered_at, p.confirmed_at, p.seed_featured
|
||||
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
|
||||
|
||||
impl ProposalRow {
|
||||
/// A seed a minima: in force from the seed, never voted into force nor confirmed
|
||||
/// (the title and the orders of a new base; a text a seed proposal amendment left).
|
||||
pub fn seed_minimal(&self) -> bool {
|
||||
self.status == "in_force" && self.entered_at.is_none() && self.confirmed_at.is_none() && self.origin == "seed"
|
||||
}
|
||||
|
||||
/// A seed proposal (ADR 0011): open, from the seed, put forward to start the vote,
|
||||
/// never voted into force. Not in force: it enters like any proposal.
|
||||
pub fn seed_proposal(&self) -> bool {
|
||||
self.seed_featured && self.status == "open" && self.origin == "seed" && self.entered_at.is_none()
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ProposalKind {
|
||||
match self.kind.as_str() {
|
||||
"remove" => ProposalKind::Remove,
|
||||
@@ -295,7 +332,8 @@ pub struct DocState {
|
||||
|
||||
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
|
||||
sqlx::query_as::<_, DocRow>(
|
||||
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?",
|
||||
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, quorums
|
||||
FROM documents WHERE id = ?",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -322,7 +360,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
let doc = load_doc(state, doc_id).await?;
|
||||
let group = load_group(state, &doc.group_slug).await?;
|
||||
let units = sqlx::query_as::<_, UnitRow>(
|
||||
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
"SELECT id, code, section, position, inertia, in_force_id, quorum FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
@@ -443,9 +481,25 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// What this document's votes are judged by: W, C and p̃.
|
||||
/// What this document's votes are judged by: W, p̃ and the quorum of each level.
|
||||
pub fn scale(&self) -> Scale {
|
||||
Scale { electorate: self.electorate, floor: self.doc.vote_floor, participation: self.participation.ratio }
|
||||
Scale { electorate: self.electorate, participation: self.participation.ratio, quorums: self.doc.quorums() }
|
||||
}
|
||||
|
||||
/// The quorum an article is judged on, and its versions with it: its own, else its
|
||||
/// level's (`Scale::quorum`).
|
||||
pub fn quorum_of(&self, unit: &UnitRow) -> u32 {
|
||||
self.scale().quorum(inertia_of(unit), unit.own_quorum())
|
||||
}
|
||||
|
||||
/// The quorum of the article's level: what it is judged on without one of its own.
|
||||
pub fn quorum_default(&self, unit: &UnitRow) -> u32 {
|
||||
self.scale().quorum(inertia_of(unit), None)
|
||||
}
|
||||
|
||||
/// The letters a proposal is judged by: its level and its article's quorum.
|
||||
pub fn params(&self, unit: &UnitRow, p: &ProposalRow) -> FormulaParams {
|
||||
compose::params_for(&bare(unit), p.kind(), &self.scale())
|
||||
}
|
||||
|
||||
/// p̃ · W, as a whole number of voters.
|
||||
@@ -489,7 +543,7 @@ impl DocState {
|
||||
|
||||
/// How far the threshold is, as shown: on `shown_tally`.
|
||||
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
|
||||
threshold::evaluate(self.shown_tally(&p.id), self.electorate, &self.params(unit, p)).ok()
|
||||
}
|
||||
|
||||
/// Open to a vote: open or in force, and not held back by an objection.
|
||||
@@ -578,6 +632,7 @@ impl DocState {
|
||||
id: u.id.clone(),
|
||||
position: u.position,
|
||||
inertia: inertia_of(u),
|
||||
quorum: u.own_quorum(),
|
||||
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) {
|
||||
Some(order) => VersionText {
|
||||
id: p.id.clone(),
|
||||
@@ -630,13 +685,12 @@ impl DocState {
|
||||
}
|
||||
|
||||
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||
self.scale().evaluate(self.tally(&p.id), self.voting_level(unit, p)).ok()
|
||||
threshold::evaluate(self.tally(&p.id), self.electorate, &self.params(unit, p)).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())
|
||||
compose::voting_level(&bare(unit), p.kind())
|
||||
}
|
||||
|
||||
/// The usual participation, as the screen tells it.
|
||||
@@ -650,22 +704,36 @@ impl DocState {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// The five levels on this scale: M, G, s_k, the quorum, and the *for* votes asked at
|
||||
/// `cast` votes — on an article's own quorum if it has one (`own`), else each level's.
|
||||
pub fn levels_json(&self, cast: u32, own: Option<u32>) -> Vec<serde_json::Value> {
|
||||
let scale = self.scale();
|
||||
InertiaLevel::ALL
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let params = scale.params(*k);
|
||||
let quorum = scale.quorum(*k, own);
|
||||
let params = scale.params(*k, quorum);
|
||||
json!({
|
||||
"level": k, "majorityPct": params.majority_pct, "gradient": params.gradient,
|
||||
"sharePct": k.share_pct(), "cast": cast, "required": scale.required(cast, *k).ok(),
|
||||
"sharePct": k.share_pct(), "quorum": quorum, "cast": cast,
|
||||
"required": scale.required(cast, *k, quorum).ok(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit as the engine sees it, without its text: its level and its own quorum.
|
||||
fn bare(unit: &UnitRow) -> UnitState {
|
||||
UnitState {
|
||||
id: unit.id.clone(),
|
||||
position: unit.position,
|
||||
inertia: inertia_of(unit),
|
||||
quorum: unit.own_quorum(),
|
||||
in_force: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
@@ -741,7 +809,7 @@ fn proposal_json(
|
||||
"mine": p.author_id.as_deref() == Some(me),
|
||||
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_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.seed_minimal(), "seedProposal": p.seed_proposal(),
|
||||
"tally": st.shown_tally(&p.id),
|
||||
"tallyPending": st.pending_tally(&p.id),
|
||||
"tallySimulated": st.simulated_tally(&p.id),
|
||||
@@ -776,6 +844,7 @@ pub async fn document(
|
||||
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
|
||||
|
||||
let changes = inertia_changes(&state, &id).await?;
|
||||
let rights = crate::quorum::Rights::of(&state, &id).await?;
|
||||
let unit_json = |u: &UnitRow| {
|
||||
let of_unit: Vec<_> = visible
|
||||
.iter()
|
||||
@@ -794,11 +863,18 @@ pub async fn document(
|
||||
.as_ref()
|
||||
.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();
|
||||
json!({
|
||||
let mut j = json!({
|
||||
"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,
|
||||
})
|
||||
});
|
||||
// Its quorum (ADR 0013): its own or `null`, its level's, and who may set it.
|
||||
if let (Some(o), serde_json::Value::Object(q)) =
|
||||
(j.as_object_mut(), crate::quorum::unit_json(&st, &rights, &user, u))
|
||||
{
|
||||
o.extend(q);
|
||||
}
|
||||
j
|
||||
};
|
||||
let units: Vec<_> = st.articles().map(unit_json).collect();
|
||||
// The modules (ADR 0009): the title of the document, the order of each section.
|
||||
@@ -828,7 +904,7 @@ pub async fn document(
|
||||
"doc": {
|
||||
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
||||
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
||||
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
|
||||
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(),
|
||||
"electorate": st.electorate, "electorateSource": st.electorate_source,
|
||||
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
|
||||
"participation": st.participation_json(),
|
||||
@@ -837,7 +913,7 @@ pub async fn document(
|
||||
"units": units,
|
||||
"modules": { "title": title, "orders": orders },
|
||||
// The five levels, with the *for* votes asked at the usual participation.
|
||||
"levels": st.levels_json(st.usual_voters()),
|
||||
"levels": st.levels_json(st.usual_voters(), None),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -882,10 +958,15 @@ pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<ser
|
||||
.bind(doc_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
// Waiting, not failing: no article in force, nothing goes out (ADR 0011).
|
||||
let suspended = match &last {
|
||||
Some(r) if r.done_at.is_none() => crate::publish::waits_for_a_text(state, doc_id).await?,
|
||||
_ => false,
|
||||
};
|
||||
Ok(match last {
|
||||
None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }),
|
||||
Some(r) => json!({
|
||||
"state": if r.done_at.is_some() { "done" } else { "pending" },
|
||||
"state": if r.done_at.is_some() { "done" } else if suspended { "suspended" } else { "pending" },
|
||||
"configured": state.cfg.publish.is_some(),
|
||||
"since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha,
|
||||
"attempts": r.attempts, "error": r.last_error.map(|_| "retrying"),
|
||||
|
||||
Reference in New Issue
Block a user