#[derive(Debug, Clone)]
pub struct TickAllocation {
pub max_divisor: u64,
pub min_divisor: u64,
pub curve: AllocationCurve,
}
#[derive(Debug, Clone)]
pub enum AllocationCurve {
Linear,
Exponential {
exponent: f64,
},
Stepped {
thresholds: Vec<(f64, u64)>,
},
}
impl TickAllocation {
#[must_use]
pub fn new() -> Self {
Self {
max_divisor: 20,
min_divisor: 1,
curve: AllocationCurve::Exponential { exponent: 2.0 },
}
}
#[must_use]
pub fn linear(min_divisor: u64, max_divisor: u64) -> Self {
Self {
max_divisor: max_divisor.max(1),
min_divisor: min_divisor.max(1),
curve: AllocationCurve::Linear,
}
}
#[must_use]
pub fn exponential(min_divisor: u64, max_divisor: u64, exponent: f64) -> Self {
Self {
max_divisor: max_divisor.max(1),
min_divisor: min_divisor.max(1),
curve: AllocationCurve::Exponential {
exponent: exponent.max(0.1),
},
}
}
#[must_use]
pub fn stepped(thresholds: Vec<(f64, u64)>) -> Self {
for window in thresholds.windows(2) {
assert!(
window[0].0 >= window[1].0,
"Stepped thresholds must be sorted descending: {} >= {} violated",
window[0].0,
window[1].0,
);
}
let max_divisor = thresholds
.iter()
.map(|(_, d)| *d)
.max()
.unwrap_or(20)
.max(1);
let min_divisor = thresholds.iter().map(|(_, d)| *d).min().unwrap_or(1).max(1);
Self {
max_divisor,
min_divisor,
curve: AllocationCurve::Stepped { thresholds },
}
}
#[must_use]
pub fn divisor_for(&self, probability: f64) -> u64 {
let prob = probability.clamp(0.0, 1.0);
let min = self.min_divisor.max(1);
let max = self.max_divisor.max(min);
let raw = match &self.curve {
AllocationCurve::Linear => {
let range = (max - min) as f64;
max as f64 - range * prob
}
AllocationCurve::Exponential { exponent } => {
let range = (max - min) as f64;
min as f64 + range * (1.0 - prob).powf(*exponent)
}
AllocationCurve::Stepped { thresholds } => {
for &(threshold, divisor) in thresholds {
if prob >= threshold {
return divisor.clamp(min, max);
}
}
max as f64
}
};
(raw.round() as u64).clamp(min, max)
}
}
impl Default for TickAllocation {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_probability_one_returns_min() {
let alloc = TickAllocation::new();
assert_eq!(alloc.divisor_for(1.0), 1);
}
#[test]
fn default_probability_zero_returns_max() {
let alloc = TickAllocation::new();
assert_eq!(alloc.divisor_for(0.0), 20);
}
#[test]
fn monotonically_decreasing() {
let alloc = TickAllocation::new();
let mut prev = u64::MAX;
for i in 0..=100 {
let prob = i as f64 / 100.0;
let div = alloc.divisor_for(prob);
assert!(
div <= prev,
"not monotonic at prob={prob}: div={div}, prev={prev}"
);
prev = div;
}
}
#[test]
fn linear_curve() {
let alloc = TickAllocation::linear(1, 20);
assert_eq!(alloc.divisor_for(1.0), 1);
assert_eq!(alloc.divisor_for(0.0), 20);
assert_eq!(alloc.divisor_for(0.5), 11);
}
#[test]
fn linear_monotonic() {
let alloc = TickAllocation::linear(1, 100);
let mut prev = u64::MAX;
for i in 0..=100 {
let prob = i as f64 / 100.0;
let div = alloc.divisor_for(prob);
assert!(div <= prev);
prev = div;
}
}
#[test]
fn exponential_curve() {
let alloc = TickAllocation::exponential(1, 20, 2.0);
assert_eq!(alloc.divisor_for(1.0), 1);
assert_eq!(alloc.divisor_for(0.0), 20);
assert_eq!(alloc.divisor_for(0.5), 6);
}
#[test]
fn exponential_monotonic() {
let alloc = TickAllocation::exponential(1, 20, 2.0);
let mut prev = u64::MAX;
for i in 0..=100 {
let prob = i as f64 / 100.0;
let div = alloc.divisor_for(prob);
assert!(div <= prev);
prev = div;
}
}
#[test]
fn exponential_default_table() {
let alloc = TickAllocation::new();
assert_eq!(alloc.divisor_for(0.50), 6);
assert_eq!(alloc.divisor_for(0.30), 10);
assert_eq!(alloc.divisor_for(0.05), 18);
}
#[test]
fn stepped_curve() {
let alloc = TickAllocation::stepped(vec![(0.30, 1), (0.10, 2), (0.03, 5), (0.00, 20)]);
assert_eq!(alloc.divisor_for(0.50), 1); assert_eq!(alloc.divisor_for(0.31), 1); assert_eq!(alloc.divisor_for(0.20), 2); assert_eq!(alloc.divisor_for(0.05), 5); assert_eq!(alloc.divisor_for(0.01), 20); }
#[test]
fn stepped_first_match_wins() {
let alloc = TickAllocation::stepped(vec![(0.50, 1), (0.25, 5), (0.00, 10)]);
assert_eq!(alloc.divisor_for(0.60), 1);
}
#[test]
fn stepped_threshold_is_inclusive() {
let alloc = TickAllocation::stepped(vec![(0.30, 1), (0.10, 2), (0.00, 20)]);
assert_eq!(alloc.divisor_for(0.30), 1);
assert_eq!(alloc.divisor_for(0.10), 2);
assert_eq!(alloc.divisor_for(0.00), 20);
}
#[test]
#[should_panic(expected = "sorted descending")]
fn stepped_panics_on_unsorted() {
let _ = TickAllocation::stepped(vec![
(0.10, 2), (0.30, 1),
(0.00, 20),
]);
}
#[test]
fn clamps_to_range() {
let alloc = TickAllocation::exponential(2, 15, 1.0);
assert!(alloc.divisor_for(1.0) >= 2);
assert!(alloc.divisor_for(0.0) <= 15);
assert!(alloc.divisor_for(1.5) >= 2); assert!(alloc.divisor_for(-0.5) <= 15); }
#[test]
fn all_curves_in_range() {
let curves: Vec<TickAllocation> = vec![
TickAllocation::linear(1, 20),
TickAllocation::exponential(1, 20, 2.0),
TickAllocation::stepped(vec![(0.5, 1), (0.0, 20)]),
];
for alloc in &curves {
for i in 0..=100 {
let prob = i as f64 / 100.0;
let div = alloc.divisor_for(prob);
assert!(
div >= alloc.min_divisor && div <= alloc.max_divisor,
"out of range: div={div}, min={}, max={}, prob={prob}",
alloc.min_divisor,
alloc.max_divisor,
);
}
}
}
#[test]
fn default_impl() {
let alloc = TickAllocation::default();
assert_eq!(alloc.max_divisor, 20);
assert_eq!(alloc.min_divisor, 1);
}
#[test]
fn empty_stepped_returns_max() {
let alloc = TickAllocation::stepped(vec![]);
let div = alloc.divisor_for(0.5);
assert_eq!(div, alloc.max_divisor);
}
#[test]
fn max_divisor_one_always_returns_one() {
let linear = TickAllocation::linear(1, 1);
let exp = TickAllocation::exponential(1, 1, 2.0);
for prob in [0.0, 0.25, 0.5, 0.75, 1.0] {
let d_lin = linear.divisor_for(prob);
let d_exp = exp.divisor_for(prob);
eprintln!("prob={prob}: linear={d_lin}, exponential={d_exp}");
assert_eq!(
d_lin, 1,
"linear with max=1 should return 1 for prob={prob}"
);
assert_eq!(
d_exp, 1,
"exponential with max=1 should return 1 for prob={prob}"
);
}
}
#[test]
fn exponential_high_exponent_concentrates_budget() {
let steep = TickAllocation::exponential(1, 100, 5.0);
let shallow = TickAllocation::exponential(1, 100, 1.0);
let p = 0.5;
let d_steep = steep.divisor_for(p);
let d_shallow = shallow.divisor_for(p);
eprintln!("p={p}: steep(exp=5)={d_steep}, shallow(exp=1)={d_shallow}");
assert!(
d_steep < d_shallow,
"steep exponent should give lower divisor (more budget) for p={p}"
);
}
}