Moteur : le tampon des votes (ld_engine::buffer) — réconciliation, poids des états, intention à la place du vote vu ; trustinWallet : casts_simulated (lot V, ADR 0012)

Testés d'abord (vus rouges) : une voix par identité, la dernière dans l'ordre de la
chaîne quel que soit l'ordre de lecture, un « pour » prouvé sur une version retenue
écarté (le vote d'avant reste), le vote simulé au bloc 0, ce que pèse chaque état
chaîne fermée ou ouverte, la bascule SIMULATED_ADOPTS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-11-02 05:44:00 +01:00
co-authored by Claude Opus 5.5
parent e7dda2ff47
commit ee37c3b4d5
3 changed files with 413 additions and 1 deletions
+384
View File
@@ -0,0 +1,384 @@
//! 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, while the chain is closed.
/// The default of lot V, until Yvv answers: like a proven vote (with C = 50 and
/// five people, nothing enters into force in Monnaie libre). `false` turns it to
/// « shown, never counted for adoption »; the totals shown do 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, while the chain is closed.
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, given whether the chain is open.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rules {
/// The vote account is set: votes go through the chain, the simulation is over.
pub chain_open: bool,
/// See `SIMULATED_ADOPTS`.
pub simulated_adopts: bool,
}
impl Rules {
/// Does a vote at this stage weigh in the totals shown?
pub fn shown(&self, stage: Stage) -> bool {
match stage {
Stage::Recorded | Stage::Pending => true,
Stage::Simulated => !self.chain_open,
}
}
/// 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.chain_open && 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> {
[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<Outcome>,
/// 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<usize>,
}
/// 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<usize> = 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<Choice>, intent: Choice, current: bool, held: bool) -> Weighed {
if discarded_by_objection(intent, held) {
return Weighed::default();
}
let one = |c: Option<Choice>| 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<ChainPosition>) {
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 closed = Rules { chain_open: false, simulated_adopts: SIMULATED_ADOPTS };
for stage in [Stage::Recorded, Stage::Pending, Stage::Simulated] {
assert!(closed.shown(stage), "{stage:?}: everything is seen while the chain is closed");
}
assert!(closed.adopts(Stage::Recorded));
assert!(!closed.adopts(Stage::Pending), "never an intention without proof");
assert!(closed.adopts(Stage::Simulated), "the default: a simulated vote counts like a proven one");
// The switch: shown, never counted for adoption.
let shown_only = Rules { simulated_adopts: false, ..closed };
assert!(shown_only.shown(Stage::Simulated) && !shown_only.adopts(Stage::Simulated));
assert!(shown_only.adopts(Stage::Recorded) && !shown_only.adopts(Stage::Pending));
// The chain opens: the simulated votes weigh nothing any more, anywhere.
for adopts in [true, false] {
let open = Rules { chain_open: true, simulated_adopts: adopts };
assert!(!open.shown(Stage::Simulated) && !open.adopts(Stage::Simulated));
assert!(open.shown(Stage::Pending) && !open.adopts(Stage::Pending));
assert!(open.shown(Stage::Recorded) && open.adopts(Stage::Recorded));
}
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));
}
}
+1
View File
@@ -9,6 +9,7 @@
pub mod address; pub mod address;
pub mod assist; pub mod assist;
pub mod ballot; pub mod ballot;
pub mod buffer;
pub mod compose; pub mod compose;
pub mod correction; pub mod correction;
pub mod edit; pub mod edit;
+28 -1
View File
@@ -23,7 +23,10 @@
//! administrator; it is reiterated by the person who raised it only — //! administrator; it is reiterated by the person who raised it only —
//! `lifts_objection` / `reiterates_objection`; //! `lifts_objection` / `reiterates_objection`;
//! - each person reads their own messages only — `reads_message` //! - each person reads their own messages only — `reads_message`
//! (2026-10-31, lot F, ADR 0010). //! (2026-10-31, lot F, ADR 0010);
//! - while the chain is closed, the vote is simulated: whoever may ask for a
//! ballot votes on the server, marked as simulated — `casts_simulated`
//! (2026-11-02, lot V, ADR 0012).
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -152,6 +155,16 @@ pub fn chain_vote_counts(member: bool) -> bool {
member member
} }
/// While the chain is closed (no vote account yet), a group that votes on the
/// chain votes in simulation (Yvv, 2026-11-02: « Simule le vote à tout endroit »,
/// ADR 0012): whoever may ask for a ballot casts a simulated vote, written by the
/// server, marked as such, for the identity of the session — there is no wallet.
/// One person signed in two ways may then hold two voices: accepted while the
/// simulation lasts, every simulated vote is set aside when the chain opens.
pub fn casts_simulated(identity: &Identity, group: &Group, granted: bool, chain_open: bool) -> bool {
!chain_open && asks_ballot(identity, group, granted)
}
/// Access management stays with trustinWallet's administrators. /// Access management stays with trustinWallet's administrators.
pub fn manages_access(identity: &Identity) -> bool { pub fn manages_access(identity: &Identity) -> bool {
identity.admin identity.admin
@@ -276,6 +289,20 @@ mod tests {
assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote"); assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote");
} }
#[test]
fn while_the_chain_is_closed_whoever_may_ask_a_ballot_votes_in_simulation() {
let ml = group("monnaie-libre");
let invited = who(IdentityKind::Invite, false, false);
let member = who(IdentityKind::G1, true, false);
assert!(casts_simulated(&invited, &ml, true, false));
assert!(casts_simulated(&member, &ml, false, false));
assert!(!casts_simulated(&invited, &ml, false, false), "not without opening the group");
// The chain open: a ballot to sign, never a simulated vote.
assert!(!casts_simulated(&member, &ml, false, true));
// A managed group votes on the server, never in simulation.
assert!(!casts_simulated(&invited, &group("essai"), true, false));
}
#[test] #[test]
fn in_a_managed_group_every_member_votes_on_the_server() { fn in_a_managed_group_every_member_votes_on_the_server() {
let essai = group("essai"); let essai = group("essai");