//! The end of the simulation (ADR 0012). //! //! Yvv, 2026-11-02: « Il compte, puis s'efface ». A simulated vote counts like a //! proven one, marked as simulated, until an act « supprime » of Yvv sets every //! one of them aside; what they brought into force is then examined again, with //! the proven votes only. Opening the chain does none of this by itself: the act //! calls `discard` — `ld-server discard-simulation` tells what it would do, //! `ld-server discard-simulation --apply` does it. Never run on its own. //! //! - **The sort**: the simulated ballots, their lines, their votes and the //! messages about them are deleted; each identity that voted in simulation is //! reconciled again, so that a proven vote of its own, if any, stands. //! - **The re-examination**: every entry into force that counted simulated votes //! (`adoptions.params.simulated`), the most recent first, still in effect, is //! judged again on the proven votes alone, at the level it was voted at. Below //! its threshold, it **returns among the proposals** and the version it had //! displaced comes back into force (a removed text comes back, a level of //! inertia goes back, the order and the title of before apply again); its //! adoption is void. At or above, it stays, and its adoption says so //! (`params.reexamined`): the act run again examines it no more. //! //! Told only (no `--apply`), the re-examination sets the simulated votes apart //! without removing them: an entry that would return only once a more recent one //! returned before it is told as no longer in effect. use crate::AppState; use crate::adoption; use crate::ballots; use crate::docs::{self, DocState, ProposalRow, UnitRow}; use crate::error::AppResult; use crate::modules; use crate::proposals; use crate::util; use ld_engine::inertia::InertiaLevel; use ld_engine::threshold::Tally; use serde::Serialize; use serde_json::Value; /// One entry into force that counted simulated votes, judged again. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Reexamined { pub doc_id: String, pub proposal_id: String, pub unit_id: String, pub code: String, /// `text`, `remove`, `inertia`, `order` or `title`. pub kind: String, pub at: String, /// The votes it entered with, and the simulated share of them. pub entered_with: Tally, pub simulated: Tally, /// The proven votes it holds now, and the *for* votes its level asks at them. pub proven: Tally, pub required: u32, /// Still in force (or still applied): something else may have taken its place since. pub in_effect: bool, /// It holds on the proven votes alone: it stays. pub holds: bool, /// Returned among the proposals (`--apply` only). pub returned: bool, /// The version it had displaced, back in force when it returns. pub displaced: Option, } /// What the act does, or would do. #[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct Discarded { /// Done (`--apply`), or only told. pub applied: bool, pub ballots: i64, pub lines: i64, pub votes: i64, pub messages: i64, pub reexamined: Vec, } #[derive(sqlx::FromRow)] struct AdoptionRow { id: i64, proposal_id: String, unit_id: String, doc_id: String, kind: String, votes_for: i64, votes_against: i64, params: String, at: String, } fn tally_of(v: &Value) -> Tally { serde_json::from_value(v.clone()).unwrap_or_default() } fn minus(a: Tally, b: Tally) -> Tally { Tally { for_votes: a.for_votes.saturating_sub(b.for_votes), against: a.against.saturating_sub(b.against) } } /// Sets every simulated vote aside and examines again what they brought into /// force — or, `apply` false, tells what it would do and changes nothing. pub async fn discard(state: &AppState, apply: bool) -> AppResult { const SIMULATED: &str = "SELECT id FROM ballots WHERE mode = 'simulated'"; let (ballots_n, lines, votes, messages): (i64, i64, i64, i64) = sqlx::query_as(&format!( "SELECT (SELECT COUNT(*) FROM ballots WHERE mode = 'simulated'), (SELECT COUNT(*) FROM ballot_lines WHERE ballot_id IN ({SIMULATED})), (SELECT COUNT(*) FROM votes WHERE source = 'simulated'), (SELECT COUNT(*) FROM messages WHERE topic = 'ballot' AND subject_id IN ({SIMULATED}))" )) .fetch_one(&state.db) .await?; let mut out = Discarded { applied: apply, ballots: ballots_n, lines, votes, messages, reexamined: Vec::new() }; if apply { // The sort: who voted in simulation, on what — reconciled again once their simulated lines are gone. let pairs: Vec<(String, String)> = sqlx::query_as( "SELECT DISTINCT b.voter_id, l.proposal_id FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id WHERE b.mode = 'simulated' AND b.voter_id IS NOT NULL", ) .fetch_all(&state.db) .await?; let now = util::now(); let mut tx = state.db.begin().await?; for sql in [ format!("DELETE FROM messages WHERE topic = 'ballot' AND subject_id IN ({SIMULATED})"), "DELETE FROM votes WHERE source = 'simulated'".to_owned(), format!("DELETE FROM ballot_lines WHERE ballot_id IN ({SIMULATED})"), "DELETE FROM ballots WHERE mode = 'simulated'".to_owned(), ] { sqlx::query(&sql).execute(&mut *tx).await?; } for (voter, proposal_id) in &pairs { ballots::reconcile(&mut tx, voter, proposal_id, &now).await?; } tx.commit().await?; tracing::warn!(ballots = ballots_n, votes, "simulated votes set aside (act « supprime »)"); } // The re-examination, the most recent entry first. for (a, params, simulated) in to_examine(state, None).await? { if let Some(r) = reexamine(state, &a, ¶ms, simulated, apply).await? { out.reexamined.push(r); } } if apply { // What waited for the proven votes may go out now (`publish::waits_for_proven_votes`). sqlx::query("UPDATE outbox SET next_at = ? WHERE done_at IS NULL").bind(util::now()).execute(&state.db).await?; state.publish_wake.notify_one(); } Ok(out) } /// The entries into force still to examine, the most recent first: those that /// counted simulated votes, not yet examined by an act — of one document, or all. async fn to_examine(state: &AppState, doc_id: Option<&str>) -> AppResult> { let adoptions = sqlx::query_as::<_, AdoptionRow>( "SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions WHERE ?1 IS NULL OR doc_id = ?1 ORDER BY at DESC, id DESC", ) .bind(doc_id) .fetch_all(&state.db) .await?; Ok(adoptions .into_iter() .filter_map(|a| { let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null); let simulated = tally_of(¶ms["simulated"]); // Without simulated votes, or examined by an act already: nothing to examine. (simulated.cast() > 0 && params["reexamined"].is_null()).then_some((a, params, simulated)) }) .collect()) } /// What, in effect in a document, entered by simulated votes and does not hold on /// the proven votes alone: the re-examination of the act, told only — nothing is /// written. Empty: everything in effect holds on the proven votes. The publication /// waits on it (Yvv, 2026-11-03: « Pas avant des votes prouvés », /// `publish::waits_for_proven_votes`). pub async fn unproven(state: &AppState, doc_id: &str) -> AppResult> { let mut out = Vec::new(); for (a, params, simulated) in to_examine(state, Some(doc_id)).await? { if let Some(r) = reexamine(state, &a, ¶ms, simulated, false).await? && r.in_effect && !r.holds { out.push(r); } } Ok(out) } async fn reexamine( state: &AppState, a: &AdoptionRow, params: &Value, simulated: Tally, apply: bool, ) -> AppResult> { let st = docs::load_state(state, &a.doc_id).await?; let Some(unit) = st.units.iter().find(|u| u.id == a.unit_id) else { return Ok(None) }; let Ok(p) = proposals::load_proposal(state, &a.proposal_id).await else { return Ok(None) }; let in_effect = match a.kind.as_str() { "remove" => p.status == "applied" && unit.in_force_id.is_none(), "inertia" => { p.status == "applied" && p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(docs::inertia_of(unit)) } _ => unit.in_force_id.as_deref() == Some(p.id.as_str()), }; // Applied: the simulated votes are gone. Told only: they still count, set them apart. let proven = if apply { st.tally(&p.id) } else { minus(st.tally(&p.id), st.simulated_counted(&p.id)) }; // At the level and on the quorum it was voted at (ADR 0013; an adoption of before // holds the C of before, `constant`, which is no quorum: the article's quorum now). let level = serde_json::from_value::(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p)); let quorum = params["quorum"].as_u64().and_then(|q| u32::try_from(q).ok()).unwrap_or_else(|| st.quorum_of(unit)); let eval = st.scale().evaluate(proven, level, quorum).ok(); let holds = eval.is_some_and(|e| e.reached); let displaced = params["displaced"].as_str().map(str::to_owned); let mut r = Reexamined { doc_id: a.doc_id.clone(), proposal_id: p.id.clone(), unit_id: unit.id.clone(), code: unit.code.clone(), kind: a.kind.clone(), at: a.at.clone(), entered_with: Tally { for_votes: u32::try_from(a.votes_for).unwrap_or(0), against: u32::try_from(a.votes_against).unwrap_or(0), }, simulated, proven, required: eval.map_or(0, |e| e.required), in_effect, holds, returned: false, displaced: displaced.clone(), }; if apply && in_effect && !holds { give_back(state, &st, unit, &p, a, params, displaced.as_deref()).await?; r.returned = true; } else if apply { // It stays (or something else took its place since): examined once, never again. let mut kept = params.clone(); kept["reexamined"] = serde_json::json!({ "at": util::now(), "proven": r.proven, "required": r.required }); sqlx::query("UPDATE adoptions SET params = ? WHERE id = ?") .bind(kept.to_string()) .bind(a.id) .execute(&state.db) .await?; } Ok(Some(r)) } /// An entry that does not hold on the proven votes: the version returns among the /// proposals, the one it displaced comes back, and its adoption is void. async fn give_back( state: &AppState, st: &DocState, unit: &UnitRow, p: &ProposalRow, a: &AdoptionRow, params: &Value, displaced: Option<&str>, ) -> AppResult<()> { let now = util::now(); let back = match displaced { Some(d) => proposals::load_proposal(state, d).await.ok().filter(|v| v.status == "open"), None => None, }; let mut tx = state.db.begin().await?; sqlx::query("UPDATE proposals SET status = 'open', entered_at = NULL, left_at = ?, updated_at = ? WHERE id = ?") .bind(&now) .bind(&now) .bind(&p.id) .execute(&mut *tx) .await?; if a.kind == "inertia" { let from = serde_json::from_value::(params["from_inertia"].clone()).unwrap_or_default(); sqlx::query("UPDATE units SET inertia = ? WHERE id = ?") .bind(from.as_db()) .bind(&unit.id) .execute(&mut *tx) .await?; sqlx::query("DELETE FROM inertia_changes WHERE proposal_id = ? AND cause = 'vote'") .bind(&p.id) .execute(&mut *tx) .await?; } else { if let Some(b) = &back { sqlx::query("UPDATE proposals SET status = 'in_force', left_at = NULL, updated_at = ? WHERE id = ?") .bind(&now) .bind(&b.id) .execute(&mut *tx) .await?; } sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") .bind(back.as_ref().map(|b| &b.id)) .bind(&unit.id) .execute(&mut *tx) .await?; // A module does more: the order of before moves its articles back, the title of before names the document. if let (Some(b), Some(_)) = (&back, st.module_of(&unit.id)) { let leaving = UnitRow { in_force_id: Some(p.id.clone()), ..unit.clone() }; modules::on_entry(&mut tx, st, &leaving, b).await?; } } sqlx::query("DELETE FROM adoptions WHERE id = ?").bind(a.id).execute(&mut *tx).await?; tx.commit().await?; tracing::warn!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, "returned among the proposals: it does not hold on the proven votes"); adoption::enqueue_publication(state, &st.doc, &format!("votes simulés écartés : {} réexaminé", unit.code)).await?; Ok(()) } /// `ld-server discard-simulation [--apply]`: the act « supprime » of the end of /// the simulation (ADR 0012). Without `--apply`, it tells what it would do. pub async fn run_cli(apply: bool) -> anyhow::Result<()> { let cfg = crate::Config::from_env(); let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display()); let db = crate::open_db(&db_url).await?; let state = AppState::new(cfg, db).await?; let done = discard(&state, apply).await.map_err(|e| anyhow::anyhow!("{e}"))?; println!("{}", serde_json::to_string_pretty(&done)?); Ok(()) }