heimdall-stake: stake function and splitting-gain report (Rust, zero deps) #9

Merged
libby merged 2 commits from libby/heimdall-stake into main 2026-11-06 04:34:33 +00:00
9 changed files with 572 additions and 0 deletions

2
.gitignore vendored
View file

@ -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
View 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"

View 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"

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

View 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);
}
}

View 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)
}

View 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");
}

View 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
}

View 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
1 # kind,base,unit_cents,amount_cents,k,expected_micro
2 # single: weight of one identity holding amount. split: budget (amount) split evenly over k identities (first amount%k hold one extra cent), summed.
3 # expected values computed independently with 60-digit decimal ln, rounded half up. No floor, cap or minimum.
4 single,10,100,100,1,1000000
5 single,10,100,1000,1,2000000
6 single,10,100,10000,1,3000000
7 single,10,100,1000000,1,5000000
8 single,10,100,50,1,698970
9 single,10,100,10,1,0
10 single,10,100,1,1,0
11 single,2,100,400,1,3000000
12 single,2,100,100,1,1000000
13 single,2,1,1,1,1000000
14 single,3,500,123456,1,6014537
15 single,7,1,987654321,1,11643268
16 single,10,1,1,1,1000000
17 single,16,250,99999,1,3160960
18 split,10,100,10000,1,3000000
19 split,10,100,10000,2,5397940
20 split,10,100,10000,10,20000000
21 split,10,100,10000,100,100000000
22 split,2,100,10000,1,7643856
23 split,2,100,10000,2,13287712
24 split,2,100,10000,10,43219280
25 split,2,100,10000,100,100000000
26 split,10,100,100000,1,4000000
27 split,10,100,100000,2,7397940
28 split,10,100,100000,10,30000000
29 split,10,100,100000,100,200000000