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
+164 -39
View File
@@ -7,6 +7,7 @@ use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::ballots::{self, MyBallot, MyVote};
use crate::error::{AppError, AppResult};
use crate::modules::ModuleKind;
use crate::participation;
use crate::util::{self, LangMap};
use axum::Json;
@@ -146,9 +147,10 @@ pub async fn group(
let docs = if open {
sqlx::query_as::<_, DocListRow>(
"SELECT d.id, d.slug, d.title, d.summary,
(SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.in_force_id IS NOT NULL) AS in_force,
(SELECT COUNT(*) FROM units u
WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force,
(SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = d.id AND p.status = 'open') AS open
WHERE u.doc_id = d.id AND u.kind = 'article' AND p.status = 'open') AS open
FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at",
)
.bind(&slug)
@@ -250,7 +252,10 @@ pub fn inertia_of(unit: &UnitRow) -> InertiaLevel {
pub struct DocState {
pub doc: DocRow,
pub group: GroupRow,
/// Every unit, articles and modules, by position.
pub units: Vec<UnitRow>,
/// The units that are modules (ADR 0009), by unit id; any other is an article.
pub module_kinds: HashMap<String, ModuleKind>,
pub proposals: Vec<ProposalRow>,
pub tallies: HashMap<String, Tally>,
pub blocked: HashSet<String>,
@@ -296,6 +301,13 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let kinds: Vec<(String, String)> =
sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article'")
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let module_kinds: HashMap<String, ModuleKind> =
kinds.into_iter().filter_map(|(id, kind)| Some((id, ModuleKind::parse(&kind)?))).collect();
// Same second (the variants of a seed): the order they were written in, not their
// random identifiers. The engine ranks rivals on its own; this order is what people see.
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
@@ -370,6 +382,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
doc,
group,
units,
module_kinds,
proposals,
tallies,
blocked,
@@ -396,38 +409,126 @@ impl DocState {
self.tallies.get(id).copied().unwrap_or_default()
}
/// The module a unit is, if it is one.
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
self.module_kinds.get(unit_id).copied()
}
/// The articles, by position: every unit that is no module.
pub fn articles(&self) -> impl Iterator<Item = &UnitRow> {
self.units.iter().filter(|u| !self.module_kinds.contains_key(&u.id))
}
/// The modules of a kind; the orders in the order of the document's sections.
pub fn modules(&self, kind: ModuleKind) -> Vec<&UnitRow> {
let mut out: Vec<&UnitRow> = self.units.iter().filter(|u| self.module_of(&u.id) == Some(kind)).collect();
let sections = self.sections();
let rank = |u: &UnitRow| u.section.as_ref().and_then(|s| sections.iter().position(|x| x == s));
out.sort_by_key(|u| (rank(u).unwrap_or(usize::MAX), u.code.clone()));
out
}
/// The sections of the document, in the order its articles read in.
pub fn sections(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for u in self.articles() {
if let Some(s) = &u.section
&& !out.contains(s)
{
out.push(s.clone());
}
}
out
}
/// The articles in the order of `section` now: those one can see — a text in
/// force, or an open version — by position (`ld_engine::order::members`;
/// `order::sections_now` says the same in SQL).
pub fn members(&self, section: &str) -> Vec<String> {
let open: HashSet<&str> = self
.proposals
.iter()
.filter(|p| p.status == "open" && matches!(p.kind.as_str(), "text" | "remove"))
.map(|p| p.unit_id.as_str())
.collect();
let articles: Vec<ld_engine::order::Article<'_>> = self
.articles()
.map(|u| ld_engine::order::Article {
id: &u.id,
section: u.section.as_deref(),
position: u.position,
visible: u.in_force_id.is_some() || open.contains(u.id.as_str()),
})
.collect();
ld_engine::order::members(&articles, section)
}
/// The order each order module's section reads in now, by unit id.
fn live_orders(&self) -> HashMap<String, Vec<String>> {
self.modules(ModuleKind::Order)
.into_iter()
.map(|u| (u.id.clone(), self.members(u.section.as_deref().unwrap_or_default())))
.collect()
}
/// The units as the engine sees them. The version in force of an order is
/// the order its section reads in now: the engine compares orders as texts.
pub fn unit_states(&self) -> Vec<UnitState> {
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
let live = self.live_orders();
self.units
.iter()
.map(|u| UnitState {
id: u.id.clone(),
position: u.position,
inertia: inertia_of(u),
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| VersionText {
id: p.id.clone(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
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(),
title: String::new(),
body: ld_engine::order::canonical(order),
tally: self.tally(&p.id),
},
None => VersionText {
id: p.id.clone(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
},
}),
})
.collect()
}
/// Open proposals only — the in-force versions compete as incumbents.
/// Open proposals only — the in-force versions compete as incumbents. An
/// order proposal is read as it fits its section now (completed at the end);
/// one that holds no order is blocked.
pub fn proposal_states(&self) -> Vec<ProposalState> {
let live = self.live_orders();
self.proposals
.iter()
.filter(|p| p.status == "open")
.map(|p| ProposalState {
id: p.id.clone(),
unit_id: p.unit_id.clone(),
kind: p.kind(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
blocked: self.blocked.contains(&p.id),
.map(|p| {
let mut s = ProposalState {
id: p.id.clone(),
unit_id: p.unit_id.clone(),
kind: p.kind(),
title: p.title.clone(),
body: p.body.clone(),
tally: self.tally(&p.id),
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
blocked: self.blocked.contains(&p.id),
};
if let Some(members) = live.get(&p.unit_id).filter(|_| p.kind == ModuleKind::Order.as_str()) {
s.title = String::new();
match ld_engine::order::decode(&p.body) {
Some(order) => {
s.body = ld_engine::order::canonical(&ld_engine::order::fit(&order, members).order);
}
None => s.blocked = true,
}
}
s
})
.collect()
}
@@ -527,6 +628,8 @@ fn proposal_json(
let (mine, ballot) = mine;
let eval = st.evaluation(unit, p);
let (kind, target) = match p.kind() {
// A module's version says what it holds (ADR 0009).
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
ProposalKind::Inertia(t) => ("inertia", Some(t)),
@@ -572,28 +675,47 @@ pub async fn document(
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st
.units
.iter()
.map(|u| {
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| {
let of = (mine.get(&p.id), pending.get(&p.id));
proposal_json(&st, u, p, of, translations.get(&p.id), &user.id)
})
.collect();
let in_force = u
.in_force_id
.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!({
"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,
let unit_json = |u: &UnitRow| {
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| {
let of = (mine.get(&p.id), pending.get(&p.id));
let mut j = proposal_json(&st, u, p, of, translations.get(&p.id), &user.id);
if st.module_of(&u.id) == Some(ModuleKind::Order) {
j["order"] = crate::order::version_json(&st, u, p);
}
j
})
.collect();
let in_force = u
.in_force_id
.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!({
"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,
})
};
let units: Vec<_> = st.articles().map(unit_json).collect();
// The modules (ADR 0009): the title of the document, the order of each section.
let title = st.modules(ModuleKind::Title).first().map(|u| {
let mut j = unit_json(u);
j["kind"] = json!("title");
j
});
let orders: Vec<_> = st
.modules(ModuleKind::Order)
.into_iter()
.map(|u| {
let members = st.members(u.section.as_deref().unwrap_or_default());
let mut j = unit_json(u);
j["kind"] = json!("order");
j["moot"] = json!(ld_engine::order::is_moot(&members));
j["members"] = json!(members);
j
})
.collect();
@@ -612,6 +734,7 @@ pub async fn document(
"publication": publication,
},
"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()),
})))
@@ -687,7 +810,9 @@ pub async fn preview(
Some("wellrated") => PreviewMode::WellRated,
_ => PreviewMode::InForce,
};
let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode)
// The articles only: a module changes no text of the preview.
let articles: Vec<UnitState> = st.unit_states().into_iter().filter(|u| st.module_of(&u.id).is_none()).collect();
let units = compose::preview(&articles, &st.proposal_states(), &st.scale(), mode)
.map_err(|_| AppError::Invalid("electorate"))?;
// Translations of the versions shown.
let shown: HashSet<String> = units