forked from yvv/decision
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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"]), §ion), Ok(()));
|
||||
assert_eq!(check(&ids(&["T1", "E1", "E2"]), §ion), Err(OrderError::Unchanged));
|
||||
for wrong in [&["E2", "E1"][..], &["E2", "E1", "T1", "T1"], &["E2", "E1", "X9"], &["E2", "E2", "T1"], &[]] {
|
||||
assert_eq!(check(&ids(wrong), §ion), 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"]), §ion);
|
||||
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"]), ¤t);
|
||||
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(§ion), 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), §ion).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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user