From 30318c18fcba74f12858632da24ceb5b34637e15 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 5 Nov 2026 00:09:07 +0000 Subject: [PATCH 1/3] heimdall-delegation: delegation resolver with property tests Re-delegation to max depth, estate-of-delegator counting, per-estate cap on the final holder, scope narrowing, direct override, revocation, uncast on cycle/depth/trim, same-estate-only switch, per-person public receipts. No stake function; weights are caller-supplied. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 + packages/heimdall-delegation/Cargo.lock | 7 + packages/heimdall-delegation/Cargo.toml | 10 + packages/heimdall-delegation/README.md | 29 ++ packages/heimdall-delegation/src/lib.rs | 270 +++++++++++++ .../heimdall-delegation/tests/properties.rs | 368 ++++++++++++++++++ 6 files changed, 686 insertions(+) create mode 100644 packages/heimdall-delegation/Cargo.lock create mode 100644 packages/heimdall-delegation/Cargo.toml create mode 100644 packages/heimdall-delegation/README.md create mode 100644 packages/heimdall-delegation/src/lib.rs create mode 100644 packages/heimdall-delegation/tests/properties.rs diff --git a/.gitignore b/.gitignore index 6d0f1f77..b033eb4c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/packages/heimdall-delegation/Cargo.lock b/packages/heimdall-delegation/Cargo.lock new file mode 100644 index 00000000..e706d732 --- /dev/null +++ b/packages/heimdall-delegation/Cargo.lock @@ -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" diff --git a/packages/heimdall-delegation/Cargo.toml b/packages/heimdall-delegation/Cargo.toml new file mode 100644 index 00000000..d57b3ab4 --- /dev/null +++ b/packages/heimdall-delegation/Cargo.toml @@ -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" diff --git a/packages/heimdall-delegation/README.md b/packages/heimdall-delegation/README.md new file mode 100644 index 00000000..40ac8cad --- /dev/null +++ b/packages/heimdall-delegation/README.md @@ -0,0 +1,29 @@ +# 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: 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. +- Input order never matters; duplicate roster entries, duplicate direct votes + and overlapping delegations from one person are input errors. diff --git a/packages/heimdall-delegation/src/lib.rs b/packages/heimdall-delegation/src/lib.rs new file mode 100644 index 00000000..adc7bfbc --- /dev/null +++ b/packages/heimdall-delegation/src/lib.rs @@ -0,0 +1,270 @@ +//! 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), +} + +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, + /// Revoked delegations are inert. + pub revoked: bool, +} + +#[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 holder's cap for the originator's estate was reached. + Trimmed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Direct { choice: Choice }, + Delegated { + holder: PersonId, + holder_estate: EstateId, + /// originator, delegates..., holder + chain: Vec, + /// 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, + pub outcome: Outcome, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Tally { + pub by_choice: BTreeMap, + /// estate -> choice -> weight, counted in the originator's estate. + pub by_estate: BTreeMap>, + pub cast: u64, + pub uncast: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Resolution { + /// Sorted by originator id. + pub receipts: Vec, + pub tally: Tally, +} + +enum Walk { + Direct(Choice), + Held { chain: Vec, 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 { + if config.cap.den == 0 { + return Err(InputError::ZeroCapDenominator); + } + let mut roster: BTreeMap = BTreeMap::new(); + for p in people { + if roster.insert(p.id, p).is_some() { + return Err(InputError::DuplicatePerson(p.id)); + } + } + let mut direct: BTreeMap = 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> = BTreeMap::new(); + for d in delegations.iter().filter(|d| !d.revoked) { + live.entry(d.from).or_default().push(d); + } + 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 = 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), + }; + receipts.push(Receipt { topic: topic.to_string(), originator: p.id, estate: p.estate, weight: p.weight, outcome }); + } + + // Cap: per (holder, originator estate), keep originators in ascending id + // order until the next would overflow; the rest are trimmed. + 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 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.weight; + *tally.by_estate.entry(r.estate).or_default().entry(choice.clone()).or_default() += r.weight; + tally.cast += r.weight; + } + Outcome::Uncast(_) => tally.uncast += r.weight, + } + } + Ok(Resolution { receipts, tally }) +} diff --git a/packages/heimdall-delegation/tests/properties.rs b/packages/heimdall-delegation/tests/properties.rs new file mode 100644 index 00000000..3f86c026 --- /dev/null +++ b/packages/heimdall-delegation/tests/properties.rs @@ -0,0 +1,368 @@ +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(&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, + delegations: Vec, + votes: Vec, +} + +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: false } +} +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, delegations: Vec, votes: Vec) -> 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 = (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, + }), + _ => { + // 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 }); + } + } + } + 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 { + (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::(), 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 = true; + } + let mut removed = w.clone(); + removed.delegations.retain(|d| d.from % 2 != 0); + assert_eq!(revoked.resolve(), removed.resolve(), "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_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. + 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(); + 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. + assert_eq!(r.tally.by_estate[&1]["yes"], 1); + assert_eq!(r.tally.uncast, 8); +} + +#[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(); + let held: Vec = r + .receipts + .iter() + .filter(|x| matches!(x.outcome, Outcome::Delegated { .. })) + .map(|x| x.originator) + .collect(); + assert_eq!(held, vec![1, 6]); +} + +#[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}"); + } + } +} + +#[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); +} -- 2.47.3 From e2a39d8aefd6adc532d6c8b235fca669c8d38a32 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 5 Nov 2026 05:26:37 +0000 Subject: [PATCH 2/3] heimdall-delegation: pro-rata cap, revocation time on receipts, prior art 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. 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 --- packages/heimdall-delegation/README.md | 76 +++++- packages/heimdall-delegation/src/lib.rs | 96 ++++++-- .../heimdall-delegation/tests/properties.rs | 223 ++++++++++++++---- 3 files changed, 324 insertions(+), 71 deletions(-) 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] -- 2.47.3 From 5cb5f35dccebfc9c170588a2eb652faa804ac308 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 5 Nov 2026 09:41:42 +0000 Subject: [PATCH 3/3] heimdall-delegation: README caveat that the cap does not bind under ballot copying Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-delegation/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/heimdall-delegation/README.md b/packages/heimdall-delegation/README.md index bf0eca38..bfe920f6 100644 --- a/packages/heimdall-delegation/README.md +++ b/packages/heimdall-delegation/README.md @@ -27,6 +27,13 @@ any CultMesh projection. 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 -- 2.47.3