forked from yvv/decision
- 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>
246 lines
10 KiB
Rust
246 lines
10 KiB
Rust
//! Entering into force — automatic, as soon as a proposal reaches its
|
|
//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle
|
|
//! remonte en vigueur »). The human gesture is the vote itself; the engine
|
|
//! only counts. Every entry is recorded with its numbers — the level of
|
|
//! inertia, M, G, s_k and the usual participation it was judged by — and
|
|
//! queued for publication to the texts repository.
|
|
|
|
use crate::AppState;
|
|
use crate::docs::{self, DocRow, DocState};
|
|
use crate::error::AppResult;
|
|
use crate::util;
|
|
use ld_engine::compose::{self, ProposalKind, UnitState};
|
|
use serde::Serialize;
|
|
use std::time::Duration;
|
|
use tokio::sync::Mutex;
|
|
|
|
/// One adoption pass at a time per process: SQLite has a single writer, and
|
|
/// two concurrent votes must not both promote rivals of the same unit.
|
|
static PASS: Mutex<()> = Mutex::const_new(());
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct Entered {
|
|
pub proposal_id: String,
|
|
pub unit_id: String,
|
|
pub code: String,
|
|
pub kind: &'static str,
|
|
}
|
|
|
|
pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> {
|
|
// A text in force changed: the document's title follows its title in force (ADR 0009).
|
|
crate::doc_title::sync(state, &doc.id).await?;
|
|
if doc.publish_path.is_none() {
|
|
return Ok(());
|
|
}
|
|
let now = util::now();
|
|
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
|
.bind(&doc.id)
|
|
.bind(reason)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.execute(&state.db)
|
|
.await?;
|
|
state.publish_wake.notify_one();
|
|
Ok(())
|
|
}
|
|
|
|
/// Seeds a minima that later reach their own threshold are marked confirmed.
|
|
async fn confirm_seeds(state: &AppState, st: &DocState) -> AppResult<()> {
|
|
for unit in &st.units {
|
|
let Some(fid) = &unit.in_force_id else { continue };
|
|
let Some(p) = st.proposals.iter().find(|p| &p.id == fid) else { continue };
|
|
if p.entered_at.is_some() || p.confirmed_at.is_some() {
|
|
continue;
|
|
}
|
|
if st.evaluation(unit, p).is_some_and(|e| e.reached) {
|
|
sqlx::query("UPDATE proposals SET confirmed_at = ? WHERE id = ? AND confirmed_at IS NULL")
|
|
.bind(util::now())
|
|
.bind(&p.id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
|
let _pass = PASS.lock().await;
|
|
let st = docs::load_state(state, doc_id).await?;
|
|
confirm_seeds(state, &st).await?;
|
|
let units = st.unit_states();
|
|
let scale = st.scale();
|
|
let winners = compose::adoptions(&units, &st.proposal_states(), &scale)
|
|
.map_err(|_| crate::error::AppError::Invalid("electorate"))?;
|
|
let mut entered = Vec::new();
|
|
for id in winners {
|
|
let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue };
|
|
let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue };
|
|
let tally = st.tally(&p.id);
|
|
let eval = st.evaluation(unit, p);
|
|
let params = kept_params(&st, unit, p);
|
|
let now = util::now();
|
|
let mut tx = state.db.begin().await?;
|
|
// The text that enters is the text that was counted: a revision that landed
|
|
// since the state was read stops it (the next pass will judge it again).
|
|
let unchanged = sqlx::query(
|
|
"UPDATE proposals SET updated_at = updated_at
|
|
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status = ?",
|
|
)
|
|
.bind(&p.id)
|
|
.bind(p.revision)
|
|
.bind(&p.title)
|
|
.bind(&p.body)
|
|
.bind(&p.status)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
if unchanged.rows_affected() != 1 {
|
|
continue;
|
|
}
|
|
let kind = match p.kind() {
|
|
ProposalKind::Text | ProposalKind::Remove => {
|
|
// The displaced version goes back among the proposals, with its votes.
|
|
if let Some(old) = &unit.in_force_id {
|
|
sqlx::query("UPDATE proposals SET status = 'open', left_at = ?, updated_at = ? WHERE id = ?")
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(old)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
if p.kind() == ProposalKind::Text {
|
|
sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ?, left_at = NULL, updated_at = ? WHERE id = ?")
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(&p.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
|
.bind(&p.id)
|
|
.bind(&unit.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
// A module does more: an order moves its articles, a title renames the document.
|
|
crate::modules::on_entry(&mut tx, &st, unit, p).await?
|
|
} else {
|
|
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(&p.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ?")
|
|
.bind(&unit.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
"remove"
|
|
}
|
|
}
|
|
ProposalKind::Inertia(target) => {
|
|
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(&p.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
|
|
.bind(target.as_db())
|
|
.bind(&unit.id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
sqlx::query(
|
|
"INSERT INTO inertia_changes (unit_id, doc_id, from_level, to_level, cause, proposal_id, at)
|
|
VALUES (?, ?, ?, ?, 'vote', ?, ?)",
|
|
)
|
|
.bind(&unit.id)
|
|
.bind(&st.doc.id)
|
|
.bind(docs::inertia_of(unit).as_db())
|
|
.bind(target.as_db())
|
|
.bind(&p.id)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
"inertia"
|
|
}
|
|
};
|
|
sqlx::query(
|
|
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&p.id)
|
|
.bind(&unit.id)
|
|
.bind(&st.doc.id)
|
|
.bind(kind)
|
|
.bind(i64::from(tally.for_votes))
|
|
.bind(i64::from(tally.against))
|
|
.bind(i64::from(st.electorate))
|
|
.bind(eval.map_or(0, |e| i64::from(e.required)))
|
|
.bind(params.to_string())
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
tx.commit().await?;
|
|
tracing::info!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, kind, "entered into force");
|
|
entered.push(Entered { proposal_id: p.id.clone(), unit_id: unit.id.clone(), code: unit.code.clone(), kind });
|
|
}
|
|
if !entered.is_empty() {
|
|
let codes: Vec<&str> = entered.iter().map(|e| e.code.as_str()).collect();
|
|
enqueue_publication(state, &st.doc, &format!("entrée en vigueur : {}", codes.join(", "))).await?;
|
|
}
|
|
Ok(entered)
|
|
}
|
|
|
|
/// What an adoption keeps of the formula it was judged by. The letters stay where
|
|
/// the rows of before have them (`majority_pct`, `gradient`, `base`; a row before
|
|
/// 2026-11-03 holds `constant`, the C of before, read as it was written); the quorum
|
|
/// (ADR 0013), the level and the scale join them — and, for the act that ends the
|
|
/// simulation (ADR 0012, `simulation::discard`), the simulated share of the votes it
|
|
/// entered with, the version it displaced and the level of inertia of before.
|
|
fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> serde_json::Value {
|
|
let level = st.voting_level(unit, p);
|
|
let f = st.params(unit, p);
|
|
let simulated = st.simulated_counted(&p.id);
|
|
serde_json::json!({
|
|
"majority_pct": f.majority_pct, "gradient": f.gradient, "base": f.base, "quorum": f.quorum,
|
|
"level": level, "share_pct": level.share_pct(),
|
|
"participation": st.participation.ratio, "participation_source": st.participation.source,
|
|
"participation_sample": st.participation.sample, "participation_median": st.participation.median,
|
|
"simulated": simulated, "displaced": unit.in_force_id, "from_inertia": docs::inertia_of(unit),
|
|
})
|
|
}
|
|
|
|
/// The electorate changes (the web of trust grows or shrinks): every few
|
|
/// minutes, every document is passed through the engine again — after the
|
|
/// usual participation of each group is measured again, once a day, and after
|
|
/// the deadlines of three days are passed (`deadlines`).
|
|
pub async fn sweeper(state: AppState) {
|
|
let mut tick = tokio::time::interval(Duration::from_secs(300));
|
|
loop {
|
|
tick.tick().await;
|
|
if let Err(e) = crate::participation::refresh(&state, false).await {
|
|
tracing::warn!(error = %e, "usual participation");
|
|
}
|
|
// Objections lifted and typo fixes applied at their deadline (three days).
|
|
if let Err(e) = crate::deadlines::sweep(&state).await {
|
|
tracing::warn!(error = %e, "deadlines");
|
|
}
|
|
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
|
|
Ok(ids) => ids,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "sweeper");
|
|
continue;
|
|
}
|
|
};
|
|
for id in ids {
|
|
if let Err(e) = run(&state, &id).await {
|
|
tracing::warn!(doc = %id, error = %e, "sweeper pass");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Unit states, re-exported for tests.
|
|
pub fn unit_states(st: &DocState) -> Vec<UnitState> {
|
|
st.unit_states()
|
|
}
|