From ae33ba484246cebc7f851d619f95549a74a5fc10 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 5 Nov 2026 11:43:03 +0000 Subject: [PATCH 1/2] heimdall-stake: stake function and splitting-gain report (Rust, zero deps) weight = 1 + log_b(amount/unit) in integer cents and micro-weights with an integer-only log, plus floor, per-identity cap and minimum amount per identity; splitting_gain/curve report what splitting a budget over k identities does. 11 property tests. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 + packages/heimdall-stake/Cargo.lock | 7 + packages/heimdall-stake/Cargo.toml | 10 + packages/heimdall-stake/README.md | 89 ++++++++ .../heimdall-stake/examples/first_call.rs | 11 + packages/heimdall-stake/src/lib.rs | 185 ++++++++++++++++ packages/heimdall-stake/tests/properties.rs | 198 ++++++++++++++++++ 7 files changed, 502 insertions(+) create mode 100644 packages/heimdall-stake/Cargo.lock create mode 100644 packages/heimdall-stake/Cargo.toml create mode 100644 packages/heimdall-stake/README.md create mode 100644 packages/heimdall-stake/examples/first_call.rs create mode 100644 packages/heimdall-stake/src/lib.rs create mode 100644 packages/heimdall-stake/tests/properties.rs diff --git a/.gitignore b/.gitignore index b033eb4c..30d5f28e 100644 --- a/.gitignore +++ b/.gitignore @@ -324,6 +324,8 @@ node_modules/ !packages/cultmesh-kotlin/** !packages/heimdall-delegation/ !packages/heimdall-delegation/** +!packages/heimdall-stake/ +!packages/heimdall-stake/** packages/**/node_modules/ packages/**/dist/ packages/**/dist-test/ diff --git a/packages/heimdall-stake/Cargo.lock b/packages/heimdall-stake/Cargo.lock new file mode 100644 index 00000000..e51417db --- /dev/null +++ b/packages/heimdall-stake/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-stake" +version = "0.1.0" diff --git a/packages/heimdall-stake/Cargo.toml b/packages/heimdall-stake/Cargo.toml new file mode 100644 index 00000000..19fe9296 --- /dev/null +++ b/packages/heimdall-stake/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "heimdall-stake" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +name = "heimdall_stake" +path = "src/lib.rs" diff --git a/packages/heimdall-stake/README.md b/packages/heimdall-stake/README.md new file mode 100644 index 00000000..03b8dc32 --- /dev/null +++ b/packages/heimdall-stake/README.md @@ -0,0 +1,89 @@ +# heimdall-stake + +Heimdall's stake function: how much weight an amount of money buys one identity. Pure, +deterministic, no I/O, no dependencies; integer cents in, integer micro-weights out +(`MICRO` = 1,000,000 = a weight of 1.0), and an integer-only logarithm, so the same inputs give +the same bits on every platform. Entry points: `weight` and `splitting_gain` in `src/lib.rs`. +Live: the formula, the three Heimdall parameters, the splitting-gain report and their property +tests (`tests/properties.rs`). Not here: the estate rule that normalizes weights, the sybil +adversary, storage, and any projection. Every parameter is a placeholder until the estates rule. + +## First call + +``` +cargo run --example first_call # prints the Meta example and a splitting table +cargo test +``` + +```rust +use heimdall_stake::*; +let p = Params::meta_example(); // unit 1 EUR, log10 +assert_eq!(weight(&p, 100_00).unwrap(), 3 * MICRO); // 100 EUR +assert_eq!(weight(&p, 10_000_00).unwrap(), 5 * MICRO); // 10,000 EUR +let costly = Params { min_amount_per_identity_cents: 50_00, ..p }; +let r = splitting_gain(&costly, 100_00, 3).unwrap(); // 100 EUR as three identities of 33.33 +assert_eq!((r.one_identity, r.split_total), (3 * MICRO, 0)); +``` + +The output feeds `heimdall-delegation`'s integer `weight`; the caller picks the scale. + +## Formula + +Per Meta, `weight = 1 + log_b(amount / unit)`, with the base `b` and the `unit` set by the charter. +Meta's example: unit 1 EUR, `b` = 10: 100 EUR weighs 3, 10,000 EUR weighs 5 (both tested exactly). +Amounts are integer cents; negative and zero amounts weigh 0. The result is clamped at 0 (an +amount under `unit / b` would otherwise go negative) and rounded to the nearest micro-weight. + +Heimdall's parameters on top: + +- `floor_cents`: an amount **at or below** the floor weighs 0. (The brief said both "below" and + "at/below"; at-or-below is implemented and tested.) +- `per_identity_cap`: the most weight one identity can hold, in micro-weights; `None` is uncapped. +- `min_amount_per_identity_cents`: an identity holding **less** than this is ineligible and weighs 0. + +`floor` and `min_amount_per_identity` both zero a weight and compose; they differ in what they are +for (the floor trims dust from the formula, the minimum prices an identity), and the estates may +want one parameter instead of two. + +Properties (tested): monotone in the amount for any parameters; concave above the unit when no +floor or minimum intrudes (the floor and minimum are steps, so the whole function is not concave); +zero at and below the floor; the cap is never exceeded and binds; deterministic; total over every +`i64` including `i64::MIN`; the integer log agrees with `f64` to 2 micro-weights; bad parameters +(`base < 2`, `unit == 0`) are `Err`, not panics. + +## Splitting gain + +`splitting_gain(params, budget_cents, k)` puts one budget in one identity and, separately, evenly +over `k` identities, and reports both totals, the gain, how many split identities are eligible and +the smallest share. `splitting_curve` and `most_profitable_split` sweep `k`. + +The log formula's `+1` is a splitting incentive: with no minimum, 100 EUR as ten identities of +10 EUR weighs 20 against 3 as one. The minimum is the only lever that prices it, and it prices it +as a cliff, not a slope: with a 50 EUR minimum, 100 EUR split in two still gains (5.40 against 3.00 +in the example), and split in three weighs 0. So the minimum has to sit near the size of a typical +budget to deter splitting, and the report is how to see where it does. + +## Where this sits in the estate rule + +This crate does not normalize. The estate rule normalizes weights above it; the weights here are +raw. A sybil rate (how many identities an adversary splits a budget into) is an adversary the +simulation harness plays, not a parameter of this function; `splitting_gain` is its closed-form +probe for the even-split case only. + +## Prior art + +Sources below are from background knowledge and were not re-fetched for this README; verify before +citing. + +- **Quadratic voting** (Lalley and Weyl, "Quadratic Voting: How Mechanism Design Can Radicalize + Democracy", 2018; Posner and Weyl, *Radical Markets*, 2018): the cost of v votes is v², so the + marginal price rises linearly and vote weight grows with the square root of spend. Lalley-Weyl + show it is efficient in large populations under assumptions that include no collusion and no + identity splitting, and that identity splitting is exactly what breaks it. Our weight grows + slower still (log), so the money buys less influence at the top, and the same sybil caveat applies + with the same remedy: a cost per identity. +- **Logarithmic weight** has the same shape as diminishing-returns utility (Bernoulli) and as the + concave caps in some stake-weighted systems; here its role is only concavity, with the charter + choosing how fast through the base. +- **Sybil attack** (Douceur, 2002): without a cost per identity, an open system cannot tell one + participant from many. The per-identity minimum is that cost, in money. diff --git a/packages/heimdall-stake/examples/first_call.rs b/packages/heimdall-stake/examples/first_call.rs new file mode 100644 index 00000000..80275375 --- /dev/null +++ b/packages/heimdall-stake/examples/first_call.rs @@ -0,0 +1,11 @@ +use heimdall_stake::*; +fn main() { + let p = Params::meta_example(); + println!("100 EUR -> {}", weight(&p, 100_00).unwrap() as f64 / MICRO as f64); + println!("10,000 EUR -> {}", weight(&p, 10_000_00).unwrap() as f64 / MICRO as f64); + let costly = Params { min_amount_per_identity_cents: 50_00, ..p }; + for k in [1, 2, 3, 10] { + let r = splitting_gain(&costly, 100_00, k).unwrap(); + println!("100 EUR in {k:>2} identities: {:.2} vs {:.2} (gain {:+.2})", r.split_total as f64 / 1e6, r.one_identity as f64 / 1e6, r.gain as f64 / 1e6); + } +} diff --git a/packages/heimdall-stake/src/lib.rs b/packages/heimdall-stake/src/lib.rs new file mode 100644 index 00000000..4e168f34 --- /dev/null +++ b/packages/heimdall-stake/src/lib.rs @@ -0,0 +1,185 @@ +//! Heimdall's stake function. See README.md for the formula and its placeholders. +//! +//! `weight = 1 + log_b(amount / unit)`, with a floor, a per-identity cap and a minimum amount per +//! identity. Pure and integer-only: amounts are integer cents, weights are integer micro-weights +//! (`MICRO` = 1.0), and the logarithm is computed in fixed point with no floating point, so the +//! same inputs give the same bits on every platform. + +use std::fmt; + +/// Weight in millionths: `MICRO` is a weight of 1.0. Feeds `heimdall-delegation`'s integer weights +/// after the caller picks a scale. +pub type Weight = u64; +pub const MICRO: Weight = 1_000_000; + +/// All of these are placeholders until the estates rule; the charter sets `base` and `unit_cents`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Params { + /// Logarithm base `b`, an integer of at least 2. + pub base: u32, + /// The amount worth weight 1.0, in cents; at least 1. + pub unit_cents: u64, + /// Amounts at or below this weigh 0. + pub floor_cents: u64, + /// Highest weight one identity can hold, in micro-weights; `None` is uncapped. + pub per_identity_cap: Option, + /// An identity holding less than this is not eligible and weighs 0 (the splitting cost). + pub min_amount_per_identity_cents: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParamError { + BaseBelowTwo, + UnitZero, +} + +impl fmt::Display for ParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParamError::BaseBelowTwo => write!(f, "log base must be at least 2"), + ParamError::UnitZero => write!(f, "unit must be at least 1 cent"), + } + } +} + +impl std::error::Error for ParamError {} + +impl Params { + /// Meta's example: unit 1 EUR, log10, no floor, no cap, no minimum. + pub fn meta_example() -> Params { + Params { base: 10, unit_cents: 100, floor_cents: 0, per_identity_cap: None, min_amount_per_identity_cents: 0 } + } + + pub fn validate(&self) -> Result<(), ParamError> { + if self.base < 2 { + return Err(ParamError::BaseBelowTwo); + } + if self.unit_cents == 0 { + return Err(ParamError::UnitZero); + } + Ok(()) + } +} + +const FRAC: u32 = 40; + +/// log2(n) for n >= 1 in Q40 fixed point, by the square-and-compare method: no floats, so it is +/// the same everywhere. Error is below 2^-38. +fn log2_q40(n: u64) -> i128 { + let k = 63 - n.leading_zeros(); + let mut m: u128 = if k >= 60 { (n >> (k - 60)) as u128 } else { (n as u128) << (60 - k) }; // [1,2) in Q60 + let mut frac: u128 = 0; + for bit in (0..FRAC).rev() { + m = (m * m) >> 60; + if m >= 1u128 << 61 { + m >>= 1; + frac |= 1u128 << bit; + } + } + ((k as i128) << FRAC) | frac as i128 +} + +/// Weight of one identity holding `amount_cents`. Zero for zero and negative amounts, for amounts at +/// or below the floor, and for amounts under the per-identity minimum. Never panics on any input. +pub fn weight(params: &Params, amount_cents: i64) -> Result { + params.validate()?; + if amount_cents <= 0 { + return Ok(0); + } + let amount = amount_cents as u64; + if amount <= params.floor_cents || amount < params.min_amount_per_identity_cents { + return Ok(0); + } + // micro = 1e6 * (1 + (log2 a - log2 u) / log2 b), rounded to nearest. + let num = (log2_q40(amount) - log2_q40(params.unit_cents)) * MICRO as i128; + let den = log2_q40(params.base as u64); + let half = den / 2; + let rounded = if num >= 0 { (num + half) / den } else { (num - half) / den }; + let raw = (MICRO as i128 + rounded).max(0) as u128; + let capped = match params.per_identity_cap { + Some(cap) => raw.min(cap as u128), + None => raw, + }; + Ok(capped.min(Weight::MAX as u128) as Weight) +} + +/// Total weight of several identities, each weighed on its own (the cap and minimum are per identity). +pub fn total_weight(params: &Params, amounts_cents: &[i64]) -> Result { + params.validate()?; + let mut total: Weight = 0; + for &a in amounts_cents { + total = total.saturating_add(weight(params, a)?); + } + Ok(total) +} + +/// One budget held as a single identity against the same budget split evenly over `k` identities +/// (the first `budget % k` identities hold one extra cent). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitReport { + pub budget_cents: i64, + pub k: u64, + pub one_identity: Weight, + pub split_total: Weight, + /// `split_total - one_identity`: positive means splitting pays. + pub gain: i128, + /// Identities of the split that clear the floor and the minimum. + pub eligible_identities: u64, + /// Smallest amount any identity of the split holds. + pub smallest_share_cents: i64, +} + +/// What splitting `budget_cents` over `k` identities does to total weight. `k` of 0 is treated as 1. +pub fn splitting_gain(params: &Params, budget_cents: i64, k: u64) -> Result { + params.validate()?; + let k = k.max(1); + let one_identity = weight(params, budget_cents)?; + let (base_share, extra, smallest) = if budget_cents <= 0 { + (0i64, 0u64, budget_cents) + } else { + let b = budget_cents as u64; + ((b / k) as i64, b % k, (b / k) as i64) + }; + // Identities are of two sizes, so weigh the two sizes instead of looping over k. + let mut split_total: u128 = 0; + let mut eligible = 0u64; + let big = k.min(extra); + let small = k - big; + for (count, amount) in [(big, base_share.saturating_add(1)), (small, base_share)] { + if count == 0 { + continue; + } + let w = weight(params, amount)?; + if w > 0 { + eligible += count; + } + split_total += w as u128 * count as u128; + } + let split_total = split_total.min(Weight::MAX as u128) as Weight; + Ok(SplitReport { + budget_cents, + k, + one_identity, + split_total, + gain: split_total as i128 - one_identity as i128, + eligible_identities: eligible, + smallest_share_cents: smallest, + }) +} + +/// `splitting_gain` for k = 1..=max_k. +pub fn splitting_curve(params: &Params, budget_cents: i64, max_k: u64) -> Result, ParamError> { + (1..=max_k.max(1)).map(|k| splitting_gain(params, budget_cents, k)).collect() +} + +/// The k in 1..=max_k with the largest split total (smallest k on ties), and its report. +pub fn most_profitable_split(params: &Params, budget_cents: i64, max_k: u64) -> Result { + let mut best = splitting_gain(params, budget_cents, 1)?; + for k in 2..=max_k { + let r = splitting_gain(params, budget_cents, k)?; + if r.split_total > best.split_total { + best = r; + } + } + Ok(best) +} diff --git a/packages/heimdall-stake/tests/properties.rs b/packages/heimdall-stake/tests/properties.rs new file mode 100644 index 00000000..7e450fbd --- /dev/null +++ b/packages/heimdall-stake/tests/properties.rs @@ -0,0 +1,198 @@ +use heimdall_stake::*; + +/// 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 random_params(r: &mut Rng) -> Params { + Params { + base: 2 + r.below(15) as u32, + unit_cents: 1 + r.below(100_000), + floor_cents: if r.below(2) == 0 { 0 } else { r.below(50_000) }, + per_identity_cap: if r.below(2) == 0 { None } else { Some(MICRO + r.below(8 * MICRO)) }, + min_amount_per_identity_cents: if r.below(2) == 0 { 0 } else { r.below(20_000) }, + } +} + +/// Log-uniform amounts in cents, so every magnitude is exercised. +fn random_amount(r: &mut Rng) -> i64 { + let bits = 1 + r.below(40); + (r.next() >> (64 - bits)) as i64 +} + +#[test] +fn meta_example_unit_one_euro_log10() { + let p = Params::meta_example(); + assert_eq!(weight(&p, 100_00).unwrap(), 3 * MICRO); // 100 EUR + assert_eq!(weight(&p, 10_000_00).unwrap(), 5 * MICRO); // 10,000 EUR + assert_eq!(weight(&p, 100).unwrap(), MICRO); // 1 EUR = the unit = weight 1 + assert_eq!(weight(&p, 10_00).unwrap(), 2 * MICRO); // 10 EUR +} + +#[test] +fn base_two_and_other_units_hit_exact_powers() { + let p = Params { base: 2, unit_cents: 250, ..Params::meta_example() }; + assert_eq!(weight(&p, 250 * 8).unwrap(), 4 * MICRO); // 1 + log2(8) + let q = Params { base: 7, unit_cents: 1, ..Params::meta_example() }; + assert_eq!(weight(&q, 7u64.pow(9) as i64).unwrap(), 10 * MICRO); +} + +#[test] +fn integer_log_agrees_with_floating_point_to_two_micro() { + let mut r = Rng(0xC0FFEE); + for _ in 0..20_000 { + let p = Params { base: 2 + r.below(15) as u32, unit_cents: 1 + r.below(1_000_000), floor_cents: 0, per_identity_cap: None, min_amount_per_identity_cents: 0 }; + let a = 1 + random_amount(&mut r).abs(); + let expect = 1.0 + (a as f64 / p.unit_cents as f64).ln() / (p.base as f64).ln(); + let expect = (expect.max(0.0) * MICRO as f64).round(); + let got = weight(&p, a).unwrap() as f64; + assert!((got - expect).abs() <= 2.0, "base {} unit {} amount {a}: {got} vs {expect}", p.base, p.unit_cents); + } +} + +#[test] +fn monotone_in_amount_for_any_parameters() { + let mut r = Rng(1); + for _ in 0..300 { + let p = random_params(&mut r); + let mut amounts: Vec = (0..60).map(|_| random_amount(&mut r)).collect(); + amounts.sort(); + let ws: Vec = amounts.iter().map(|&a| weight(&p, a).unwrap()).collect(); + assert!(ws.windows(2).all(|w| w[0] <= w[1]), "{p:?} {amounts:?} {ws:?}"); + } +} + +#[test] +fn concave_above_the_unit_when_no_floor_or_minimum_intrudes() { + // Second differences of 1 + log_b(a/u) are non-positive; rounding to a micro-weight allows 2. + let mut r = Rng(2); + for _ in 0..300 { + let mut p = random_params(&mut r); + p.floor_cents = 0; + p.min_amount_per_identity_cents = 0; + let a = p.unit_cents as i64 + random_amount(&mut r) % 1_000_000_000; + let d = 1 + (r.below(1_000_000) as i64); + let (w0, w1, w2) = (weight(&p, a).unwrap() as i128, weight(&p, a + d).unwrap() as i128, weight(&p, a + 2 * d).unwrap() as i128); + assert!((w2 - w1) <= (w1 - w0) + 2, "{p:?} a {a} d {d}: {w0} {w1} {w2}"); + } +} + +#[test] +fn zero_at_and_below_the_floor_and_positive_just_above() { + let mut r = Rng(3); + for _ in 0..300 { + let mut p = random_params(&mut r); + p.min_amount_per_identity_cents = 0; + p.per_identity_cap = None; + p.floor_cents = 1 + r.below(1_000_000); + for a in [p.floor_cents as i64, p.floor_cents as i64 - 1, 1] { + assert_eq!(weight(&p, a).unwrap(), 0, "{p:?} amount {a}"); + } + // Far enough above both floor and unit that the formula is positive. + let above = (p.floor_cents.max(p.unit_cents) * 2) as i64; + assert!(weight(&p, above).unwrap() > 0, "{p:?} amount {above}"); + } +} + +#[test] +fn cap_is_never_exceeded_and_binds_when_the_formula_passes_it() { + let mut r = Rng(4); + for _ in 0..300 { + let mut p = random_params(&mut r); + let cap = MICRO + r.below(8 * MICRO); + p.per_identity_cap = Some(cap); + for _ in 0..40 { + assert!(weight(&p, random_amount(&mut r)).unwrap() <= cap); + } + let uncapped = Params { per_identity_cap: None, ..p }; + let big = i64::MAX; + if weight(&uncapped, big).unwrap() > cap && p.min_amount_per_identity_cents <= big as u64 { + assert_eq!(weight(&p, big).unwrap(), cap); + } + } +} + +#[test] +fn deterministic_and_total_over_every_input() { + let mut r = Rng(5); + for _ in 0..200 { + let p = random_params(&mut r); + for a in [i64::MIN, -1, 0, 1, i64::MAX, random_amount(&mut r)] { + assert_eq!(weight(&p, a), weight(&p, a)); + } + let budget = random_amount(&mut r); + assert_eq!(splitting_gain(&p, budget, 7), splitting_gain(&p, budget, 7)); + assert_eq!(splitting_curve(&p, budget, 5), splitting_curve(&p, budget, 5)); + } + let p = Params::meta_example(); + for a in [i64::MIN, -5, -1, 0] { + assert_eq!(weight(&p, a).unwrap(), 0); + } + assert!(weight(&p, i64::MAX).unwrap() > 0); +} + +#[test] +fn bad_parameters_are_errors_not_panics() { + let mut p = Params::meta_example(); + p.base = 1; + assert_eq!(weight(&p, 100), Err(ParamError::BaseBelowTwo)); + p.base = 0; + assert_eq!(total_weight(&p, &[1]), Err(ParamError::BaseBelowTwo)); + p = Params { unit_cents: 0, ..Params::meta_example() }; + assert_eq!(splitting_gain(&p, 100, 2), Err(ParamError::UnitZero)); +} + +#[test] +fn splitting_report_matches_summing_the_shares_by_hand() { + let mut r = Rng(6); + for _ in 0..200 { + let p = random_params(&mut r); + let budget = 1 + random_amount(&mut r); + let k = 1 + r.below(40); + let rep = splitting_gain(&p, budget, k).unwrap(); + let shares: Vec = (0..k).map(|i| (budget as u64 / k + u64::from(i < budget as u64 % k)) as i64).collect(); + assert_eq!(shares.iter().sum::(), budget); + assert_eq!(rep.split_total, total_weight(&p, &shares).unwrap()); + assert_eq!(rep.one_identity, weight(&p, budget).unwrap()); + assert_eq!(rep.gain, rep.split_total as i128 - rep.one_identity as i128); + assert_eq!(rep.smallest_share_cents, *shares.iter().min().unwrap()); + assert_eq!(rep.eligible_identities, shares.iter().filter(|&&s| weight(&p, s).unwrap() > 0).count() as u64); + } +} + +#[test] +fn without_a_minimum_splitting_pays_and_with_one_it_stops_paying() { + // 100 EUR, unit 1 EUR, log10: one identity weighs 3. Ten identities of 10 EUR weigh 2 each = 20. + let free = Params::meta_example(); + let rep = splitting_gain(&free, 100_00, 10).unwrap(); + assert_eq!((rep.one_identity, rep.split_total), (3 * MICRO, 20 * MICRO)); + assert!(rep.gain > 0); + assert_eq!(most_profitable_split(&free, 100_00, 50).unwrap().k, rep_best_k(&free, 100_00, 50)); + + // Each identity must hold 50 EUR: two shares of 50 EUR are eligible, three of 33.33 are not. + let costly = Params { min_amount_per_identity_cents: 50_00, ..free }; + assert_eq!(splitting_gain(&costly, 100_00, 2).unwrap().eligible_identities, 2); + let three = splitting_gain(&costly, 100_00, 3).unwrap(); + assert_eq!((three.split_total, three.eligible_identities), (0, 0)); + assert_eq!(three.gain, -(3 * MICRO as i128)); + // Past the minimum the curve is dead flat at zero, so the best split is never past budget/min. + let best = most_profitable_split(&costly, 100_00, 200).unwrap(); + assert!(best.k <= 2 && best.split_total > 0); + for rep in splitting_curve(&costly, 100_00, 200).unwrap().iter().filter(|r| r.k > 2) { + assert_eq!(rep.split_total, 0); + } +} + +fn rep_best_k(p: &Params, budget: i64, max_k: u64) -> u64 { + splitting_curve(p, budget, max_k).unwrap().iter().max_by_key(|r| (r.split_total, std::cmp::Reverse(r.k))).unwrap().k +} -- 2.47.3 From 84f932ec506d859469ed2037f69d8ef3daf80f23 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 5 Nov 2026 21:27:41 +0000 Subject: [PATCH 2/2] heimdall-stake: sybil note and golden vectors README: the constant 1 in weight = 1 + log_b(amount/unit) is paid once per identity, so splitting always pays until the log goes negative and no base fixes it; the defence lives outside the function. Golden vectors (single and split k=1,2,10,100, three bases) computed with 60-digit decimal ln, pinned by tests/golden.rs. Co-Authored-By: Claude Sonnet 5.5 --- packages/heimdall-stake/README.md | 11 +++++++ packages/heimdall-stake/tests/golden.rs | 30 +++++++++++++++++++ .../tests/vectors/stake_golden.csv | 29 ++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 packages/heimdall-stake/tests/golden.rs create mode 100644 packages/heimdall-stake/tests/vectors/stake_golden.csv diff --git a/packages/heimdall-stake/README.md b/packages/heimdall-stake/README.md index 03b8dc32..5c8ae095 100644 --- a/packages/heimdall-stake/README.md +++ b/packages/heimdall-stake/README.md @@ -63,6 +63,17 @@ as a cliff, not a slope: with a 50 EUR minimum, 100 EUR split in two still gains in the example), and split in three weighs 0. So the minimum has to sit near the size of a typical budget to deter splitting, and the report is how to see where it does. +## Why the sybil defence cannot live here + +`weight = 1 + log_b(amount / unit)` pays the constant 1 once per identity. Splitting a budget into +k identities pays that 1 k times and costs only the logarithm's shrinkage, so splitting wins until +the log term goes negative: with unit 1 EUR and b = 10, 100 EUR weighs 3 as one identity, 5.40 as +two, 20 as ten and 100 as one hundred (`tests/vectors/stake_golden.csv`, pinned by +`tests/golden.rs`). No choice of `b` fixes this: `b` scales the log term and never the constant. +A cap or a per-identity minimum only moves the cliff (see Splitting gain). So the defence belongs +outside the function: non-transferable or vesting stake, a per-person cap on total stake, and a +cost to create an identity. This crate states the incentive; it cannot remove it. + ## Where this sits in the estate rule This crate does not normalize. The estate rule normalizes weights above it; the weights here are diff --git a/packages/heimdall-stake/tests/golden.rs b/packages/heimdall-stake/tests/golden.rs new file mode 100644 index 00000000..d742aa5a --- /dev/null +++ b/packages/heimdall-stake/tests/golden.rs @@ -0,0 +1,30 @@ +use heimdall_stake::*; + +const VECTORS: &str = include_str!("vectors/stake_golden.csv"); + +/// Pins `weight` and `total_weight` to values computed outside this crate (see the fixture header). +/// Split rows are the sybil argument in numbers: the same budget in k identities weighs more. +#[test] +fn golden_vectors() { + let mut rows = 0; + let mut split_rows = 0; + for line in VECTORS.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let f: Vec<&str> = line.split(',').collect(); + let (base, unit, amount, k, expected): (u32, u64, i64, usize, u64) = + (f[1].parse().unwrap(), f[2].parse().unwrap(), f[3].parse().unwrap(), f[4].parse().unwrap(), f[5].parse().unwrap()); + let p = Params { base, unit_cents: unit, floor_cents: 0, per_identity_cap: None, min_amount_per_identity_cents: 0 }; + let got = match f[0] { + "single" => weight(&p, amount).unwrap(), + "split" => { + split_rows += 1; + let (q, r) = (amount / k as i64, (amount % k as i64) as usize); + let shares: Vec = (0..k).map(|i| q + i64::from(i < r)).collect(); + total_weight(&p, &shares).unwrap() + } + other => panic!("unknown vector kind {other}"), + }; + assert_eq!(got, expected, "vector `{line}`"); + rows += 1; + } + assert!(rows >= 20 && split_rows >= 12, "fixture shrank: {rows} rows, {split_rows} split"); +} diff --git a/packages/heimdall-stake/tests/vectors/stake_golden.csv b/packages/heimdall-stake/tests/vectors/stake_golden.csv new file mode 100644 index 00000000..26f8978a --- /dev/null +++ b/packages/heimdall-stake/tests/vectors/stake_golden.csv @@ -0,0 +1,29 @@ +# kind,base,unit_cents,amount_cents,k,expected_micro +# single: weight of one identity holding amount. split: budget (amount) split evenly over k identities (first amount%k hold one extra cent), summed. +# expected values computed independently with 60-digit decimal ln, rounded half up. No floor, cap or minimum. +single,10,100,100,1,1000000 +single,10,100,1000,1,2000000 +single,10,100,10000,1,3000000 +single,10,100,1000000,1,5000000 +single,10,100,50,1,698970 +single,10,100,10,1,0 +single,10,100,1,1,0 +single,2,100,400,1,3000000 +single,2,100,100,1,1000000 +single,2,1,1,1,1000000 +single,3,500,123456,1,6014537 +single,7,1,987654321,1,11643268 +single,10,1,1,1,1000000 +single,16,250,99999,1,3160960 +split,10,100,10000,1,3000000 +split,10,100,10000,2,5397940 +split,10,100,10000,10,20000000 +split,10,100,10000,100,100000000 +split,2,100,10000,1,7643856 +split,2,100,10000,2,13287712 +split,2,100,10000,10,43219280 +split,2,100,10000,100,100000000 +split,10,100,100000,1,4000000 +split,10,100,100000,2,7397940 +split,10,100,100000,10,30000000 +split,10,100,100000,100,200000000 -- 2.47.3