heimdall-delegation: delegation resolver (Rust, zero deps) #6

Merged
libby merged 3 commits from libby/heimdall-delegation into main 2026-11-05 11:02:24 +00:00
6 changed files with 946 additions and 0 deletions

2
.gitignore vendored
View file

@ -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
View 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"

View 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"

View 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.

View 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 })
}

View 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);
}