diff --git a/packages/heimdall-delegation/README.md b/packages/heimdall-delegation/README.md index 40ac8cad..bf0eca38 100644 --- a/packages/heimdall-delegation/README.md +++ b/packages/heimdall-delegation/README.md @@ -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. diff --git a/packages/heimdall-delegation/src/lib.rs b/packages/heimdall-delegation/src/lib.rs index adc7bfbc..a6fe97d6 100644 --- a/packages/heimdall-delegation/src/lib.rs +++ b/packages/heimdall-delegation/src/lib.rs @@ -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, } #[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, pub outcome: Outcome, } @@ -178,8 +187,16 @@ pub fn resolve( } } let mut live: BTreeMap> = BTreeMap::new(); - for d in delegations.iter().filter(|d| !d.revoked) { - live.entry(d.from).or_default().push(d); + let mut revoked_at: BTreeMap = 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 = 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> = 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::(); + let mut order: Vec = (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, } diff --git a/packages/heimdall-delegation/tests/properties.rs b/packages/heimdall-delegation/tests/properties.rs index 3f86c026..e4fa61ad 100644 --- a/packages/heimdall-delegation/tests/properties.rs +++ b/packages/heimdall-delegation/tests/properties.rs @@ -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 = 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 = 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 = (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 { + (1..=n).map(|seed| (seed, crowded_world(&mut Rng(seed * 104729)))) +} + +fn groups(w: &World, r: &Resolution) -> std::collections::BTreeMap<(u64, u64), Vec> { + let _ = w; + let mut g: std::collections::BTreeMap<(u64, u64), Vec> = 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> { + 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]