forked from yvv/decision
327 lines
14 KiB
Rust
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, ¶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<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(¶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<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, ¶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<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(())
|
|
}
|