forked from yvv/decision
WIP lot V : le tampon câblé dans le serveur (migration 0010, simulation, discard-simulation), tests en cours de reprise — repris tel quel après la coupure du 02/11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
//! 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.
|
||||
|
||||
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.
|
||||
let adoptions = sqlx::query_as::<_, AdoptionRow>(
|
||||
"SELECT id, proposal_id, unit_id, doc_id, kind, votes_for, votes_against, params, at FROM adoptions
|
||||
ORDER BY at DESC, id DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for a in adoptions {
|
||||
let params: Value = serde_json::from_str(&a.params).unwrap_or(Value::Null);
|
||||
let simulated = tally_of(¶ms["simulated"]);
|
||||
if simulated.cast() == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = reexamine(state, &a, ¶ms, simulated, apply).await? {
|
||||
out.reexamined.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)) };
|
||||
let level = serde_json::from_value::<InertiaLevel>(params["level"].clone()).unwrap_or_else(|_| st.voting_level(unit, &p));
|
||||
let eval = st.scale().evaluate(proven, level).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;
|
||||
}
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user