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

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:
Libby 2026-11-05 05:26:37 +00:00
commit e2a39d8aef
3 changed files with 321 additions and 68 deletions

View file

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

View file

@ -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,
}

View file

@ -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]