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,
pub weight: Decimal,
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,
pub weight: Decimal,
pub share: Decimal,
pub allocated: Decimal,
}
impl AllocatedPart {
#[must_use]
pub fn capped(&self) -> bool {
self.share > self.allocated
}
#[must_use]
pub fn forgone(&self) -> Decimal {
self.share - self.allocated
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AllocationRow {
pub total: Decimal,
pub parts: Vec<AllocatedPart>,
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 / weight_sum * total
}
AllocationBasis::Proportional => Decimal::ZERO,
});
let allocated = cap(share, part.capacity);
AllocatedPart {
key: part.key,
weight: part.weight,
share,
allocated,
}
})
.collect();
let allocated: Decimal = allocated_parts.iter().map(|p| p.allocated).sum();
Ok(AllocationRow {
total,
parts: allocated_parts,
residual: total - allocated,
})
}
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 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));
}
}