From ee37c3b4d53cee48a7dba20a0d8d75bbb97b8f89 Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 2 Nov 2026 05:44:00 +0100 Subject: [PATCH] =?UTF-8?q?Moteur=20:=20le=20tampon=20des=20votes=20(ld=5F?= =?UTF-8?q?engine::buffer)=20=E2=80=94=20r=C3=A9conciliation,=20poids=20de?= =?UTF-8?q?s=20=C3=A9tats,=20intention=20=C3=A0=20la=20place=20du=20vote?= =?UTF-8?q?=20vu=20;=20trustinWallet=20:=20casts=5Fsimulated=20(lot=20V,?= =?UTF-8?q?=20ADR=200012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ld-engine/src/buffer.rs | 384 +++++++++++++++++++++++++++++++ crates/ld-engine/src/lib.rs | 1 + crates/trustin-access/src/lib.rs | 29 ++- 3 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 crates/ld-engine/src/buffer.rs diff --git a/crates/ld-engine/src/buffer.rs b/crates/ld-engine/src/buffer.rs new file mode 100644 index 0000000..95beb41 --- /dev/null +++ b/crates/ld-engine/src/buffer.rs @@ -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::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 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)); + } +} diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index e36681d..5bb71ac 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod address; pub mod assist; pub mod ballot; +pub mod buffer; pub mod compose; pub mod correction; pub mod edit; diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 5c54455..6848762 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -23,7 +23,10 @@ //! administrator; it is reiterated by the person who raised it only — //! `lifts_objection` / `reiterates_objection`; //! - 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}; @@ -152,6 +155,16 @@ pub fn chain_vote_counts(member: bool) -> bool { 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. pub fn manages_access(identity: &Identity) -> bool { identity.admin @@ -276,6 +289,20 @@ mod tests { 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] fn in_a_managed_group_every_member_votes_on_the_server() { let essai = group("essai");