//! The buffer between the votes and the chain (ADR 0012). //! //! Yvv, 2026-11-02: « il faut prévoir le "tampon" entre la base des votes et la //! mise à jour par les indexeurs de la chaîne. Il faut prévoir le bon process //! pour une ux moderne "je vois ce que j'ai fait sans attendre", avec un //! contrôle a posteriori de l'intégrité du vote (bien unique, bien le dernier //! qui vaut). » //! //! A vote goes through three times: //! - **the intention**, the gesture itself (a sealed ballot, `ballot`): seen at //! once by the person and in the totals shown, never counted for adoption; //! - **the proof**, the chain read by the indexer (or, while the chain is //! closed, the simulated confirmer); //! - **the reconciliation**, after each proof: one voice per identity and per //! version, the last in the order of the chain, whatever the order the proofs //! were read in (`reconcile`). What the chain never confirms in time, refuses, //! or what a more recent vote of the same identity overtook, is set aside — and //! a *for* proven while an objection holds its version back. //! //! What each stage of a vote weighs, in the totals shown and in adoption, is //! `Rules`. Pure: the facts come from the caller. use crate::ballot::{ChainPosition, Choice}; use crate::objection; use crate::threshold::Tally; use serde::Serialize; /// Whether a simulated vote counts for adoption. Yvv, 2026-11-02: « Il compte, /// puis s'efface » — like a proven vote, marked as simulated, until an act /// « supprime » sets every simulated vote aside and examines again, with the /// proven votes only, what they brought into force (`ld_server::simulation`). /// `false` would turn it to « shown, never counted for adoption »; the totals /// shown would not change. pub const SIMULATED_ADOPTS: bool = true; /// Where a vote stands. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Stage { /// Proven by the chain, or written by the server in a managed group. Recorded, /// An intention still waiting for its proof, within its window. Pending, /// A vote of the simulation (cast while the chain was closed), until the act /// that sets them aside. Simulated, } impl Stage { pub fn as_str(self) -> &'static str { match self { Self::Recorded => "recorded", Self::Pending => "pending", Self::Simulated => "simulated", } } /// The stage of a vote that counts (`votes.source`): `simulated`, or recorded /// (`server`, `chain`). An intention is never in `votes`. pub fn of_source(source: &str) -> Self { if source == "simulated" { Self::Simulated } else { Self::Recorded } } } /// What the stages weigh. Opening the chain changes nothing by itself: the /// simulated votes weigh until the act that sets them aside (Yvv, 2026-11-02). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Rules { /// See `SIMULATED_ADOPTS`. pub simulated_adopts: bool, } impl Default for Rules { fn default() -> Self { Self { simulated_adopts: SIMULATED_ADOPTS } } } impl Rules { /// Does a vote at this stage weigh in the totals shown? Every stage: the /// intention at once, the simulated vote with its mark. pub fn shown(&self, stage: Stage) -> bool { match stage { Stage::Recorded | Stage::Pending | Stage::Simulated => true, } } /// Does a vote at this stage weigh in adoption (and in the usual participation)? /// Never an intention without proof. pub fn adopts(&self, stage: Stage) -> bool { match stage { Stage::Recorded => true, Stage::Pending => false, Stage::Simulated => self.simulated_adopts, } } } /// A proven line of one identity on one version: where its proof sits on the /// chain, its choice, and whether an objection held the version back when the /// proof arrived. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Proven { pub position: ChainPosition, pub choice: Choice, pub held: bool, } /// What became of a proven line. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Outcome { /// The voice of the identity on this version (a withdrawal too: then no voice). Counted, /// A more recent proven line of the same identity decided. Superseded, /// A *for* proven while an objection held the version back: never counts. Objection, } impl Outcome { pub fn as_str(self) -> &'static str { match self { Self::Counted => "counted", Self::Superseded => "superseded", Self::Objection => "objection", } } pub fn parse(raw: &str) -> Option { [Self::Counted, Self::Superseded, Self::Objection].into_iter().find(|o| o.as_str() == raw) } } /// A *for* whose proof arrives while an objection holds its version back is set /// aside (ADR 0012, revising 0010): against and a withdrawal still count (Yvv, /// 2026-11-01: « Retirer ou "contre" »). pub fn discarded_by_objection(choice: Choice, held: bool) -> bool { !objection::may_cast(held, choice) } /// The reconciliation of one identity on one version. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Reconciled { /// One per line, in the order given. pub outcomes: Vec, /// The line that stands, if any: `None` when every line was set aside (the /// vote of before, if any, is left as it is). pub standing: Option, } /// One voice per identity and per version, the last in the order of the chain: /// the block, then the rank of the event — whatever the order the lines come in. /// A *for* proven while held is set aside first and never takes a place in the /// order. Two lines at the same place (never on the chain): the later given. pub fn reconcile(lines: &[Proven]) -> Reconciled { let mut outcomes = vec![Outcome::Superseded; lines.len()]; let mut standing: Option = None; for (i, l) in lines.iter().enumerate() { if discarded_by_objection(l.choice, l.held) { outcomes[i] = Outcome::Objection; continue; } if standing.is_none_or(|j| lines[j].position <= l.position) { standing = Some(i); } } if let Some(i) = standing { outcomes[i] = Outcome::Counted; } Reconciled { outcomes, standing } } /// A change of the totals shown, for and against. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Shift { pub for_votes: i64, pub against: i64, } impl Shift { /// The totals, shifted; never below zero. pub fn apply(self, t: Tally) -> Tally { let add = |n: u32, d: i64| u32::try_from((i64::from(n) + d).clamp(0, i64::from(u32::MAX))).unwrap_or(n); Tally { for_votes: add(t.for_votes, self.for_votes), against: add(t.against, self.against) } } } /// What an intention weighs in the totals shown. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Weighed { /// What it changes in the totals shown. pub shift: Shift, /// The part of the totals shown it adds that still waits for its proof. pub pending: Tally, } /// The latest intention of a session on a version, still without proof, takes /// the place — in the totals shown only — of the vote this session sees as its /// own: /// - `seen`: that vote, if it counts in the totals shown now (*for* or *against*); /// - `intent`: the choice of the intention (`None`: a withdrawal); /// - `current`: it votes the revision in place (otherwise it would be « to /// reconfirm »: it adds nothing, but still takes the vote of before away); /// - `held`: an objection holds the version back — a *for* then weighs nothing /// at all (it would be set aside), the vote of before stays. pub fn weigh_intent(seen: Option, intent: Choice, current: bool, held: bool) -> Weighed { if discarded_by_objection(intent, held) { return Weighed::default(); } let one = |c: Option| match c { Some(Choice::For) => Tally { for_votes: 1, against: 0 }, Some(Choice::Against) => Tally { for_votes: 0, against: 1 }, Some(Choice::None) | None => Tally::default(), }; let new = one(current.then_some(intent)); let old = one(seen); Weighed { shift: Shift { for_votes: i64::from(new.for_votes) - i64::from(old.for_votes), against: i64::from(new.against) - i64::from(old.against), }, pending: new, } } #[cfg(test)] mod tests { use super::*; const FOR: Choice = Choice::For; const AGAINST: Choice = Choice::Against; const NONE: Choice = Choice::None; fn at(block: u32, event: u32) -> ChainPosition { ChainPosition { block, event } } fn line(block: u32, event: u32, choice: Choice) -> Proven { Proven { position: at(block, event), choice, held: false } } /// The outcome of each line, keyed by its position: comparable across orders. fn by_position(lines: &[Proven]) -> (Vec<(ChainPosition, Outcome)>, Option) { let r = reconcile(lines); let mut out: Vec<(ChainPosition, Outcome)> = lines.iter().zip(&r.outcomes).map(|(l, o)| (l.position, *o)).collect(); out.sort_by_key(|(p, _)| *p); (out, r.standing.map(|i| lines[i].position)) } #[test] fn the_last_in_the_order_of_the_chain_stands_whatever_the_order_of_reading() { let a = line(200, 2, FOR); let b = line(200, 4, AGAINST); let c = line(300, 1, NONE); let expected = ( vec![(a.position, Outcome::Superseded), (b.position, Outcome::Superseded), (c.position, Outcome::Counted)], Some(c.position), ); // Every order the indexer may read them in gives the same voice. for order in [[a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a]] { assert_eq!(by_position(&order), expected, "{order:?}"); } // The withdrawal stands: no voice. let r = reconcile(&[a, b, c]); assert_eq!(r.standing.map(|i| [a, b, c][i].choice), Some(NONE)); } #[test] fn one_voice_per_identity_never_two() { for lines in [ vec![], vec![line(5, 1, FOR)], vec![line(5, 1, FOR), line(5, 2, FOR), line(4, 9, AGAINST)], vec![Proven { held: true, ..line(9, 1, FOR) }, line(8, 1, AGAINST), line(7, 1, FOR)], ] { let r = reconcile(&lines); assert_eq!(r.outcomes.len(), lines.len()); let counted = r.outcomes.iter().filter(|o| **o == Outcome::Counted).count(); assert_eq!(counted, usize::from(r.standing.is_some()), "{lines:?}"); assert!(counted <= 1); } assert_eq!(reconcile(&[]).standing, None); } #[test] fn a_for_proven_while_held_is_set_aside_and_the_vote_of_before_stays() { let before = line(100, 1, AGAINST); let held_for = Proven { held: true, ..line(200, 1, FOR) }; for lines in [[before, held_for], [held_for, before]] { let (outcomes, standing) = by_position(&lines); assert_eq!(outcomes, vec![(before.position, Outcome::Counted), (held_for.position, Outcome::Objection)]); assert_eq!(standing, Some(before.position), "the *for* takes no place in the order"); } // Alone, it leaves nothing standing: the vote of before, if any, is left as it is. let r = reconcile(&[held_for]); assert_eq!((r.outcomes, r.standing), (vec![Outcome::Objection], None)); // Against and a withdrawal, proven while held, count. for choice in [AGAINST, NONE] { let held = Proven { held: true, ..line(300, 1, choice) }; let r = reconcile(&[before, held_for, held]); assert_eq!(r.standing, Some(2), "{choice:?}"); assert_eq!(r.outcomes, vec![Outcome::Superseded, Outcome::Objection, Outcome::Counted]); } assert!(discarded_by_objection(FOR, true)); assert!(!discarded_by_objection(FOR, false)); assert!(!discarded_by_objection(AGAINST, true) && !discarded_by_objection(NONE, true)); } #[test] fn two_lines_at_the_same_place_the_later_given_stands() { let r = reconcile(&[line(0, 3, FOR), line(0, 3, AGAINST)]); assert_eq!(r.standing, Some(1)); } #[test] fn a_simulated_vote_at_block_zero_comes_before_any_vote_of_the_chain() { // The simulation proves at block 0, in the order votes arrive (ADR 0012). let simulated = line(0, 41, FOR); let chain = line(1, 0, AGAINST); assert_eq!(by_position(&[chain, simulated]).1, Some(chain.position)); } #[test] fn what_each_stage_weighs() { let rules = Rules::default(); assert_eq!(rules.simulated_adopts, SIMULATED_ADOPTS); for stage in [Stage::Recorded, Stage::Pending, Stage::Simulated] { assert!(rules.shown(stage), "{stage:?}: everything is seen, at once"); } assert!(rules.adopts(Stage::Recorded)); assert!(!rules.adopts(Stage::Pending), "never an intention without proof"); // Yvv, 2026-11-02: « Il compte, puis s'efface » — like a proven vote, until the act. assert!(SIMULATED_ADOPTS && rules.adopts(Stage::Simulated)); // The other way, kept at hand: shown, never counted for adoption. let shown_only = Rules { simulated_adopts: false }; assert!(shown_only.shown(Stage::Simulated) && !shown_only.adopts(Stage::Simulated)); assert!(shown_only.adopts(Stage::Recorded) && !shown_only.adopts(Stage::Pending)); assert_eq!(Stage::of_source("simulated"), Stage::Simulated); assert_eq!(Stage::of_source("chain"), Stage::Recorded); assert_eq!(Stage::of_source("server"), Stage::Recorded); for o in [Outcome::Counted, Outcome::Superseded, Outcome::Objection] { assert_eq!(Outcome::parse(o.as_str()), Some(o)); } assert_eq!(Outcome::parse("maybe"), None); } fn shift(for_votes: i64, against: i64) -> Shift { Shift { for_votes, against } } fn tally(for_votes: u32, against: u32) -> Tally { Tally { for_votes, against } } #[test] fn an_intention_takes_the_place_of_the_vote_one_sees_as_one_s_own() { let w = |seen, intent, current, held| weigh_intent(seen, intent, current, held); // A first vote. assert_eq!(w(None, FOR, true, false), Weighed { shift: shift(1, 0), pending: tally(1, 0) }); // From *for* to *against*: one *for* less, one *against* more, that one waiting. assert_eq!(w(Some(FOR), AGAINST, true, false), Weighed { shift: shift(-1, 1), pending: tally(0, 1) }); // The same choice again: nothing moves, the voice now waits for its proof. assert_eq!(w(Some(FOR), FOR, true, false), Weighed { shift: shift(0, 0), pending: tally(1, 0) }); // A withdrawal takes the vote away at once. assert_eq!(w(Some(AGAINST), NONE, true, false), Weighed { shift: shift(0, -1), pending: tally(0, 0) }); assert_eq!(w(None, NONE, true, false), Weighed::default()); // On a revision gone by: it adds nothing, it still takes the vote of before away. assert_eq!(w(Some(FOR), AGAINST, false, false), Weighed { shift: shift(-1, 0), pending: tally(0, 0) }); // Held back by an objection: a *for* weighs nothing (it would be set aside)... assert_eq!(w(Some(AGAINST), FOR, true, true), Weighed::default()); assert_eq!(w(None, FOR, true, true), Weighed::default()); // ...against and a withdrawal do. assert_eq!(w(Some(FOR), AGAINST, true, true), Weighed { shift: shift(-1, 1), pending: tally(0, 1) }); assert_eq!(w(Some(FOR), NONE, true, true), Weighed { shift: shift(-1, 0), pending: tally(0, 0) }); // A « seen » withdrawal is no vote. assert_eq!(w(Some(NONE), FOR, true, false), w(None, FOR, true, false)); } #[test] fn a_shift_never_takes_the_totals_below_zero() { assert_eq!(shift(-1, 2).apply(tally(3, 0)), tally(2, 2)); assert_eq!(shift(-1, -1).apply(tally(0, 0)), tally(0, 0)); assert_eq!(shift(1, 0).apply(tally(u32::MAX, 0)), tally(u32::MAX, 0)); } }