heimdall-delegation: delegation resolver (Rust, zero deps) #6
6 changed files with 946 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -322,6 +322,8 @@ node_modules/
|
|||
!packages/cultmesh-browser/**
|
||||
!packages/cultmesh-kotlin/
|
||||
!packages/cultmesh-kotlin/**
|
||||
!packages/heimdall-delegation/
|
||||
!packages/heimdall-delegation/**
|
||||
packages/**/node_modules/
|
||||
packages/**/dist/
|
||||
packages/**/dist-test/
|
||||
|
|
|
|||
7
packages/heimdall-delegation/Cargo.lock
generated
Normal file
7
packages/heimdall-delegation/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "heimdall-delegation"
|
||||
version = "0.1.0"
|
||||
10
packages/heimdall-delegation/Cargo.toml
Normal file
10
packages/heimdall-delegation/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "heimdall-delegation"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "heimdall_delegation"
|
||||
path = "src/lib.rs"
|
||||
98
packages/heimdall-delegation/README.md
Normal file
98
packages/heimdall-delegation/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# heimdall-delegation
|
||||
|
||||
Heimdall's delegation resolver: given who is seated, who delegated to whom, and
|
||||
who voted directly on a topic, it decides whose vote counts where, and publishes
|
||||
a receipt for every seated person. Pure and deterministic; no I/O, no
|
||||
dependencies. Entry point: `resolve` in `src/lib.rs`. Live: the resolver and its
|
||||
property tests (`tests/properties.rs`). Not here: a stake function (the caller
|
||||
supplies each person's `weight` until the estates' formula exists), storage, and
|
||||
any CultMesh projection.
|
||||
|
||||
## Rules
|
||||
|
||||
- A person may delegate to any seated person. Delegates may re-delegate, up to
|
||||
`Config::max_depth` hops.
|
||||
- A delegated vote counts in the **delegator's** estate.
|
||||
- Scope narrows along a chain: topic `t` flows only if every link's scope
|
||||
contains `t` (the receipt carries the intersection).
|
||||
- A direct vote on the topic overrides the voter's own delegation.
|
||||
- Cycles, depth violations, unseated or (with `same_estate_only`) cross-estate
|
||||
delegates, and chains that end without a vote return the vote **uncast** to
|
||||
its originator. Nothing is re-routed.
|
||||
- Cap (**placeholder**): per (final holder, originator estate) the holder may
|
||||
wield at most `cap` (default 1/5) of that estate's total weight. The holder's
|
||||
own direct vote is not counted against it. Over the cap, every delegator into
|
||||
that holder from that estate is scaled by the same factor `limit / sum`, in
|
||||
integer weight by largest remainder; only exact remainder ties fall to
|
||||
ascending originator id. A receipt's `counted` is its scaled weight and
|
||||
`weight - counted` is uncast; a share that rounds to zero is
|
||||
`Uncast(Trimmed)`. Cast + uncast = total weight.
|
||||
**Caveat:** the cap binds the ledger, not influence. A delegator under an
|
||||
over-cap holder can copy that holder's ballot and vote directly; the
|
||||
resolver sees a direct ballot and cannot see the copying. In the Bifrost sim
|
||||
(`docs/research/2026-11-05-estate-rule-sim/`, commit `5d352c6c`, Bifrost PR
|
||||
#4), with full copying the cap leaves effective top-holder share unchanged
|
||||
(difference +0.001 or less at every alpha), while the formal share drops. The
|
||||
copy rate there is an assumption, not a measurement.
|
||||
- Revocation (**placeholder**): `Delegation::revoked` is `Some(time)` when
|
||||
revoked. A revoked delegation is inert; the resolver reads no clock and the
|
||||
caller owns the ledger. Each receipt carries `revoked_at`, the latest
|
||||
revocation time among the originator's own revoked delegations covering the
|
||||
topic (revocations further down a chain are not reported).
|
||||
- Input order never matters; duplicate roster entries, duplicate direct votes
|
||||
and overlapping delegations from one person are input errors (**placeholder**: overlapping scopes are rejected, not resolved by precedence).
|
||||
|
||||
## Placeholders until the estates rule
|
||||
|
||||
Four choices are Heimdall's #swarm rulings of 2026-11-05 standing in for the
|
||||
estates' own decision, and change if they rule otherwise: (1) the cap is
|
||||
pro-rata, (2) the cap excludes the holder's own direct vote, (3) overlapping
|
||||
scopes are an input error, (4) revocation is a flag with a time and the caller
|
||||
owns the ledger.
|
||||
|
||||
## Prior art
|
||||
|
||||
Sources were fetched on 2026-11-05; claims below are what those pages say.
|
||||
|
||||
- **LiquidFeedback, cycles.** The platform treats a cycle as a non-problem: if
|
||||
everyone in a loop only delegates nobody votes, and if anyone in it acts
|
||||
directly their delegation is suspended, so the loop breaks
|
||||
([Liquid Democracy Journal 3, "Circular Delegations: Myth or Disaster"](https://liquid-democracy-journal.org/issue/3/The_Liquid_Democracy_Journal-Issue003-02-Circular_Delegations_-_Myth_or_Disaster.html)).
|
||||
Delegations are per area and a single area's set must not form cycles
|
||||
([Kling et al. 2015](https://ar5iv.arxiv.org/html/1503.07723)). *Consequence:*
|
||||
we keep a direct vote overriding the voter's own delegation, and a
|
||||
cycle with no direct vote returns every member's weight uncast to its
|
||||
originator rather than re-routing it. We do not forbid cycles at input time
|
||||
(the ledger is the caller's and may form them); we resolve them.
|
||||
- **Kling, Kunegis, Hartmann, Strohmaier, Staab 2015** ([ICWSM](https://ojs.aaai.org/index.php/ICWSM/article/view/14618),
|
||||
[arXiv:1503.07723](https://arxiv.org/abs/1503.07723)), a study of
|
||||
LiquidFeedback in the German Pirate Party. Weight is power-law distributed
|
||||
(38 voters held more than 100 delegations); delegation is transitive; super-voters
|
||||
have high theoretical power but exercise less of it (mean exercised/theoretical
|
||||
ratio 0.34), and the paper argues potential power is the safer measure. It
|
||||
proposes no cap. *Consequence:* a cap is our own addition, justified by the
|
||||
potential-power argument, not by that paper; keep the cap a separate,
|
||||
swappable `Config` field so the estates can set it to 1/1 and turn it off.
|
||||
Transitive delegation with `max_depth` stays.
|
||||
- **Gölz, Kahng, Mackenzie, Procaccia 2018** ([The Fluid Mechanics of Liquid
|
||||
Democracy](https://arxiv.org/abs/1808.01906), WINE 2018; TEAC 2021). With single
|
||||
delegation the maximum guru weight grows polynomially in the electorate;
|
||||
letting voters list more than one option and routing by confluent flow brings
|
||||
it to O(log log t). It handles cycles in general graphs by processing strongly
|
||||
connected components, and notes that people who delegate only among themselves
|
||||
have weight that reaches nobody. It considers caps and rejects them: voters
|
||||
can coordinate outside the system and copy a delegate's ballot, so a cap
|
||||
"would not alleviate the problem of super-voters, just reduce
|
||||
transparency" ([full text](https://ar5iv.arxiv.org/html/1808.01906)).
|
||||
*Consequences:* (a) a cap bounds what the platform records, not what a
|
||||
holder can persuade; the receipts' `counted`/uncast split keeps the trimmed
|
||||
part visible instead of hiding it, which is the transparency their objection
|
||||
turns on. (b) Pro-rata scaling treats equal delegators equally without a
|
||||
priority order a holder could game by who delegates first; ascending-id
|
||||
trimming did not. (c) Multi-option delegation is the research direction for
|
||||
reducing concentration without a cap; it is out of scope here and would need
|
||||
the same preference-vs-equal-treatment question the Liquid Democracy Journal
|
||||
piece raises about preferential delegation.
|
||||
- **Not checked:** this section does not cover other liquid-democracy
|
||||
implementations (e.g. Loomio, Democracy Earth) or the cycle literature after
|
||||
2018.
|
||||
320
packages/heimdall-delegation/src/lib.rs
Normal file
320
packages/heimdall-delegation/src/lib.rs
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
//! Heimdall's delegation resolver. See README.md for the rules.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub type PersonId = u64;
|
||||
pub type EstateId = u64;
|
||||
pub type Topic = String;
|
||||
pub type Choice = String;
|
||||
|
||||
/// A seated person. `weight` is supplied by the caller (no stake function yet).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Person {
|
||||
pub id: PersonId,
|
||||
pub estate: EstateId,
|
||||
pub weight: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Scope {
|
||||
All,
|
||||
Only(BTreeSet<Topic>),
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub fn covers(&self, topic: &str) -> bool {
|
||||
match self {
|
||||
Scope::All => true,
|
||||
Scope::Only(s) => s.contains(topic),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn narrow(&self, other: &Scope) -> Scope {
|
||||
match (self, other) {
|
||||
(Scope::All, s) | (s, Scope::All) => s.clone(),
|
||||
(Scope::Only(a), Scope::Only(b)) => Scope::Only(a.intersection(b).cloned().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Scope) -> bool {
|
||||
match (self, other) {
|
||||
(Scope::All, _) | (_, Scope::All) => true,
|
||||
(Scope::Only(a), Scope::Only(b)) => a.intersection(b).next().is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Delegation {
|
||||
pub from: PersonId,
|
||||
pub to: PersonId,
|
||||
pub scope: Scope,
|
||||
/// `Some(t)` means revoked at caller-clock time `t`. Revoked delegations are
|
||||
/// inert; the resolver never reads a clock and the caller owns the ledger.
|
||||
pub revoked: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vote {
|
||||
pub voter: PersonId,
|
||||
pub topic: Topic,
|
||||
pub choice: Choice,
|
||||
}
|
||||
|
||||
/// A fraction of an estate's total weight.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Ratio {
|
||||
pub num: u64,
|
||||
pub den: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
/// Maximum hops from originator to final holder; 0 disables delegation.
|
||||
pub max_depth: usize,
|
||||
/// Per-estate cap on what one final holder may wield from that estate.
|
||||
pub cap: Ratio,
|
||||
/// Fallback switch: refuse delegation across estates.
|
||||
pub same_estate_only: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config { max_depth: 3, cap: Ratio { num: 1, den: 5 }, same_estate_only: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum InputError {
|
||||
DuplicatePerson(PersonId),
|
||||
DuplicateVote(PersonId),
|
||||
ZeroCapDenominator,
|
||||
/// Two live delegations from one person cover a common topic.
|
||||
OverlappingDelegations(PersonId),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UncastReason {
|
||||
/// The chain ended at someone who neither voted nor delegated this topic.
|
||||
NoVote,
|
||||
Cycle,
|
||||
DepthExceeded,
|
||||
/// The next delegate is not seated.
|
||||
NotSeated,
|
||||
/// `same_estate_only` and the next delegate is in another estate.
|
||||
CrossEstate,
|
||||
/// The cap scaled this originator's whole weight away (rounded to zero).
|
||||
Trimmed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Direct { choice: Choice },
|
||||
Delegated {
|
||||
holder: PersonId,
|
||||
holder_estate: EstateId,
|
||||
/// originator, delegates..., holder
|
||||
chain: Vec<PersonId>,
|
||||
/// Intersection of every link's scope.
|
||||
scope: Scope,
|
||||
choice: Choice,
|
||||
},
|
||||
Uncast(UncastReason),
|
||||
}
|
||||
|
||||
/// Public receipt: one per seated person per topic. `estate` is the estate the
|
||||
/// weight counts in (the originator's).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Receipt {
|
||||
pub topic: Topic,
|
||||
pub originator: PersonId,
|
||||
pub estate: EstateId,
|
||||
pub weight: u64,
|
||||
/// Part of `weight` that counted for the outcome's choice. `weight` for a
|
||||
/// direct vote, `0` for an uncast one, and for a delegated one `weight`
|
||||
/// scaled by the cap; `weight - counted` is uncast.
|
||||
pub counted: u64,
|
||||
/// Latest revocation time among the originator's own revoked delegations
|
||||
/// whose scope covers this topic. Those delegations took no part in the
|
||||
/// resolution; the receipt records that they existed.
|
||||
pub revoked_at: Option<u64>,
|
||||
pub outcome: Outcome,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Tally {
|
||||
pub by_choice: BTreeMap<Choice, u64>,
|
||||
/// estate -> choice -> weight, counted in the originator's estate.
|
||||
pub by_estate: BTreeMap<EstateId, BTreeMap<Choice, u64>>,
|
||||
pub cast: u64,
|
||||
pub uncast: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Resolution {
|
||||
/// Sorted by originator id.
|
||||
pub receipts: Vec<Receipt>,
|
||||
pub tally: Tally,
|
||||
}
|
||||
|
||||
enum Walk {
|
||||
Direct(Choice),
|
||||
Held { chain: Vec<PersonId>, scope: Scope, choice: Choice },
|
||||
Uncast(UncastReason),
|
||||
}
|
||||
|
||||
/// Resolve one topic. Pure; the order of every input slice is irrelevant.
|
||||
pub fn resolve(
|
||||
config: &Config,
|
||||
people: &[Person],
|
||||
delegations: &[Delegation],
|
||||
votes: &[Vote],
|
||||
topic: &str,
|
||||
) -> Result<Resolution, InputError> {
|
||||
if config.cap.den == 0 {
|
||||
return Err(InputError::ZeroCapDenominator);
|
||||
}
|
||||
let mut roster: BTreeMap<PersonId, &Person> = BTreeMap::new();
|
||||
for p in people {
|
||||
if roster.insert(p.id, p).is_some() {
|
||||
return Err(InputError::DuplicatePerson(p.id));
|
||||
}
|
||||
}
|
||||
let mut direct: BTreeMap<PersonId, &Choice> = BTreeMap::new();
|
||||
for v in votes.iter().filter(|v| v.topic == topic) {
|
||||
if direct.insert(v.voter, &v.choice).is_some() {
|
||||
return Err(InputError::DuplicateVote(v.voter));
|
||||
}
|
||||
}
|
||||
let mut live: BTreeMap<PersonId, Vec<&Delegation>> = BTreeMap::new();
|
||||
let mut revoked_at: BTreeMap<PersonId, u64> = BTreeMap::new();
|
||||
for d in delegations {
|
||||
match d.revoked {
|
||||
None => live.entry(d.from).or_default().push(d),
|
||||
Some(t) if d.scope.covers(topic) => {
|
||||
let e = revoked_at.entry(d.from).or_insert(t);
|
||||
*e = (*e).max(t);
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for (from, ds) in &live {
|
||||
for (i, a) in ds.iter().enumerate() {
|
||||
if ds[i + 1..].iter().any(|b| a.scope.overlaps(&b.scope)) {
|
||||
return Err(InputError::OverlappingDelegations(*from));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let walk = |origin: &Person| -> Walk {
|
||||
if let Some(c) = direct.get(&origin.id) {
|
||||
return Walk::Direct((*c).clone());
|
||||
}
|
||||
let mut chain = vec![origin.id];
|
||||
let mut scope = Scope::All;
|
||||
let mut cur = origin.id;
|
||||
loop {
|
||||
let Some(d) = live.get(&cur).and_then(|ds| ds.iter().find(|d| d.scope.covers(topic))) else {
|
||||
return Walk::Uncast(UncastReason::NoVote);
|
||||
};
|
||||
let Some(next) = roster.get(&d.to) else {
|
||||
return Walk::Uncast(UncastReason::NotSeated);
|
||||
};
|
||||
if config.same_estate_only && next.estate != origin.estate {
|
||||
return Walk::Uncast(UncastReason::CrossEstate);
|
||||
}
|
||||
if chain.contains(&next.id) {
|
||||
return Walk::Uncast(UncastReason::Cycle);
|
||||
}
|
||||
if chain.len() > config.max_depth {
|
||||
return Walk::Uncast(UncastReason::DepthExceeded);
|
||||
}
|
||||
chain.push(next.id);
|
||||
scope = scope.narrow(&d.scope);
|
||||
if let Some(c) = direct.get(&next.id) {
|
||||
return Walk::Held { chain, scope, choice: (*c).clone() };
|
||||
}
|
||||
cur = next.id;
|
||||
}
|
||||
};
|
||||
|
||||
let mut receipts: Vec<Receipt> = Vec::with_capacity(roster.len());
|
||||
for p in roster.values() {
|
||||
let outcome = match walk(p) {
|
||||
Walk::Direct(choice) => Outcome::Direct { choice },
|
||||
Walk::Held { chain, scope, choice } => {
|
||||
let holder = *chain.last().expect("chain has a holder");
|
||||
Outcome::Delegated { holder, holder_estate: roster[&holder].estate, chain, scope, choice }
|
||||
}
|
||||
Walk::Uncast(r) => Outcome::Uncast(r),
|
||||
};
|
||||
let counted = if matches!(outcome, Outcome::Direct { .. } | Outcome::Delegated { .. }) { p.weight } else { 0 };
|
||||
receipts.push(Receipt {
|
||||
topic: topic.to_string(),
|
||||
originator: p.id,
|
||||
estate: p.estate,
|
||||
weight: p.weight,
|
||||
counted,
|
||||
revoked_at: revoked_at.get(&p.id).copied(),
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
|
||||
// Cap: per (final holder, originator estate) the holder may wield at most
|
||||
// floor(estate total * cap) of delegated weight (the holder's own direct
|
||||
// vote is not part of it). An over-cap group is scaled pro rata: every
|
||||
// delegator by the same factor limit / sum, in integer weight by largest
|
||||
// remainder. Only exact remainder ties fall to ascending originator id.
|
||||
let mut estate_total: BTreeMap<EstateId, u128> = BTreeMap::new();
|
||||
for p in roster.values() {
|
||||
*estate_total.entry(p.estate).or_default() += p.weight as u128;
|
||||
}
|
||||
let mut groups: BTreeMap<(PersonId, EstateId), Vec<usize>> = BTreeMap::new();
|
||||
for (i, r) in receipts.iter().enumerate() {
|
||||
if let Outcome::Delegated { holder, .. } = r.outcome {
|
||||
groups.entry((holder, r.estate)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
for ((_, estate), members) in groups {
|
||||
let limit = estate_total[&estate] * config.cap.num as u128 / config.cap.den as u128;
|
||||
let sum: u128 = members.iter().map(|&i| receipts[i].weight as u128).sum();
|
||||
if sum <= limit {
|
||||
continue;
|
||||
}
|
||||
// members are in ascending originator id (receipts are), so a stable
|
||||
// sort by descending remainder leaves ids ascending within ties.
|
||||
let mut shares: Vec<(usize, u128, u128)> = members
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let scaled = receipts[i].weight as u128 * limit;
|
||||
(i, scaled / sum, scaled % sum)
|
||||
})
|
||||
.collect();
|
||||
let leftover = limit - shares.iter().map(|s| s.1).sum::<u128>();
|
||||
let mut order: Vec<usize> = (0..shares.len()).collect();
|
||||
order.sort_by(|&a, &b| shares[b].2.cmp(&shares[a].2));
|
||||
for &k in order.iter().take(leftover as usize) {
|
||||
shares[k].1 += 1;
|
||||
}
|
||||
for (i, counted, _) in shares {
|
||||
receipts[i].counted = counted as u64;
|
||||
if counted == 0 {
|
||||
receipts[i].outcome = Outcome::Uncast(UncastReason::Trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut tally = Tally::default();
|
||||
for r in &receipts {
|
||||
match &r.outcome {
|
||||
Outcome::Direct { choice } | Outcome::Delegated { choice, .. } => {
|
||||
*tally.by_choice.entry(choice.clone()).or_default() += r.counted;
|
||||
*tally.by_estate.entry(r.estate).or_default().entry(choice.clone()).or_default() += r.counted;
|
||||
tally.cast += r.counted;
|
||||
tally.uncast += r.weight - r.counted;
|
||||
}
|
||||
Outcome::Uncast(_) => tally.uncast += r.weight,
|
||||
}
|
||||
}
|
||||
Ok(Resolution { receipts, tally })
|
||||
}
|
||||
509
packages/heimdall-delegation/tests/properties.rs
Normal file
509
packages/heimdall-delegation/tests/properties.rs
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
use heimdall_delegation::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// xorshift64*: seeded, dependency-free, reproducible.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545F4914F6CDD1D)
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
fn shuffle<T>(&mut self, v: &mut [T]) {
|
||||
for i in (1..v.len()).rev() {
|
||||
v.swap(i, self.below(i as u64 + 1) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TOPIC: &str = "budget";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct World {
|
||||
config: Config,
|
||||
people: Vec<Person>,
|
||||
delegations: Vec<Delegation>,
|
||||
votes: Vec<Vote>,
|
||||
}
|
||||
|
||||
impl World {
|
||||
fn resolve(&self) -> Resolution {
|
||||
resolve(&self.config, &self.people, &self.delegations, &self.votes, TOPIC).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn person(id: u64, estate: u64, weight: u64) -> Person {
|
||||
Person { id, estate, weight }
|
||||
}
|
||||
fn del(from: u64, to: u64) -> Delegation {
|
||||
Delegation { from, to, scope: Scope::All, revoked: None }
|
||||
}
|
||||
fn vote(voter: u64, choice: &str) -> Vote {
|
||||
Vote { voter, topic: TOPIC.into(), choice: choice.into() }
|
||||
}
|
||||
fn only(topics: &[&str]) -> Scope {
|
||||
Scope::Only(topics.iter().map(|t| t.to_string()).collect())
|
||||
}
|
||||
fn world(config: Config, people: Vec<Person>, delegations: Vec<Delegation>, votes: Vec<Vote>) -> World {
|
||||
World { config, people, delegations, votes }
|
||||
}
|
||||
fn loose(max_depth: usize) -> Config {
|
||||
Config { max_depth, cap: Ratio { num: 1, den: 1 }, same_estate_only: false }
|
||||
}
|
||||
fn outcome(r: &Resolution, id: u64) -> &Outcome {
|
||||
&r.receipts.iter().find(|x| x.originator == id).unwrap().outcome
|
||||
}
|
||||
|
||||
/// Random world: ids 1..=n, 3 estates. Delegations may form cycles, cross
|
||||
/// estates, point at unseated ids, carry scopes, and be revoked. At most one
|
||||
/// delegation per person per scope bucket so inputs stay valid.
|
||||
fn random_world(rng: &mut Rng) -> World {
|
||||
let n = 2 + rng.below(14);
|
||||
let people: Vec<Person> = (1..=n).map(|i| person(i, rng.below(3), 1 + rng.below(9))).collect();
|
||||
let mut delegations = vec![];
|
||||
for from in 1..=n {
|
||||
match rng.below(4) {
|
||||
0 => {}
|
||||
1 | 2 => delegations.push(Delegation {
|
||||
from,
|
||||
to: 1 + rng.below(n + 2), // n+1, n+2 are unseated
|
||||
scope: Scope::All,
|
||||
revoked: (rng.below(6) == 0).then(|| rng.below(1000)),
|
||||
}),
|
||||
_ => {
|
||||
// two disjoint scoped delegations
|
||||
delegations.push(Delegation { from, to: 1 + rng.below(n), scope: only(&["budget"]), revoked: None });
|
||||
delegations.push(Delegation { from, to: 1 + rng.below(n), scope: only(&["war", "trade"]), revoked: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut votes = vec![];
|
||||
for v in 1..=n {
|
||||
if rng.below(3) == 0 {
|
||||
votes.push(vote(v, ["yes", "no", "abstain"][rng.below(3) as usize]));
|
||||
}
|
||||
}
|
||||
let config = Config {
|
||||
max_depth: rng.below(5) as usize,
|
||||
cap: Ratio { num: 1 + rng.below(3), den: 1 + rng.below(5) },
|
||||
same_estate_only: rng.below(4) == 0,
|
||||
};
|
||||
World { config, people, delegations, votes }
|
||||
}
|
||||
|
||||
fn worlds(n: u64) -> impl Iterator<Item = (u64, World)> {
|
||||
(1..=n).map(|seed| (seed, random_world(&mut Rng(seed * 7919))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_independence_under_shuffled_input() {
|
||||
for (seed, w) in worlds(400) {
|
||||
let base = w.resolve();
|
||||
let mut rng = Rng(seed + 1000);
|
||||
for _ in 0..5 {
|
||||
let mut s = w.clone();
|
||||
rng.shuffle(&mut s.people);
|
||||
rng.shuffle(&mut s.delegations);
|
||||
rng.shuffle(&mut s.votes);
|
||||
assert_eq!(s.resolve(), base, "seed {seed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conservation_one_receipt_per_person_and_no_inflation() {
|
||||
for (seed, w) in worlds(400) {
|
||||
let r = w.resolve();
|
||||
let total: u64 = w.people.iter().map(|p| p.weight).sum();
|
||||
assert_eq!(r.receipts.len(), w.people.len(), "seed {seed}");
|
||||
assert_eq!(r.tally.cast + r.tally.uncast, total, "seed {seed}");
|
||||
assert!(r.tally.cast <= total);
|
||||
assert_eq!(r.tally.by_choice.values().sum::<u64>(), r.tally.cast);
|
||||
for p in &w.people {
|
||||
let counted: u64 = r.tally.by_estate.get(&p.estate).map_or(0, |m| m.values().sum());
|
||||
let estate_total: u64 = w.people.iter().filter(|q| q.estate == p.estate).map(|q| q.weight).sum();
|
||||
assert!(counted <= estate_total, "seed {seed}: estate {} inflated", p.estate);
|
||||
}
|
||||
// Delegated weight counts in the delegator's estate, not the holder's.
|
||||
for rc in &r.receipts {
|
||||
let p = w.people.iter().find(|p| p.id == rc.originator).unwrap();
|
||||
assert_eq!((rc.estate, rc.weight), (p.estate, p.weight));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_vote_overrides_any_delegation() {
|
||||
for (seed, w) in worlds(400) {
|
||||
let r = w.resolve();
|
||||
for v in &w.votes {
|
||||
assert_eq!(outcome(&r, v.voter), &Outcome::Direct { choice: v.choice.clone() }, "seed {seed}");
|
||||
}
|
||||
}
|
||||
// Same person with and without a delegation chain pointing elsewhere.
|
||||
let people = vec![person(1, 0, 1), person(2, 0, 1), person(3, 0, 1)];
|
||||
let votes = vec![vote(1, "yes"), vote(3, "no")];
|
||||
let with = world(loose(3), people.clone(), vec![del(1, 2), del(2, 3)], votes.clone()).resolve();
|
||||
let without = world(loose(3), people, vec![del(2, 3)], votes).resolve();
|
||||
assert_eq!(outcome(&with, 1), &Outcome::Direct { choice: "yes".into() });
|
||||
assert_eq!(with.receipts[0], without.receipts[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_return_the_vote_to_every_originator_in_them() {
|
||||
let people: Vec<_> = (1..=4).map(|i| person(i, 0, 1)).collect();
|
||||
let r = world(loose(9), people.clone(), vec![del(1, 2), del(2, 3), del(3, 1), del(4, 1)], vec![]).resolve();
|
||||
for id in 1..=4 {
|
||||
assert_eq!(outcome(&r, id), &Outcome::Uncast(UncastReason::Cycle));
|
||||
}
|
||||
assert_eq!((r.tally.cast, r.tally.uncast), (0, 4));
|
||||
// Self-delegation is a cycle too.
|
||||
let r = world(loose(9), people, vec![del(1, 1)], vec![]).resolve();
|
||||
assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::Cycle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_chains_are_acyclic_and_within_depth() {
|
||||
for (seed, w) in worlds(400) {
|
||||
for rc in &w.resolve().receipts {
|
||||
if let Outcome::Delegated { chain, .. } = &rc.outcome {
|
||||
let uniq: BTreeSet<_> = chain.iter().collect();
|
||||
assert_eq!(uniq.len(), chain.len(), "seed {seed}");
|
||||
assert!(chain.len() - 1 <= w.config.max_depth, "seed {seed}");
|
||||
assert_eq!(chain[0], rc.originator);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_boundary_is_exact() {
|
||||
// 1 -> 2 -> 3 -> 4(votes): three hops.
|
||||
let people: Vec<_> = (1..=4).map(|i| person(i, 0, 1)).collect();
|
||||
let d = vec![del(1, 2), del(2, 3), del(3, 4)];
|
||||
let v = vec![vote(4, "yes")];
|
||||
let at = world(loose(3), people.clone(), d.clone(), v.clone()).resolve();
|
||||
assert!(matches!(outcome(&at, 1), Outcome::Delegated { holder: 4, .. }));
|
||||
let over = world(loose(2), people, d, v).resolve();
|
||||
assert_eq!(outcome(&over, 1), &Outcome::Uncast(UncastReason::DepthExceeded));
|
||||
assert!(matches!(outcome(&over, 2), Outcome::Delegated { holder: 4, .. }));
|
||||
assert!(matches!(outcome(&over, 3), Outcome::Delegated { holder: 4, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_depth_disables_delegation() {
|
||||
let people = vec![person(1, 0, 1), person(2, 0, 1)];
|
||||
let r = world(loose(0), people, vec![del(1, 2)], vec![vote(2, "yes")]).resolve();
|
||||
assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::DepthExceeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_narrows_along_the_chain() {
|
||||
let people: Vec<_> = (1..=3).map(|i| person(i, 0, 1)).collect();
|
||||
let mut a = del(1, 2);
|
||||
a.scope = only(&["budget", "war"]);
|
||||
let mut b = del(2, 3);
|
||||
b.scope = only(&["budget", "trade"]);
|
||||
let r = world(loose(3), people, vec![a, b], vec![vote(3, "yes")]).resolve();
|
||||
match outcome(&r, 1) {
|
||||
Outcome::Delegated { scope, .. } => assert_eq!(scope, &only(&["budget"])),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
// Topic outside the second link's scope: chain ends at 2, who has no vote.
|
||||
let mut a = del(1, 2);
|
||||
a.scope = Scope::All;
|
||||
let mut b = del(2, 3);
|
||||
b.scope = only(&["trade"]);
|
||||
let people: Vec<_> = (1..=3).map(|i| person(i, 0, 1)).collect();
|
||||
let r = world(loose(3), people, vec![a, b], vec![vote(3, "yes")]).resolve();
|
||||
assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::NoVote));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_delegated_scope_covers_the_topic() {
|
||||
for (seed, w) in worlds(400) {
|
||||
for rc in &w.resolve().receipts {
|
||||
if let Outcome::Delegated { scope, .. } = &rc.outcome {
|
||||
assert!(scope.covers(TOPIC), "seed {seed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_delegation_equals_removed_delegation() {
|
||||
for (seed, w) in worlds(400) {
|
||||
let mut revoked = w.clone();
|
||||
for d in revoked.delegations.iter_mut().filter(|d| d.from % 2 == 0) {
|
||||
d.revoked = Some(d.revoked.unwrap_or(7));
|
||||
}
|
||||
let mut removed = w.clone();
|
||||
removed.delegations.retain(|d| d.from % 2 != 0);
|
||||
let (mut a, mut b) = (revoked.resolve(), removed.resolve());
|
||||
for r in a.receipts.iter_mut().chain(b.receipts.iter_mut()) {
|
||||
r.revoked_at = None;
|
||||
}
|
||||
assert_eq!(a, b, "seed {seed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unseated_delegate_returns_the_vote() {
|
||||
let r = world(loose(3), vec![person(1, 0, 4)], vec![del(1, 99)], vec![vote(99, "yes")]).resolve();
|
||||
assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::NotSeated));
|
||||
assert_eq!(r.tally.uncast, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_estate_only_refuses_cross_estate_hops() {
|
||||
let people = vec![person(1, 0, 1), person(2, 1, 1), person(3, 0, 1)];
|
||||
let d = vec![del(1, 2), del(3, 1)];
|
||||
let v = vec![vote(2, "yes")];
|
||||
let open = world(loose(3), people.clone(), d.clone(), v.clone()).resolve();
|
||||
assert!(matches!(outcome(&open, 1), Outcome::Delegated { holder: 2, holder_estate: 1, .. }));
|
||||
let mut c = loose(3);
|
||||
c.same_estate_only = true;
|
||||
let shut = world(c, people, d, v).resolve();
|
||||
assert_eq!(outcome(&shut, 1), &Outcome::Uncast(UncastReason::CrossEstate));
|
||||
assert_eq!(outcome(&shut, 3), &Outcome::Uncast(UncastReason::CrossEstate));
|
||||
// Delegated votes count in the delegator's estate even across estates.
|
||||
assert_eq!(open.tally.by_estate[&0]["yes"], 2);
|
||||
assert_eq!(open.tally.by_estate[&1]["yes"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_estate_switch_only_ever_removes_delegated_votes() {
|
||||
for (seed, w) in worlds(400) {
|
||||
let mut shut = w.clone();
|
||||
shut.config.same_estate_only = true;
|
||||
for rc in &shut.resolve().receipts {
|
||||
if let Outcome::Delegated { holder_estate, .. } = rc.outcome {
|
||||
assert_eq!(holder_estate, rc.estate, "seed {seed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_scales_every_delegator_by_the_same_factor() {
|
||||
// Estate 0: ten people of weight 1 plus a weight-10 whale (total 20);
|
||||
// cap 1/5 -> 4 from estate 0. Eleven delegators into holder 12 (estate 1):
|
||||
// sum 20 > 4, factor 1/5: the whale keeps 2, the ten keep 0.2 each; the
|
||||
// two leftover units go to the largest remainders (equal, so ids 1 and 2).
|
||||
let mut people: Vec<_> = (1..=10).map(|i| person(i, 0, 1)).collect();
|
||||
people.push(person(11, 0, 10));
|
||||
people.push(person(12, 1, 1));
|
||||
let d: Vec<_> = (1..=11).map(|i| del(i, 12)).collect();
|
||||
let mut c = loose(1);
|
||||
c.cap = Ratio { num: 1, den: 5 };
|
||||
let r = world(c, people, d, vec![vote(12, "yes")]).resolve();
|
||||
let counted: Vec<u64> = r.receipts.iter().filter(|x| x.estate == 0).map(|x| x.counted).collect();
|
||||
assert_eq!(counted, vec![1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2]);
|
||||
assert_eq!(outcome(&r, 3), &Outcome::Uncast(UncastReason::Trimmed));
|
||||
assert_eq!(r.tally.by_estate[&0]["yes"], 4);
|
||||
// The holder's own direct vote is outside the cap and untouched.
|
||||
assert_eq!(r.tally.by_estate[&1]["yes"], 1);
|
||||
assert_eq!((r.tally.cast, r.tally.uncast), (5, 16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_trim_keeps_the_delegation_and_reports_the_uncast_part() {
|
||||
let people = vec![person(1, 0, 6), person(2, 0, 4), person(3, 1, 1)];
|
||||
let mut c = loose(1);
|
||||
c.cap = Ratio { num: 1, den: 2 }; // limit 5 of estate 0 (total 10)
|
||||
let r = world(c, people, vec![del(1, 3), del(2, 3)], vec![vote(3, "yes")]).resolve();
|
||||
let rc = |id| r.receipts.iter().find(|x| x.originator == id).unwrap();
|
||||
assert_eq!((rc(1).counted, rc(2).counted), (3, 2)); // exactly 6/10 and 4/10 of 5
|
||||
assert!(matches!(rc(1).outcome, Outcome::Delegated { holder: 3, .. }));
|
||||
assert_eq!(r.tally.uncast, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_is_per_estate_and_applies_to_the_final_holder() {
|
||||
// Two estates of 5. Cap 1/5 -> 1 each. Holder 20 gets 1 from each estate.
|
||||
let mut people: Vec<_> = (1..=5).map(|i| person(i, 0, 1)).collect();
|
||||
people.extend((6..=10).map(|i| person(i, 1, 1)));
|
||||
people.push(person(20, 2, 1));
|
||||
let d: Vec<_> = (1..=10).map(|i| del(i, 20)).collect();
|
||||
let mut c = loose(1);
|
||||
c.cap = Ratio { num: 1, den: 5 };
|
||||
let r = world(c, people, d, vec![vote(20, "yes")]).resolve();
|
||||
for e in [0, 1] {
|
||||
let got: u64 = r.receipts.iter().filter(|x| x.estate == e).map(|x| x.counted).sum();
|
||||
assert_eq!(got, 1, "estate {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Worlds built to hit the cap: few holders, most people delegate straight
|
||||
/// to them, varied weights, tight caps.
|
||||
fn crowded_world(rng: &mut Rng) -> World {
|
||||
let n = 6 + rng.below(20);
|
||||
let holders = 1 + rng.below(3);
|
||||
let people: Vec<Person> = (1..=n).map(|i| person(i, rng.below(3), 1 + rng.below(12))).collect();
|
||||
let mut delegations = vec![];
|
||||
let mut votes = vec![];
|
||||
for i in 1..=n {
|
||||
if i <= holders {
|
||||
votes.push(vote(i, "yes"));
|
||||
} else if rng.below(8) != 0 {
|
||||
delegations.push(del(i, 1 + rng.below(holders)));
|
||||
}
|
||||
}
|
||||
let config = Config { max_depth: 1, cap: Ratio { num: 1, den: 2 + rng.below(6) }, same_estate_only: false };
|
||||
World { config, people, delegations, votes }
|
||||
}
|
||||
|
||||
fn crowded(n: u64) -> impl Iterator<Item = (u64, World)> {
|
||||
(1..=n).map(|seed| (seed, crowded_world(&mut Rng(seed * 104729))))
|
||||
}
|
||||
|
||||
fn groups(w: &World, r: &Resolution) -> std::collections::BTreeMap<(u64, u64), Vec<Receipt>> {
|
||||
let _ = w;
|
||||
let mut g: std::collections::BTreeMap<(u64, u64), Vec<Receipt>> = Default::default();
|
||||
for rc in &r.receipts {
|
||||
if let Outcome::Delegated { holder, .. } = rc.outcome {
|
||||
g.entry((holder, rc.estate)).or_default().push(rc.clone());
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
fn limit(w: &World, estate: u64) -> u128 {
|
||||
let total: u128 = w.people.iter().filter(|p| p.estate == estate).map(|p| p.weight as u128).sum();
|
||||
total * w.config.cap.num as u128 / w.config.cap.den as u128
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_is_order_independent_under_shuffles() {
|
||||
let mut trimmed = 0;
|
||||
for (seed, w) in crowded(400) {
|
||||
let base = w.resolve();
|
||||
trimmed += base.receipts.iter().filter(|r| r.counted < r.weight).count();
|
||||
let mut rng = Rng(seed + 5000);
|
||||
for _ in 0..5 {
|
||||
let mut s = w.clone();
|
||||
rng.shuffle(&mut s.people);
|
||||
rng.shuffle(&mut s.delegations);
|
||||
rng.shuffle(&mut s.votes);
|
||||
assert_eq!(s.resolve(), base, "seed {seed}");
|
||||
}
|
||||
}
|
||||
assert!(trimmed > 500, "generator must actually exercise the cap, got {trimmed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegated_weight_never_exceeds_the_cap_and_fills_it_when_over() {
|
||||
for (seed, w) in worlds(400).chain(crowded(400)) {
|
||||
let r = w.resolve();
|
||||
for ((_, estate), members) in groups(&w, &r) {
|
||||
let sum: u128 = members.iter().map(|m| m.weight as u128).sum();
|
||||
let got: u128 = members.iter().map(|m| m.counted as u128).sum();
|
||||
let lim = limit(&w, estate);
|
||||
assert!(got <= lim, "seed {seed}: {got} > {lim}");
|
||||
if sum > lim {
|
||||
assert_eq!(got, lim, "seed {seed}: over-cap group must use exactly the cap");
|
||||
} else {
|
||||
assert_eq!(got, sum, "seed {seed}: under-cap group is untouched");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_delegator_is_scaled_more_than_another() {
|
||||
for (seed, w) in crowded(400) {
|
||||
let r = w.resolve();
|
||||
for ((_, estate), members) in groups(&w, &r).into_iter().chain(trimmed_groups(&w, &r)) {
|
||||
let sum: u128 = members.iter().map(|m| m.weight as u128).sum();
|
||||
let lim = limit(&w, estate);
|
||||
if sum <= lim {
|
||||
continue;
|
||||
}
|
||||
for m in &members {
|
||||
// counted is the exact pro-rata share rounded down or up, never further.
|
||||
let exact = m.weight as u128 * lim;
|
||||
let (lo, hi) = (exact / sum, exact.div_ceil(sum));
|
||||
assert!((lo..=hi).contains(&(m.counted as u128)), "seed {seed}: {m:?}");
|
||||
}
|
||||
// Equal weights are scaled equally to within one unit of rounding,
|
||||
// and a unit of rounding goes to the lower originator id on a tie.
|
||||
for a in &members {
|
||||
for b in &members {
|
||||
if a.weight == b.weight {
|
||||
assert!(a.counted.abs_diff(b.counted) <= 1, "seed {seed}");
|
||||
if a.counted < b.counted {
|
||||
assert!(a.originator > b.originator, "seed {seed}: tie broke against ascending id");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegators whose whole share rounded to zero are `Uncast(Trimmed)`, so they
|
||||
/// are not in `groups`; rebuild their groups by following the chain they would
|
||||
/// have taken (depth 1: their sole live delegation).
|
||||
fn trimmed_groups(w: &World, r: &Resolution) -> std::collections::BTreeMap<(u64, u64), Vec<Receipt>> {
|
||||
let mut g = groups(w, r);
|
||||
for rc in &r.receipts {
|
||||
if rc.outcome == Outcome::Uncast(UncastReason::Trimmed) {
|
||||
let to = w.delegations.iter().find(|d| d.from == rc.originator && d.revoked.is_none()).unwrap().to;
|
||||
g.entry((to, rc.estate)).or_default().push(rc.clone());
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipts_carry_the_revocation_time_of_the_originators_own_revoked_delegation() {
|
||||
let people = vec![person(1, 0, 1), person(2, 0, 1), person(3, 0, 1)];
|
||||
let mut a = del(1, 2);
|
||||
a.revoked = Some(40);
|
||||
let mut b = del(1, 3);
|
||||
b.revoked = Some(90);
|
||||
b.scope = only(&["budget"]);
|
||||
let mut c = del(1, 3);
|
||||
c.revoked = Some(500);
|
||||
c.scope = only(&["war"]); // other topic: not reported
|
||||
let r = world(loose(2), people, vec![a, b, c], vec![vote(3, "yes")]).resolve();
|
||||
let rc = |id| r.receipts.iter().find(|x| x.originator == id).unwrap();
|
||||
assert_eq!(rc(1).revoked_at, Some(90)); // latest covering revocation
|
||||
assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::NoVote)); // and it stayed inert
|
||||
assert_eq!(rc(2).revoked_at, None);
|
||||
// A live delegation alongside an older revoked one still resolves.
|
||||
let people = vec![person(1, 0, 1), person(2, 0, 1)];
|
||||
let mut old = del(1, 2);
|
||||
old.revoked = Some(3);
|
||||
let r = world(loose(2), people, vec![old, del(1, 2)], vec![vote(2, "yes")]).resolve();
|
||||
assert!(matches!(outcome(&r, 1), Outcome::Delegated { holder: 2, .. }));
|
||||
assert_eq!(r.receipts[0].revoked_at, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_is_an_error_not_a_guess() {
|
||||
let p = vec![person(1, 0, 1), person(1, 0, 1)];
|
||||
assert_eq!(resolve(&loose(1), &p, &[], &[], TOPIC), Err(InputError::DuplicatePerson(1)));
|
||||
let p = vec![person(1, 0, 1)];
|
||||
let v = vec![vote(1, "a"), vote(1, "b")];
|
||||
assert_eq!(resolve(&loose(1), &p, &[], &v, TOPIC), Err(InputError::DuplicateVote(1)));
|
||||
let mut a = del(1, 2);
|
||||
a.scope = only(&["budget"]);
|
||||
let b = del(1, 3);
|
||||
assert_eq!(resolve(&loose(1), &p, &[a, b], &[], TOPIC), Err(InputError::OverlappingDelegations(1)));
|
||||
let mut c = loose(1);
|
||||
c.cap.den = 0;
|
||||
assert_eq!(resolve(&c, &p, &[], &[], TOPIC), Err(InputError::ZeroCapDenominator));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn votes_on_other_topics_are_ignored() {
|
||||
let people = vec![person(1, 0, 1), person(2, 0, 1)];
|
||||
let other = Vote { voter: 2, topic: "war".into(), choice: "yes".into() };
|
||||
let r = resolve(&loose(2), &people, &[del(1, 2)], &[other], TOPIC).unwrap();
|
||||
assert_eq!(r.tally.cast, 0);
|
||||
}
|
||||
Loading…
Reference in a new issue