Files
decision/crates/ld-server/src/simulation.rs
T

327 lines
14 KiB
Rust

//! 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<String>,
}
/// 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<Reexamined>,
}
#[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<Discarded> {
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, &params, 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<Vec<(AdoptionRow, Value, Tally)>> {
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(&params["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<Vec<Reexamined>> {
let mut out = Vec::new();
for (a, params, simulated) in to_examine(state, Some(doc_id)).await? {
if let Some(r) = reexamine(state, &a, &params, 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<Option<Reexamined>> {
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::<InertiaLevel>(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::<InertiaLevel>(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(())
}