use rust_decimal::{Decimal, RoundingStrategy};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
pub const ALLOCATION_DP: u32 = 6;
#[must_use]
pub fn allocation_share(raw: Decimal) -> Decimal {
raw.round_dp_with_strategy(ALLOCATION_DP, RoundingStrategy::ToZero)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum AllocationBasis {
Fraction,
#[default]
Proportional,
}
impl AllocationBasis {
pub const ALL: [Self; 2] = [Self::Fraction, Self::Proportional];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Fraction => "FRACTION",
Self::Proportional => "PROPORTIONAL",
}
}
}
crate::codes::string_codes! {
AllocationBasis;
}
#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
#[non_exhaustive]
pub enum AllocationError {
#[error("allocation fractions total {sum} — each must be positive and the total at most 1")]
InvalidFractions {
sum: Decimal,
},
#[error("proportional weight for {key} is {weight} — a weight cannot be negative")]
NegativeWeight {
key: String,
weight: Decimal,
},
#[error("capacity for {key} is {capacity} — a capacity cannot be negative")]
NegativeCapacity {
key: String,
capacity: Decimal,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AllocationPart {
pub key: String,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub weight: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_option"))]
pub capacity: Option<Decimal>,
}
impl AllocationPart {
#[must_use]
pub fn new(key: impl Into<String>, weight: Decimal) -> Self {
Self {
key: key.into(),
weight,
capacity: None,
}
}
#[must_use]
pub fn capped_at(mut self, capacity: Decimal) -> Self {
self.capacity = Some(capacity);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AllocatedPart {
pub key: String,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub weight: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub share: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub allocated: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_option"))]
pub capacity: Option<Decimal>,
}
impl AllocatedPart {
#[must_use]
pub fn capped(&self) -> bool {
self.allocated < self.share
}
#[must_use]
pub fn forgone(&self) -> Decimal {
(self.share - self.allocated).max(Decimal::ZERO)
}
#[must_use]
pub fn at_capacity(&self) -> bool {
self.capacity.is_some_and(|c| self.allocated >= c)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AllocationRow {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub total: Decimal,
pub parts: Vec<AllocatedPart>,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub residual: Decimal,
}
impl AllocationRow {
#[must_use]
pub fn allocated(&self) -> Decimal {
self.parts.iter().map(|p| p.allocated).sum()
}
#[must_use]
pub fn part(&self, key: &str) -> Option<&AllocatedPart> {
self.parts.iter().find(|p| p.key == key)
}
}
pub fn validate_key(
parts: &[AllocationPart],
basis: AllocationBasis,
) -> Result<(), AllocationError> {
for part in parts {
if let Some(capacity) = part.capacity
&& capacity < Decimal::ZERO
{
return Err(AllocationError::NegativeCapacity {
key: part.key.clone(),
capacity,
});
}
}
match basis {
AllocationBasis::Fraction => {
let sum: Decimal = parts.iter().map(|p| p.weight).sum();
if parts.iter().any(|p| p.weight <= Decimal::ZERO) || sum > Decimal::ONE {
return Err(AllocationError::InvalidFractions { sum });
}
}
AllocationBasis::Proportional => {
if let Some(part) = parts.iter().find(|p| p.weight < Decimal::ZERO) {
return Err(AllocationError::NegativeWeight {
key: part.key.clone(),
weight: part.weight,
});
}
}
}
Ok(())
}
pub fn allocate(
total: Decimal,
parts: Vec<AllocationPart>,
basis: AllocationBasis,
) -> Result<AllocationRow, AllocationError> {
validate_key(&parts, basis)?;
let weight_sum: Decimal = parts.iter().map(|p| p.weight).sum();
let allocated_parts: Vec<AllocatedPart> = parts
.into_iter()
.map(|part| {
let share = allocation_share(match basis {
AllocationBasis::Fraction => part.weight * total,
AllocationBasis::Proportional if weight_sum > Decimal::ZERO => {
part.weight * total / weight_sum
}
AllocationBasis::Proportional => Decimal::ZERO,
});
let allocated = cap(share, part.capacity);
AllocatedPart {
key: part.key,
weight: part.weight,
share,
allocated,
capacity: part.capacity,
}
})
.collect();
let allocated: Decimal = allocated_parts.iter().map(|p| p.allocated).sum();
Ok(AllocationRow {
total,
parts: allocated_parts,
residual: total - allocated,
})
}
pub fn allocate_cascading(
total: Decimal,
parts: Vec<AllocationPart>,
) -> Result<AllocationRow, AllocationError> {
validate_key(&parts, AllocationBasis::Proportional)?;
let n = parts.len();
let mut allocated = vec![Decimal::ZERO; n];
let mut nominal = vec![Decimal::ZERO; n];
let mut open = vec![true; n];
let mut pool = total.max(Decimal::ZERO);
let mut first_pass = true;
loop {
let weight_sum: Decimal = parts
.iter()
.zip(&open)
.filter(|(_, o)| **o)
.map(|(p, _)| p.weight)
.sum();
if pool <= Decimal::ZERO || weight_sum <= Decimal::ZERO {
break;
}
let mut moved = Decimal::ZERO;
for i in 0..n {
if first_pass {
nominal[i] = allocation_share(parts[i].weight * pool / weight_sum);
}
if !open[i] {
continue;
}
let offered = allocation_share(parts[i].weight * pool / weight_sum);
let headroom = match parts[i].capacity {
Some(c) => (c - allocated[i]).max(Decimal::ZERO),
None => offered,
};
let give = offered.min(headroom);
if give < offered {
open[i] = false;
}
allocated[i] += give;
moved += give;
}
first_pass = false;
if moved <= Decimal::ZERO {
break;
}
pool -= moved;
}
let taken: Decimal = allocated.iter().copied().sum();
Ok(AllocationRow {
total,
parts: parts
.into_iter()
.zip(nominal)
.zip(allocated)
.map(|((part, share), allocated)| AllocatedPart {
key: part.key,
weight: part.weight,
share,
allocated,
capacity: part.capacity,
})
.collect(),
residual: total - taken,
})
}
pub(crate) fn cap(share: Decimal, capacity: Option<Decimal>) -> Decimal {
match capacity {
Some(c) => share.min(c).max(Decimal::ZERO),
None => share.max(Decimal::ZERO),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
fn parts(spec: &[(&str, Decimal, Option<Decimal>)]) -> Vec<AllocationPart> {
spec.iter()
.map(|(k, w, c)| AllocationPart {
key: (*k).to_owned(),
weight: *w,
capacity: *c,
})
.collect()
}
#[test]
fn a_refused_share_is_re_offered_until_nothing_moves() {
let row = allocate_cascading(
dec!(9),
parts(&[
("a", dec!(1), Some(dec!(1))),
("b", dec!(1), Some(dec!(10))),
("c", dec!(1), Some(dec!(10))),
]),
)
.unwrap();
assert_eq!(row.part("a").unwrap().allocated, dec!(1));
assert_eq!(row.part("b").unwrap().allocated, dec!(4));
assert_eq!(row.part("c").unwrap().allocated, dec!(4));
assert_eq!(row.residual, Decimal::ZERO);
assert!(row.part("a").unwrap().at_capacity());
assert!(!row.part("b").unwrap().at_capacity());
}
#[test]
fn the_cascade_stops_at_the_ceilings_and_reports_the_rest() {
let row = allocate_cascading(
dec!(100),
parts(&[("a", dec!(1), Some(dec!(2))), ("b", dec!(1), Some(dec!(3)))]),
)
.unwrap();
assert_eq!(row.allocated(), dec!(5));
assert_eq!(row.residual, dec!(95));
assert!(row.parts.iter().all(AllocatedPart::at_capacity));
}
#[test]
fn an_uncapped_part_absorbs_the_pool_in_one_pass() {
let row = allocate_cascading(dec!(7), parts(&[("a", dec!(1), None)])).unwrap();
assert_eq!(row.part("a").unwrap().allocated, dec!(7));
assert_eq!(row.residual, Decimal::ZERO);
assert!(!row.part("a").unwrap().at_capacity());
}
#[test]
fn the_nominal_share_is_the_first_pass_offer() {
let row = allocate_cascading(
dec!(9),
parts(&[
("a", dec!(1), Some(dec!(1))),
("b", dec!(1), Some(dec!(10))),
("c", dec!(1), Some(dec!(10))),
]),
)
.unwrap();
for part in &row.parts {
assert_eq!(part.share, dec!(3), "{} nominal", part.key);
}
assert!(row.part("a").unwrap().capped());
assert_eq!(row.part("a").unwrap().forgone(), dec!(2));
assert!(!row.part("b").unwrap().capped());
assert_eq!(row.part("b").unwrap().forgone(), Decimal::ZERO);
}
#[test]
fn later_passes_use_the_agreed_weights_not_the_headroom() {
let row = allocate_cascading(
dec!(12),
parts(&[
("a", dec!(1), Some(dec!(0))),
("b", dec!(2), Some(dec!(40))),
("c", dec!(1), Some(dec!(10))),
]),
)
.unwrap();
assert_eq!(row.part("a").unwrap().allocated, Decimal::ZERO);
assert_eq!(row.part("b").unwrap().allocated, dec!(8));
assert_eq!(row.part("c").unwrap().allocated, dec!(4));
assert_eq!(row.residual, Decimal::ZERO);
}
#[test]
fn a_cascade_over_consumption_weighted_consumption_caps_changes_nothing() {
for pool in [dec!(3), dec!(6), dec!(20)] {
let spec: &[(&str, Decimal, Option<Decimal>)] =
&[("a", dec!(2), Some(dec!(2))), ("b", dec!(4), Some(dec!(4)))];
let once = allocate(pool, parts(spec), AllocationBasis::Proportional).unwrap();
let cascaded = allocate_cascading(pool, parts(spec)).unwrap();
for (a, b) in once.parts.iter().zip(&cascaded.parts) {
assert_eq!(a.allocated, b.allocated, "pool {pool}, part {}", a.key);
}
assert_eq!(once.residual, cascaded.residual, "pool {pool}");
}
}
#[test]
fn the_cascade_refuses_what_a_proportional_key_refuses() {
assert!(matches!(
allocate_cascading(dec!(1), parts(&[("a", dec!(-1), None)])),
Err(AllocationError::NegativeWeight { .. })
));
assert!(matches!(
allocate_cascading(dec!(1), parts(&[("a", dec!(1), Some(dec!(-1)))])),
Err(AllocationError::NegativeCapacity { .. })
));
}
#[test]
fn a_pool_or_key_with_nothing_in_it_allocates_nothing() {
let zero_weights =
allocate_cascading(dec!(5), parts(&[("a", Decimal::ZERO, None)])).unwrap();
assert_eq!(zero_weights.allocated(), Decimal::ZERO);
assert_eq!(zero_weights.residual, dec!(5));
let empty = allocate_cascading(dec!(5), Vec::new()).unwrap();
assert_eq!(empty.residual, dec!(5));
let negative = allocate_cascading(dec!(-5), parts(&[("a", dec!(1), None)])).unwrap();
assert_eq!(negative.allocated(), Decimal::ZERO);
assert_eq!(negative.residual, dec!(-5));
}
#[test]
fn three_equal_claims_exhaust_a_divisible_pool_exactly() {
let row = allocate(
dec!(9),
parts(&[
("a", dec!(4), None),
("b", dec!(4), None),
("c", dec!(4), None),
]),
AllocationBasis::Proportional,
)
.unwrap();
for part in &row.parts {
assert_eq!(
part.allocated,
dec!(3),
"{} took {}",
part.key,
part.allocated
);
}
assert_eq!(row.residual, Decimal::ZERO);
}
#[test]
fn proportional_splits_and_conserves() {
let row = allocate(
dec!(10),
parts(&[("a", dec!(1), None), ("b", dec!(2), None)]),
AllocationBasis::Proportional,
)
.unwrap();
assert_eq!(row.parts[0].allocated, dec!(3.333333));
assert_eq!(row.parts[1].allocated, dec!(6.666666));
assert_eq!(row.allocated() + row.residual, row.total);
}
#[test]
fn a_repeating_quotient_still_conserves() {
let row = allocate(
dec!(1),
parts(&[
("a", dec!(1), None),
("b", dec!(1), None),
("c", dec!(1), None),
]),
AllocationBasis::Proportional,
)
.unwrap();
assert_eq!(row.allocated(), dec!(0.999999));
assert_eq!(row.residual, dec!(0.000001));
assert_eq!(row.allocated() + row.residual, row.total);
}
#[test]
fn a_zero_weight_sum_leaves_everything_in_the_residual() {
let row = allocate(
dec!(7),
parts(&[("a", Decimal::ZERO, None), ("b", Decimal::ZERO, None)]),
AllocationBasis::Proportional,
)
.unwrap();
assert!(row.parts.iter().all(|p| p.share.is_zero()));
assert_eq!(row.residual, dec!(7));
}
#[test]
fn a_ceiling_moves_the_difference_to_the_residual() {
let row = allocate(
dec!(10),
parts(&[
("a", dec!(1), Some(dec!(2))),
("b", dec!(1), Some(dec!(99))),
]),
AllocationBasis::Proportional,
)
.unwrap();
assert_eq!(row.parts[0].allocated, dec!(2));
assert!(row.parts[0].capped());
assert_eq!(row.parts[0].forgone(), dec!(3));
assert_eq!(row.parts[1].allocated, dec!(5));
assert_eq!(row.residual, dec!(3));
assert_eq!(row.allocated() + row.residual, row.total);
}
#[test]
fn fractions_are_absolute_and_the_rest_is_residual() {
let row = allocate(
dec!(8),
parts(&[("a", dec!(0.25), None)]),
AllocationBasis::Fraction,
)
.unwrap();
assert_eq!(row.parts[0].allocated, dec!(2.00));
assert_eq!(row.residual, dec!(6.00));
}
#[test]
fn over_subscribed_fractions_are_refused() {
assert_eq!(
allocate(
dec!(8),
parts(&[("a", dec!(0.7), None), ("b", dec!(0.5), None)]),
AllocationBasis::Fraction,
),
Err(AllocationError::InvalidFractions { sum: dec!(1.2) })
);
}
#[test]
fn a_zero_fraction_is_refused() {
assert!(matches!(
allocate(
dec!(8),
parts(&[("a", Decimal::ZERO, None)]),
AllocationBasis::Fraction,
),
Err(AllocationError::InvalidFractions { .. })
));
}
#[test]
fn a_negative_proportional_weight_is_refused() {
assert_eq!(
allocate(
dec!(8),
parts(&[("a", dec!(-1), None), ("b", dec!(3), None)]),
AllocationBasis::Proportional,
),
Err(AllocationError::NegativeWeight {
key: "a".to_owned(),
weight: dec!(-1),
})
);
}
#[test]
fn a_negative_capacity_is_refused() {
assert!(matches!(
allocate(
dec!(8),
parts(&[("a", dec!(1), Some(dec!(-1)))]),
AllocationBasis::Proportional,
),
Err(AllocationError::NegativeCapacity { .. })
));
}
#[test]
fn the_result_does_not_depend_on_the_order_of_the_parts() {
let spec = [
("a", dec!(1), Some(dec!(2))),
("b", dec!(2), None),
("c", dec!(3), Some(dec!(1))),
];
let forward = allocate(dec!(13), parts(&spec), AllocationBasis::Proportional).unwrap();
let mut reversed = spec;
reversed.reverse();
let backward = allocate(dec!(13), parts(&reversed), AllocationBasis::Proportional).unwrap();
assert_eq!(forward.residual, backward.residual);
for part in &forward.parts {
assert_eq!(
Some(part.allocated),
backward.part(&part.key).map(|p| p.allocated)
);
}
}
#[test]
fn an_empty_set_leaves_the_whole_total_as_residual() {
let row = allocate(dec!(5), Vec::new(), AllocationBasis::Proportional).unwrap();
assert!(row.parts.is_empty());
assert_eq!(row.residual, dec!(5));
}
}