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:
Yvv
2026-11-03 05:22:24 +01:00
co-authored by Claude Opus 5.5
parent ee37c3b4d5
commit 4b81a2e909
16 changed files with 1503 additions and 213 deletions
+103 -18
View File
@@ -13,6 +13,7 @@ use crate::util::{self, LangMap};
use axum::Json;
use axum::extract::{Path, Query, State};
use ld_engine::ballot::Choice;
use ld_engine::buffer::{self, Rules, Stage};
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
use ld_engine::inertia::{InertiaLevel, Participation, Scale};
use ld_engine::threshold::{Evaluation, Tally};
@@ -265,7 +266,21 @@ pub struct DocState {
/// The units that are modules (ADR 0009), by unit id; any other is an article.
pub module_kinds: HashMap<String, ModuleKind>,
pub proposals: Vec<ProposalRow>,
/// What adoption counts (ADR 0012): recorded votes, and simulated ones by
/// default — never an intention without proof.
pub tallies: HashMap<String, Tally>,
/// What is recorded and shown: recorded votes and simulated ones, without the
/// intentions (what a published text may say).
pub recorded: HashMap<String, Tally>,
/// The totals shown: `recorded`, each intention in place of the vote its
/// session sees as its own (`ld_engine::buffer::weigh_intent`).
pub shown: HashMap<String, Tally>,
/// The part of `shown` still waiting for its proof.
pub pending: HashMap<String, Tally>,
/// The part of `shown` that comes from the simulation.
pub simulated: HashMap<String, Tally>,
/// What each stage of a vote weighs.
pub rules: Rules,
/// Versions held back by a standing objection: against or a withdrawal only, never
/// a new *for* (`admits`), and never entering into force.
pub blocked: HashSet<String>,
@@ -333,30 +348,70 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
struct TallyRow {
proposal_id: String,
choice: String,
source: String,
n: i64,
}
let rows = sqlx::query_as::<_, TallyRow>(
"SELECT v.proposal_id, v.choice, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id
"SELECT v.proposal_id, v.choice, v.source, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice",
WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice, v.source",
)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut tallies: HashMap<String, Tally> = HashMap::new();
for r in rows {
let t = tallies.entry(r.proposal_id).or_default();
let n = u32::try_from(r.n).unwrap_or(0);
match r.choice.as_str() {
"for" => t.for_votes = n,
"against" => t.against = n,
let rules = state.vote_rules();
let add = |map: &mut HashMap<String, Tally>, id: &str, choice: &str, n: u32| {
let t = map.entry(id.to_owned()).or_default();
match choice {
"for" => t.for_votes = t.for_votes.saturating_add(n),
"against" => t.against = t.against.saturating_add(n),
_ => {}
}
};
let (mut tallies, mut recorded, mut simulated) = (HashMap::new(), HashMap::new(), HashMap::new());
for r in rows {
let n = u32::try_from(r.n).unwrap_or(0);
let stage = Stage::of_source(&r.source);
if rules.adopts(stage) {
add(&mut tallies, &r.proposal_id, &r.choice, n);
}
if rules.shown(stage) {
add(&mut recorded, &r.proposal_id, &r.choice, n);
if stage == Stage::Simulated {
add(&mut simulated, &r.proposal_id, &r.choice, n);
}
}
}
// A standing objection holds back the latest arrival of its pair (never a version
// in force): no new *for*, and it cannot enter into force (ADR 0010).
let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
// The intentions (ADR 0012): seen at once in the totals shown, in place of the
// vote their session sees as its own; never counted for adoption.
let mut shown = recorded.clone();
let mut pending: HashMap<String, Tally> = HashMap::new();
let mut seen_by: HashMap<String, HashMap<String, MyVote>> = HashMap::new();
for i in ballots::intents(state, doc_id).await? {
let Some(p) = proposals.iter().find(|p| p.id == i.proposal_id) else { continue };
if !matches!(p.status.as_str(), "open" | "in_force") {
continue;
}
if !seen_by.contains_key(&i.requester) {
seen_by.insert(i.requester.clone(), ballots::my_votes(state, &i.requester, doc_id).await?);
}
let seen = seen_by
.get(&i.requester)
.and_then(|m| m.get(&p.id))
.filter(|v| v.revision == p.revision)
.and_then(|v| Choice::parse(&v.choice));
let w = buffer::weigh_intent(seen, i.choice, i.revision == p.revision, blocked.contains(&p.id));
let t = shown.entry(p.id.clone()).or_default();
*t = w.shift.apply(*t);
let q = pending.entry(p.id.clone()).or_default();
q.for_votes = q.for_votes.saturating_add(w.pending.for_votes);
q.against = q.against.saturating_add(w.pending.against);
}
let (electorate, electorate_source) = electorate(state, &group).await?;
let measure = participation::stored(state, &group.slug).await?;
let participation = Participation::of(
@@ -373,6 +428,11 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
module_kinds,
proposals,
tallies,
recorded,
shown,
pending,
simulated,
rules,
blocked,
signal_counts,
electorate,
@@ -397,13 +457,34 @@ impl DocState {
self.tallies.get(id).copied().unwrap_or_default()
}
/// The totals shown to people: today the live count, as `tally`. The one
/// place where a publication by steps would plug in — on the chain, who
/// voted and when is public, and live totals tell what (the secret of the
/// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads
/// `tally`; the published texts (`publish.rs`) would follow this one.
/// The totals shown to people: the live count, intentions included (ADR
/// 0012: « je vois ce que j'ai fait sans attendre »). The one place where a
/// publication by steps would plug in — on the chain, who voted and when is
/// public, and live totals tell what (the secret of the vote, review of
/// 74f96be). Adoption reads `tally`, never an intention.
pub fn shown_tally(&self, id: &str) -> Tally {
self.tally(id)
self.shown.get(id).copied().unwrap_or_default()
}
/// The part of `shown_tally` still waiting for its proof.
pub fn pending_tally(&self, id: &str) -> Tally {
self.pending.get(id).copied().unwrap_or_default()
}
/// The part of `shown_tally` that comes from the simulation.
pub fn simulated_tally(&self, id: &str) -> Tally {
self.simulated.get(id).copied().unwrap_or_default()
}
/// What is recorded and shown, without the intentions: what a published text says.
pub fn recorded_tally(&self, id: &str) -> Tally {
self.recorded.get(id).copied().unwrap_or_default()
}
/// The simulated share of what adoption counts for a version (zero when the
/// simulation counts for display only).
pub fn simulated_counted(&self, id: &str) -> Tally {
if self.rules.adopts(Stage::Simulated) { self.simulated_tally(id) } else { Tally::default() }
}
/// How far the threshold is, as shown: on `shown_tally`.
@@ -641,6 +722,7 @@ fn proposal_json(
me: &str,
) -> serde_json::Value {
let (mine, ballot) = mine;
let seen = ballots::seen(p.revision, mine, ballot);
let eval = st.shown_evaluation(unit, p);
let (kind, target) = match p.kind() {
// A module's version says what it holds (ADR 0009).
@@ -661,14 +743,17 @@ fn proposal_json(
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.shown_tally(&p.id),
"tallyPending": st.pending_tally(&p.id),
"tallySimulated": st.simulated_tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"votable": st.votable(p),
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
"myVote": seen.choice,
"myVoteState": seen.state,
"myVoteStale": seen.stale,
"myVoteChain": seen.chain,
"myBallot": ballot,
"translations": translations.cloned().unwrap_or_default(),
})