heimdall-delegation: pro-rata cap, revocation time on receipts, prior art
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Replace ascending-id trimming with pro-rata scaling by largest remainder (ascending originator id breaks exact remainder ties only). Receipts carry counted weight and the originator's latest covering revocation time. Delegation::revoked becomes Option<u64>. Property tests: trim order-independence, cap never exceeded, no delegator scaled more than another. README: prior-art section, placeholders labelled. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
30318c18fc
commit
e2a39d8aef
3 changed files with 321 additions and 68 deletions
|
|
@ -19,11 +19,73 @@ any CultMesh projection.
|
|||
- 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: the final holder may wield at most `cap` (default 1/5) of an estate's
|
||||
total weight delegated in from that estate. Over the cap, originators are
|
||||
kept in ascending id order until the next would overflow; the rest are
|
||||
trimmed and stay uncast. The ordering is a placeholder for Heimdall's ruling.
|
||||
- Revoked delegations are inert. Resolution never raises the total: every
|
||||
seated person yields exactly one receipt, cast + uncast = total weight.
|
||||
- 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.
|
||||
- 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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -49,8 +49,9 @@ pub struct Delegation {
|
|||
pub from: PersonId,
|
||||
pub to: PersonId,
|
||||
pub scope: Scope,
|
||||
/// Revoked delegations are inert.
|
||||
pub revoked: bool,
|
||||
/// `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)]
|
||||
|
|
@ -102,7 +103,7 @@ pub enum UncastReason {
|
|||
NotSeated,
|
||||
/// `same_estate_only` and the next delegate is in another estate.
|
||||
CrossEstate,
|
||||
/// The holder's cap for the originator's estate was reached.
|
||||
/// The cap scaled this originator's whole weight away (rounded to zero).
|
||||
Trimmed,
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +130,14 @@ pub struct Receipt {
|
|||
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,
|
||||
}
|
||||
|
||||
|
|
@ -178,8 +187,16 @@ pub fn resolve(
|
|||
}
|
||||
}
|
||||
let mut live: BTreeMap<PersonId, Vec<&Delegation>> = BTreeMap::new();
|
||||
for d in delegations.iter().filter(|d| !d.revoked) {
|
||||
live.entry(d.from).or_default().push(d);
|
||||
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() {
|
||||
|
|
@ -231,27 +248,59 @@ pub fn resolve(
|
|||
}
|
||||
Walk::Uncast(r) => Outcome::Uncast(r),
|
||||
};
|
||||
receipts.push(Receipt { topic: topic.to_string(), originator: p.id, estate: p.estate, weight: p.weight, outcome });
|
||||
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 (holder, originator estate), keep originators in ascending id
|
||||
// order until the next would overflow; the rest are trimmed.
|
||||
// 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 used: BTreeMap<(PersonId, EstateId), u128> = BTreeMap::new();
|
||||
let mut full: BTreeSet<(PersonId, EstateId)> = BTreeSet::new();
|
||||
for r in receipts.iter_mut() {
|
||||
let Outcome::Delegated { holder, .. } = r.outcome else { continue };
|
||||
let key = (holder, r.estate);
|
||||
let limit = estate_total[&r.estate] * config.cap.num as u128 / config.cap.den as u128;
|
||||
let after = used.get(&key).copied().unwrap_or(0) + r.weight as u128;
|
||||
if full.contains(&key) || after > limit {
|
||||
full.insert(key);
|
||||
r.outcome = Outcome::Uncast(UncastReason::Trimmed);
|
||||
} else {
|
||||
used.insert(key, after);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,9 +308,10 @@ pub fn resolve(
|
|||
for r in &receipts {
|
||||
match &r.outcome {
|
||||
Outcome::Direct { choice } | Outcome::Delegated { choice, .. } => {
|
||||
*tally.by_choice.entry(choice.clone()).or_default() += r.weight;
|
||||
*tally.by_estate.entry(r.estate).or_default().entry(choice.clone()).or_default() += r.weight;
|
||||
tally.cast += r.weight;
|
||||
*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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ 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: false }
|
||||
Delegation { from, to, scope: Scope::All, revoked: None }
|
||||
}
|
||||
fn vote(voter: u64, choice: &str) -> Vote {
|
||||
Vote { voter, topic: TOPIC.into(), choice: choice.into() }
|
||||
|
|
@ -72,12 +72,12 @@ fn random_world(rng: &mut Rng) -> World {
|
|||
from,
|
||||
to: 1 + rng.below(n + 2), // n+1, n+2 are unseated
|
||||
scope: Scope::All,
|
||||
revoked: rng.below(6) == 0,
|
||||
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: false });
|
||||
delegations.push(Delegation { from, to: 1 + rng.below(n), scope: only(&["war", "trade"]), revoked: false });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -239,11 +239,15 @@ 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 = true;
|
||||
d.revoked = Some(d.revoked.unwrap_or(7));
|
||||
}
|
||||
let mut removed = w.clone();
|
||||
removed.delegations.retain(|d| d.from % 2 != 0);
|
||||
assert_eq!(revoked.resolve(), removed.resolve(), "seed {seed}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,26 +289,37 @@ fn same_estate_switch_only_ever_removes_delegated_votes() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cap_trims_deterministically_in_originator_order() {
|
||||
// Estate 0: ten people of weight 1, holder is person 11 (estate 1).
|
||||
// Cap 1/5 of estate 0 (total 10) = 2.
|
||||
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, 1, 1));
|
||||
let d: Vec<_> = (1..=10).map(|i| del(i, 11)).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 w = world(c, people, d, vec![vote(11, "yes")]);
|
||||
let r = w.resolve();
|
||||
for id in 1..=2 {
|
||||
assert!(matches!(outcome(&r, id), Outcome::Delegated { holder: 11, .. }));
|
||||
}
|
||||
for id in 3..=10 {
|
||||
assert_eq!(outcome(&r, id), &Outcome::Uncast(UncastReason::Trimmed));
|
||||
}
|
||||
assert_eq!(r.tally.by_estate[&0]["yes"], 2);
|
||||
// Trimmed votes are not re-routed: the holder's own weight is untouched.
|
||||
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.uncast, 8);
|
||||
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]
|
||||
|
|
@ -317,30 +332,156 @@ fn cap_is_per_estate_and_applies_to_the_final_holder() {
|
|||
let mut c = loose(1);
|
||||
c.cap = Ratio { num: 1, den: 5 };
|
||||
let r = world(c, people, d, vec![vote(20, "yes")]).resolve();
|
||||
let held: Vec<u64> = r
|
||||
.receipts
|
||||
.iter()
|
||||
.filter(|x| matches!(x.outcome, Outcome::Delegated { .. }))
|
||||
.map(|x| x.originator)
|
||||
.collect();
|
||||
assert_eq!(held, vec![1, 6]);
|
||||
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 cap_holds_for_every_holder_and_estate() {
|
||||
for (seed, w) in worlds(400) {
|
||||
let r = w.resolve();
|
||||
let mut per: std::collections::BTreeMap<(u64, u64), u128> = Default::default();
|
||||
for rc in &r.receipts {
|
||||
if let Outcome::Delegated { holder, .. } = rc.outcome {
|
||||
*per.entry((holder, rc.estate)).or_default() += rc.weight as u128;
|
||||
}
|
||||
}
|
||||
for ((_, estate), got) in per {
|
||||
let total: u128 = w.people.iter().filter(|p| p.estate == estate).map(|p| p.weight as u128).sum();
|
||||
assert!(got <= total * w.config.cap.num as u128 / w.config.cap.den as u128, "seed {seed}");
|
||||
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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue