v3 : modules d'ordre et de titre — moteur, données, serveur (L11)

L'ordre des articles de chaque section (cran 2) et le titre du document
(cran 4) deviennent des modules : des unités à part, dont les versions
sont des propositions votées comme les articles (dépliées, panier, chaîne).

- moteur : ld-engine/src/order.rs (qui est dans l'ordre, ordre proposé,
  complété à la fin quand un article entre ou sort, places redistribuées,
  nouvel article en fin de section) ; pending : un ordre de moins de deux
  articles n'est jamais à voter ;
- données : migration 0007 (units.kind, module_amendments) ; le seed
  déclare ses modules ; module_amend.rs les ajoute une fois, gardé et
  journalisé, aux documents nés sans eux, sans toucher articles ni votes ;
- serveur : modules.rs, order.rs, doc_title.rs (portes pour proposer,
  entrée en vigueur : les articles changent de place, le document change
  de titre, en et es passent « à revoir ») ; publication des modules ;
- reste mineur : la médiane compte les statuts open, in_force, applied,
  et eux seuls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 02:08:44 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit 3831ddb1bf
21 changed files with 2249 additions and 115 deletions
+1
View File
@@ -13,6 +13,7 @@ pub mod compose;
pub mod edit;
pub mod inertia;
pub mod names;
pub mod order;
pub mod pending;
pub mod threshold;
+329
View File
@@ -0,0 +1,329 @@
//! The order of the articles of a section — a module of its own.
//!
//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de
//! changer l'ordre des "articles". Lui-même à (2), avec une simple liste
//! clipable. » The order of a section is voted like an article: its version in
//! force is the order the section reads in now; a proposal is another order.
//!
//! The rules, purely:
//!
//! - **who is in the order** (`members`): the articles of the section that a
//! person can see — a text in force, or an open version. A draft is nobody's
//! but its author's; an article whose versions were all withdrawn, or that
//! was removed with nothing left open, is out;
//! - **an order proposed** names every article of the section, each once
//! (`check`);
//! - **articles come and go** while an order is under vote: the proposal is
//! **completed at the end** (`fit`). The articles it names that are still in
//! the section keep its order; those that entered since go at the end, in the
//! order of the section; those that left are dropped. Its votes stay: the
//! voters chose the relative order of the articles they saw, and a newcomer
//! at the end is where a new article goes anyway (`place_at_end`). Neither
//! lapsing nor asking every voter again would let an order ever pass in a
//! section that lives;
//! - an order that, so completed, is the order in force changes nothing and
//! cannot enter into force (the engine compares `canonical` texts);
//! - **entering into force** gives the section's places out again in the new
//! order (`positions`): the section keeps the positions it holds, so it never
//! mingles with its neighbours;
//! - an order of fewer than two articles has nothing to decide (`is_moot`).
//!
//! The vote itself — threshold, margin over the version in force (ADR 0002) —
//! is that of any version (`compose`).
/// An article as the order of its section sees it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Article<'a> {
pub id: &'a str,
pub section: Option<&'a str>,
pub position: f64,
/// A text in force, or at least one open version (text or removal).
pub visible: bool,
}
/// Below this many articles, an order has nothing to decide.
pub const MIN_ARTICLES: usize = 2;
pub fn is_moot(members: &[String]) -> bool {
members.len() < MIN_ARTICLES
}
/// The articles in the order of `section`, as it reads now: by position, then id.
pub fn members(articles: &[Article<'_>], section: &str) -> Vec<String> {
let mut inside: Vec<&Article<'_>> = articles.iter().filter(|a| a.visible && a.section == Some(section)).collect();
inside.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(b.id)));
inside.into_iter().map(|a| a.id.to_owned()).collect()
}
/// What an order proposal gives the section now.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Fitted {
/// The order as it would read now.
pub order: Vec<String>,
/// Articles that entered the section since it was proposed: at the end.
pub added: Vec<String>,
/// Articles it names that left the section: dropped.
pub dropped: Vec<String>,
}
/// An order proposal against the section as it is now: completed at the end.
pub fn fit(proposed: &[String], members: &[String]) -> Fitted {
let mut order: Vec<String> = Vec::with_capacity(members.len());
let mut dropped = Vec::new();
for id in proposed {
if order.contains(id) {
continue;
}
if members.contains(id) {
order.push(id.clone());
} else if !dropped.contains(id) {
dropped.push(id.clone());
}
}
let added: Vec<String> = members.iter().filter(|m| !order.contains(m)).cloned().collect();
order.extend(added.iter().cloned());
Fitted { order, added, dropped }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OrderError {
/// Not every article of the section, each once.
NotTheSection,
/// The order the section already reads in.
Unchanged,
}
/// An order proposed now: every article of the section, each once, in another order.
pub fn check(proposed: &[String], members: &[String]) -> Result<(), OrderError> {
let mut seen: Vec<&String> = Vec::with_capacity(proposed.len());
for id in proposed {
if seen.contains(&id) || !members.contains(id) {
return Err(OrderError::NotTheSection);
}
seen.push(id);
}
if proposed.len() != members.len() {
return Err(OrderError::NotTheSection);
}
if proposed == members {
return Err(OrderError::Unchanged);
}
Ok(())
}
/// The order as a text: one article per line. It is the body of an order
/// proposal, and what the engine compares to tell an order from another.
pub fn canonical(order: &[String]) -> String {
order.join("\n")
}
/// The order a proposal's body holds; `None` when it holds none.
pub fn decode(body: &str) -> Option<Vec<String>> {
let ids: Vec<String> = body.lines().map(str::trim).map(str::to_owned).collect();
let readable = !ids.is_empty() && ids.iter().all(|id| !id.is_empty() && !id.contains(char::is_whitespace));
readable.then_some(ids)
}
/// The places of the section, given out again in the new order: the section
/// keeps the positions it holds (`current`, any order), the first of them goes
/// to the first article of `order`, and so on. Articles of `order` that hold no
/// place, and places beyond `order`, are left alone.
pub fn positions(order: &[String], current: &[(String, f64)]) -> Vec<(String, f64)> {
let ranked: Vec<&String> = order.iter().filter(|id| current.iter().any(|(c, _)| c == *id)).collect();
let mut places: Vec<f64> = current.iter().filter(|(c, _)| ranked.contains(&c)).map(|(_, p)| *p).collect();
places.sort_by(f64::total_cmp);
ranked.into_iter().zip(places).map(|(id, p)| (id.clone(), p)).collect()
}
/// Where a new article goes: at the end of its section — after the last article
/// of the section (seen or not: a draft keeps its place), before the first one
/// that follows it in the document. Without a section, or in a section not seen
/// yet, after everything.
pub fn place_at_end(articles: &[(Option<&str>, f64)], section: Option<&str>) -> f64 {
let last_of_doc = articles.iter().map(|(_, p)| *p).fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))));
let after_all = last_of_doc.map_or(1.0, |m| m + 1.0);
let Some(section) = section else { return after_all };
let Some(last) = articles
.iter()
.filter(|(s, _)| *s == Some(section))
.map(|(_, p)| *p)
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.max(p))))
else {
return after_all;
};
let next = articles
.iter()
.map(|(_, p)| *p)
.filter(|p| *p > last)
.fold(None, |m: Option<f64>, p| Some(m.map_or(p, |m| m.min(p))));
next.map_or(last + 1.0, |next| (last + next) / 2.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn art<'a>(id: &'a str, section: &'a str, position: f64, visible: bool) -> Article<'a> {
Article { id, section: Some(section), position, visible }
}
#[test]
fn the_order_holds_the_articles_one_can_see_in_the_order_they_read() {
let doc = [
art("I1", "introduction", 1.0, true),
art("T1", "fondamental", 3.0, true),
art("E2", "fondamental", 5.0, true),
art("E1", "fondamental", 4.0, true),
// A draft, or an article with nothing left open: out.
art("N1", "fondamental", 5.5, false),
];
assert_eq!(members(&doc, "fondamental"), ids(&["T1", "E1", "E2"]));
assert_eq!(members(&doc, "introduction"), ids(&["I1"]));
assert!(members(&doc, "annexe").is_empty());
// The same place: the identifier settles it.
let tie = [art("b", "s", 1.0, true), art("a", "s", 1.0, true)];
assert_eq!(members(&tie, "s"), ids(&["a", "b"]));
}
#[test]
fn an_order_of_fewer_than_two_articles_has_nothing_to_decide() {
assert!(is_moot(&[]));
assert!(is_moot(&ids(&["O1"])));
assert!(!is_moot(&ids(&["K1", "K2"])));
}
#[test]
fn an_order_is_proposed_with_every_article_of_the_section_each_once() {
let section = ids(&["T1", "E1", "E2"]);
assert_eq!(check(&ids(&["E2", "E1", "T1"]), &section), Ok(()));
assert_eq!(check(&ids(&["T1", "E1", "E2"]), &section), Err(OrderError::Unchanged));
for wrong in [&["E2", "E1"][..], &["E2", "E1", "T1", "T1"], &["E2", "E1", "X9"], &["E2", "E2", "T1"], &[]] {
assert_eq!(check(&ids(wrong), &section), Err(OrderError::NotTheSection), "{wrong:?}");
}
}
#[test]
fn a_proposal_still_fitting_the_section_is_read_as_it_was_proposed() {
let section = ids(&["T1", "E1", "E2"]);
let f = fit(&ids(&["E2", "T1", "E1"]), &section);
assert_eq!(f, Fitted { order: ids(&["E2", "T1", "E1"]), added: vec![], dropped: vec![] });
}
#[test]
fn an_article_that_enters_the_section_goes_at_the_end_of_a_proposal() {
// Proposed when the section held T1, E1, E2; N4 then N7 entered since.
let now = ids(&["T1", "N4", "E1", "E2", "N7"]);
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
assert_eq!(f.order, ids(&["E2", "E1", "T1", "N4", "N7"]), "the newcomers at the end, in the section's order");
assert_eq!(f.added, ids(&["N4", "N7"]));
assert!(f.dropped.is_empty());
}
#[test]
fn an_article_that_leaves_the_section_is_dropped_from_a_proposal() {
let now = ids(&["T1", "E2"]);
let f = fit(&ids(&["E2", "E1", "T1"]), &now);
assert_eq!(f.order, ids(&["E2", "T1"]));
assert_eq!(f.dropped, ids(&["E1"]));
assert!(f.added.is_empty());
// Both at once; a repeated or unknown line never doubles an article.
let f = fit(&ids(&["E2", "E2", "E1", "X1", "T1"]), &ids(&["T1", "E2", "N4"]));
assert_eq!((f.order, f.added, f.dropped), (ids(&["E2", "T1", "N4"]), ids(&["N4"]), ids(&["E1", "X1"])));
}
#[test]
fn a_proposal_that_comes_back_to_the_order_in_force_changes_nothing() {
// [E1, T1] proposed over [T1, E1]; E1 left: completed, it is the order in force.
let now = ids(&["T1"]);
assert_eq!(canonical(&fit(&ids(&["E1", "T1"]), &now).order), canonical(&now));
}
#[test]
fn the_body_of_an_order_proposal_is_one_article_per_line() {
let order = ids(&["a1f0", "77c2", "09bd"]);
assert_eq!(canonical(&order), "a1f0\n77c2\n09bd");
assert_eq!(decode(&canonical(&order)), Some(order));
assert_eq!(decode("a1\r\nb2"), Some(ids(&["a1", "b2"])));
for unreadable in ["", "a1\n\nb2", "a1 b2", "\n"] {
assert_eq!(decode(unreadable), None, "{unreadable:?}");
}
}
#[test]
fn entering_into_force_gives_the_places_of_the_section_out_again() {
let current = vec![("T1".to_owned(), 3.0), ("E1".to_owned(), 4.0), ("E2".to_owned(), 5.0)];
let mut moved = positions(&ids(&["E2", "T1", "E1"]), &current);
moved.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(moved, vec![("E1".to_owned(), 5.0), ("E2".to_owned(), 3.0), ("T1".to_owned(), 4.0)]);
// Uneven places stay the section's own: the neighbours never mingle with it.
let uneven = vec![("a".to_owned(), 6.0), ("b".to_owned(), 6.5), ("c".to_owned(), 11.0)];
assert_eq!(
positions(&ids(&["c", "a", "b"]), &uneven),
vec![("c".to_owned(), 6.0), ("a".to_owned(), 6.5), ("b".to_owned(), 11.0)]
);
// An article of the order without a place is left out; a place without an article is kept.
assert_eq!(positions(&ids(&["z", "b"]), &uneven), vec![("b".to_owned(), 6.5)]);
}
#[test]
fn an_order_is_voted_like_any_version_with_the_margin_of_the_one_in_force() {
use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions};
use crate::inertia::{InertiaLevel, Scale};
use crate::threshold::Tally;
// Monnaie libre before any median: W 5745, C 50; the order of a section at level 2.
let scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 };
let section = ids(&["T1", "E1", "E2"]);
let module = |tally: Tally| UnitState {
id: "ordre.fondamental".into(),
position: 0.0,
inertia: InertiaLevel::new(2).expect("level"),
in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(&section), tally }),
};
let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState {
id: id.into(),
unit_id: "ordre.fondamental".into(),
kind: ProposalKind::Text,
title: String::new(),
body: canonical(&fit(&ids(order), &section).order),
tally: Tally { for_votes, against: 0 },
submitted_at: "2026-10-31T00:00:00Z".into(),
blocked: false,
};
let seed = [module(Tally::default())];
// Never under C voices for; the order in force at zero votes is a seed a minima: margin −C.
assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 49)], &scale).expect("valid").is_empty());
assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 50)], &scale).expect("valid"), ["p"]);
// An order that is the one in force changes nothing, whatever its votes.
assert!(adoptions(&seed, &[proposal("same", &["T1", "E1", "E2"], 80)], &scale).expect("valid").is_empty());
// Adopted by a vote, the order in force holds its margin: a weaker order waits, a stronger one passes.
let voted = [module(Tally { for_votes: 70, against: 0 })];
assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty());
assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]);
}
#[test]
fn a_new_article_goes_at_the_end_of_its_section() {
let doc = [
(Some("introduction"), 1.0),
(Some("introduction"), 2.0),
(Some("fondamental"), 3.0),
(Some("fondamental"), 5.0),
(Some("technique"), 6.0),
];
// Between the last of its section and the first that follows it.
assert_eq!(place_at_end(&doc, Some("introduction")), 2.5);
assert_eq!(place_at_end(&doc, Some("fondamental")), 5.5);
// The last section of the document, an unknown section, or none: after everything.
assert_eq!(place_at_end(&doc, Some("technique")), 7.0);
assert_eq!(place_at_end(&doc, Some("annexe")), 7.0);
assert_eq!(place_at_end(&doc, None), 7.0);
assert_eq!(place_at_end(&[], Some("introduction")), 1.0);
// After the ones already placed at its end.
let again = [(Some("fondamental"), 5.0), (Some("fondamental"), 5.5), (Some("technique"), 6.0)];
assert_eq!(place_at_end(&again, Some("fondamental")), 5.75);
}
}
+30 -3
View File
@@ -1,9 +1,11 @@
//! What is left to vote in a document — the line of « Décisions ».
//!
//! A module is an article (a unit). It stays **to vote** as long as no version
//! A module is an article, the title of the document, or the order of a
//! section (ADR 0009) — a unit. It stays **to vote** as long as no version
//! of it was adopted by a vote: its version in force is a seed a minima not yet
//! confirmed, or it has no version in force and at least one open version.
//! Variants of an article count once. For a person who votes, a module is
//! Variants of an article count once. The order of a section of fewer than two
//! articles has nothing to decide: never to vote. For a person who votes, a module is
//! **without their vote** when they hold no valid vote (one not « to
//! reconfirm ») on any of its versions.
@@ -17,10 +19,16 @@ pub struct Module {
pub open: u32,
/// The person holds a valid vote on one of its versions, in force or open.
pub voted: bool,
/// Nothing to decide: the order of a section of fewer than two articles
/// (`order::is_moot`). Never to vote, and not shown.
pub moot: bool,
}
impl Module {
pub fn to_vote(&self) -> bool {
if self.moot {
return false;
}
match self.in_force {
Some(adopted) => !adopted,
None => self.open > 0,
@@ -51,7 +59,7 @@ mod tests {
const ADOPTED: Option<bool> = Some(true);
fn m(in_force: Option<bool>, open: u32, voted: bool) -> Module {
Module { in_force, open, voted }
Module { in_force, open, voted, moot: false }
}
#[test]
@@ -88,6 +96,25 @@ mod tests {
assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 });
}
#[test]
fn an_order_with_nothing_to_decide_is_never_to_vote() {
let one_article = Module { moot: true, ..m(SEED, 1, false) };
assert!(!one_article.to_vote());
assert_eq!(pending(&[one_article]), Pending::default());
}
#[test]
fn the_modules_of_the_certification_act() {
// 33 articles; the title of the document; the order of each of the 8 sections,
// all seeds a minima — but « ordonnancement » holds one article: 41 to vote.
let mut modules = vec![m(SEED, 0, false); 3];
modules.extend(vec![m(None, 1, false); 30]);
modules.push(m(SEED, 0, false));
modules.extend(vec![m(SEED, 0, false); 7]);
modules.push(Module { moot: true, ..m(SEED, 0, false) });
assert_eq!(pending(&modules), Pending { to_vote: 41, without_my_vote: 41 });
}
#[test]
fn nothing_to_vote_in_an_empty_document() {
assert_eq!(pending(&[]), Pending::default());