heimdall-stake: stake function and splitting-gain report (Rust, zero deps) #9
9 changed files with 572 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
7
packages/heimdall-stake/Cargo.lock
generated
Normal file
7
packages/heimdall-stake/Cargo.lock
generated
Normal file
|
|
@ -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"
|
||||
10
packages/heimdall-stake/Cargo.toml
Normal file
10
packages/heimdall-stake/Cargo.toml
Normal file
|
|
@ -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"
|
||||
100
packages/heimdall-stake/README.md
Normal file
100
packages/heimdall-stake/README.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# 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.
|
||||
|
||||
## 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
|
||||
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.
|
||||
11
packages/heimdall-stake/examples/first_call.rs
Normal file
11
packages/heimdall-stake/examples/first_call.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
185
packages/heimdall-stake/src/lib.rs
Normal file
185
packages/heimdall-stake/src/lib.rs
Normal file
|
|
@ -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<Weight>,
|
||||
/// 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<Weight, ParamError> {
|
||||
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<Weight, ParamError> {
|
||||
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<SplitReport, ParamError> {
|
||||
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<Vec<SplitReport>, 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<SplitReport, ParamError> {
|
||||
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)
|
||||
}
|
||||
30
packages/heimdall-stake/tests/golden.rs
Normal file
30
packages/heimdall-stake/tests/golden.rs
Normal file
|
|
@ -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<i64> = (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");
|
||||
}
|
||||
198
packages/heimdall-stake/tests/properties.rs
Normal file
198
packages/heimdall-stake/tests/properties.rs
Normal file
|
|
@ -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<i64> = (0..60).map(|_| random_amount(&mut r)).collect();
|
||||
amounts.sort();
|
||||
let ws: Vec<Weight> = 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<i64> = (0..k).map(|i| (budget as u64 / k + u64::from(i < budget as u64 % k)) as i64).collect();
|
||||
assert_eq!(shares.iter().sum::<i64>(), 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
|
||||
}
|
||||
29
packages/heimdall-stake/tests/vectors/stake_golden.csv
Normal file
29
packages/heimdall-stake/tests/vectors/stake_golden.csv
Normal file
|
|
@ -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
|
||||
|
Loading…
Reference in a new issue