openmeter / pkg /currencyx /allocation.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 9)
fea99b3 verified
Raw
History Blame Contribute Delete
8.54 kB
package currencyx
import (
"cmp"
"errors"
"fmt"
"slices"
"github.com/alpacahq/alpacadecimal"
)
// WeightedAllocationItem defines one key that can receive a proportional
// allocation from a currency amount. Weight is dimensionless; it does not need
// to be a currency amount.
type WeightedAllocationItem[T any] struct {
Key T
Weight alpacadecimal.Decimal
}
// WeightedAllocation is the allocated currency amount for one key.
type WeightedAllocation[T any] struct {
Key T
Amount alpacadecimal.Decimal
}
// AmountAllocationItem defines one currency amount bucket that can receive a
// proportional allocation. The amount is both the allocation weight and the
// maximum amount that can be allocated to the key.
type AmountAllocationItem[T any] struct {
Key T
Amount alpacadecimal.Decimal
}
// AmountAllocation is the allocated currency amount for one key.
type AmountAllocation[T any] struct {
Key T
Amount alpacadecimal.Decimal
}
// WeightedAllocationInput defines a proportional currency allocation.
type WeightedAllocationInput[T any] struct {
Amount alpacadecimal.Decimal
Items []WeightedAllocationItem[T]
// CompareKey is used as a deterministic tie-breaker when two items have
// the same fractional remainder. If nil, the original item order is used.
CompareKey func(left, right T) int
}
// AmountAllocationInput defines a proportional allocation across currency
// amount buckets.
type AmountAllocationInput[T any] struct {
Amount alpacadecimal.Decimal
Items []AmountAllocationItem[T]
// CompareKey is used as a deterministic tie-breaker when two buckets have
// the same fractional remainder. If nil, the original item order is used.
CompareKey func(left, right T) int
}
// AllocateByWeight allocates a currency amount across keys using their
// weights and the largest remainder quota method at the currency precision.
func AllocateByWeight[T any](currency Currency, input WeightedAllocationInput[T]) ([]WeightedAllocation[T], error) {
if err := validateWeightedAllocationInput(currency, input); err != nil {
return nil, err
}
if input.Amount.IsZero() {
return nil, nil
}
totalWeight := alpacadecimal.Zero
for _, item := range input.Items {
totalWeight = totalWeight.Add(item.Weight)
}
type allocationCandidate struct {
index int
key T
amount alpacadecimal.Decimal
remainder alpacadecimal.Decimal
}
candidates := make([]allocationCandidate, 0, len(input.Items))
allocated := alpacadecimal.Zero
for i, item := range input.Items {
share := input.Amount.Mul(item.Weight).Div(totalWeight)
amount := currency.RoundDown(share)
candidates = append(candidates, allocationCandidate{
index: i,
key: item.Key,
amount: amount,
remainder: share.Sub(amount),
})
allocated = allocated.Add(amount)
}
slices.SortStableFunc(candidates, func(left, right allocationCandidate) int {
if remainderCmp := right.remainder.Cmp(left.remainder); remainderCmp != 0 {
return remainderCmp
}
if input.CompareKey != nil {
if keyCmp := input.CompareKey(left.key, right.key); keyCmp != 0 {
return keyCmp
}
}
return cmp.Compare(left.index, right.index)
})
unit := currency.Unit()
remaining := input.Amount.Sub(allocated)
for i := range candidates {
if remaining.LessThan(unit) {
break
}
candidates[i].amount = candidates[i].amount.Add(unit)
remaining = remaining.Sub(unit)
}
slices.SortFunc(candidates, func(left, right allocationCandidate) int {
return cmp.Compare(left.index, right.index)
})
allocations := make([]WeightedAllocation[T], 0, len(candidates))
for _, candidate := range candidates {
if candidate.amount.IsZero() {
continue
}
allocations = append(allocations, WeightedAllocation[T]{
Key: candidate.key,
Amount: candidate.amount,
})
}
return allocations, nil
}
// AllocateByAmount allocates a currency amount across currency amount buckets
// using the largest remainder quota method. Each item amount is both its
// proportional weight and its allocation cap.
func AllocateByAmount[T any](currency Currency, input AmountAllocationInput[T]) ([]AmountAllocation[T], error) {
if currency == nil {
return nil, errors.New("currency is required")
}
if err := validateAmountAllocationInput(currency, input); err != nil {
return nil, err
}
if input.Amount.IsZero() {
return nil, nil
}
totalAmount := alpacadecimal.Zero
for _, item := range input.Items {
totalAmount = totalAmount.Add(item.Amount)
}
type allocationCandidate struct {
index int
key T
amount alpacadecimal.Decimal
allocated alpacadecimal.Decimal
remainder alpacadecimal.Decimal
}
candidates := make([]allocationCandidate, 0, len(input.Items))
allocated := alpacadecimal.Zero
for i, item := range input.Items {
share := input.Amount.Mul(item.Amount).Div(totalAmount)
floor := currency.RoundDown(share)
candidates = append(candidates, allocationCandidate{
index: i,
key: item.Key,
amount: item.Amount,
allocated: floor,
remainder: share.Sub(floor),
})
allocated = allocated.Add(floor)
}
slices.SortStableFunc(candidates, func(left, right allocationCandidate) int {
if remainderCmp := right.remainder.Cmp(left.remainder); remainderCmp != 0 {
return remainderCmp
}
if input.CompareKey != nil {
if keyCmp := input.CompareKey(left.key, right.key); keyCmp != 0 {
return keyCmp
}
}
return cmp.Compare(left.index, right.index)
})
unit := currency.Unit()
remaining := input.Amount.Sub(allocated)
for remaining.GreaterThanOrEqual(unit) {
distributed := false
for i := range candidates {
if remaining.LessThan(unit) {
break
}
next := candidates[i].allocated.Add(unit)
if next.GreaterThan(candidates[i].amount) {
continue
}
candidates[i].allocated = next
remaining = remaining.Sub(unit)
distributed = true
}
if !distributed {
return nil, errors.New("cannot distribute remaining allocation without exceeding item amounts")
}
}
slices.SortFunc(candidates, func(left, right allocationCandidate) int {
return cmp.Compare(left.index, right.index)
})
allocations := make([]AmountAllocation[T], 0, len(candidates))
for _, candidate := range candidates {
if candidate.allocated.IsZero() {
continue
}
allocations = append(allocations, AmountAllocation[T]{
Key: candidate.key,
Amount: candidate.allocated,
})
}
return allocations, nil
}
func validateWeightedAllocationInput[T any](currency Currency, input WeightedAllocationInput[T]) error {
if currency == nil {
return errors.New("currency is required")
}
var errs []error
if err := currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("invalid currency: %w", err))
}
if !currency.IsRoundedToPrecision(input.Amount) {
errs = append(errs, errors.New("amount must be rounded to currency precision"))
}
if input.Amount.Sign() < 0 {
errs = append(errs, errors.New("amount must be non-negative"))
}
if len(input.Items) == 0 && !input.Amount.IsZero() {
errs = append(errs, errors.New("items are required for a non-zero amount"))
}
totalWeight := alpacadecimal.Zero
for i, item := range input.Items {
if item.Weight.Sign() <= 0 {
errs = append(errs, fmt.Errorf("items[%d].weight must be positive", i))
continue
}
totalWeight = totalWeight.Add(item.Weight)
}
return errors.Join(errs...)
}
func validateAmountAllocationInput[T any](currency Currency, input AmountAllocationInput[T]) error {
if currency == nil {
return errors.New("currency is required")
}
var errs []error
if input.Amount.Sign() < 0 {
errs = append(errs, errors.New("amount must be non-negative"))
}
if !currency.IsRoundedToPrecision(input.Amount) {
errs = append(errs, errors.New("amount must be rounded to currency precision"))
}
if len(input.Items) == 0 && !input.Amount.IsZero() {
errs = append(errs, errors.New("items are required for a non-zero amount"))
}
totalAmount := alpacadecimal.Zero
for i, item := range input.Items {
if item.Amount.Sign() <= 0 {
errs = append(errs, fmt.Errorf("items[%d].amount must be positive", i))
continue
}
if !currency.IsRoundedToPrecision(item.Amount) {
errs = append(errs, fmt.Errorf("items[%d].amount must be rounded to currency precision", i))
}
totalAmount = totalAmount.Add(item.Amount)
}
if input.Amount.GreaterThan(totalAmount) {
errs = append(errs, errors.New("amount must not exceed total item amount"))
}
return errors.Join(errs...)
}