"""Quota allocation strategies for parallel campaigns.""" from abc import ABC, abstractmethod from src.models import Campaign class QuotaStrategy(ABC): """Base class for quota allocation strategies.""" @abstractmethod def get_name(self) -> str: """Return the strategy name.""" pass @abstractmethod def allocate_quota( self, campaigns: list[Campaign], minute: int, available_quota: int ) -> dict[str, int]: """ Allocate quota among active campaigns for the given minute. Args: campaigns: List of all campaigns minute: Current minute in simulation available_quota: Total quota available this minute Returns: Dict mapping campaign ID to allocated quota """ pass class EqualSplitStrategy(QuotaStrategy): """Divide quota equally among all active campaigns.""" def get_name(self) -> str: return "Equal Split" def allocate_quota( self, campaigns: list[Campaign], minute: int, available_quota: int ) -> dict[str, int]: active_campaigns = [c for c in campaigns if c.is_active(minute)] if not active_campaigns: return {} quota_per_campaign = available_quota // len(active_campaigns) remaining_quota = available_quota % len(active_campaigns) allocations: dict[str, int] = {} for i, campaign in enumerate(active_campaigns): # Distribute remainder to first campaigns bonus = 1 if i < remaining_quota else 0 allocated = min(quota_per_campaign + bonus, campaign.remaining_volume) allocations[campaign.id] = allocated return allocations class ProportionalToTotalStrategy(QuotaStrategy): """Allocate quota proportionally based on total campaign size.""" def get_name(self) -> str: return "Proportional to Total Size" def allocate_quota( self, campaigns: list[Campaign], minute: int, available_quota: int ) -> dict[str, int]: active_campaigns = [c for c in campaigns if c.is_active(minute)] if not active_campaigns: return {} total_size = sum(c.total_volume for c in active_campaigns) if total_size == 0: return {} allocations: dict[str, int] = {} allocated_so_far = 0 for campaign in active_campaigns[:-1]: # Calculate proportional share based on total size proportion = campaign.total_volume / total_size allocated = min( int(available_quota * proportion), campaign.remaining_volume ) allocations[campaign.id] = allocated allocated_so_far += allocated # Give remaining quota to last campaign to avoid rounding issues last_campaign = active_campaigns[-1] remaining = available_quota - allocated_so_far allocations[last_campaign.id] = min(remaining, last_campaign.remaining_volume) return allocations class ProportionalToRemainingStrategy(QuotaStrategy): """ Allocate quota proportionally based on remaining volume. Note: This strategy gives MORE quota to campaigns with MORE remaining emails, which can penalize campaigns that started earlier. This is useful when you want to prioritize finishing campaigns with larger backlogs. """ def get_name(self) -> str: return "Proportional to Remaining" def allocate_quota( self, campaigns: list[Campaign], minute: int, available_quota: int ) -> dict[str, int]: active_campaigns = [c for c in campaigns if c.is_active(minute)] if not active_campaigns: return {} total_remaining = sum(c.remaining_volume for c in active_campaigns) if total_remaining == 0: return {} allocations: dict[str, int] = {} allocated_so_far = 0 for campaign in active_campaigns[:-1]: # Calculate proportional share based on remaining volume proportion = campaign.remaining_volume / total_remaining allocated = min( int(available_quota * proportion), campaign.remaining_volume ) allocations[campaign.id] = allocated allocated_so_far += allocated # Give remaining quota to last campaign to avoid rounding issues last_campaign = active_campaigns[-1] remaining = available_quota - allocated_so_far allocations[last_campaign.id] = min(remaining, last_campaign.remaining_volume) return allocations class FirstComeFirstServedStrategy(QuotaStrategy): """Prioritize campaigns that started earlier.""" def get_name(self) -> str: return "First-Come-First-Served" def allocate_quota( self, campaigns: list[Campaign], minute: int, available_quota: int ) -> dict[str, int]: active_campaigns = [c for c in campaigns if c.is_active(minute)] if not active_campaigns: return {} # Sort by start minute (earlier first) sorted_campaigns = sorted(active_campaigns, key=lambda c: c.start_minute) allocations: dict[str, int] = {} remaining_quota = available_quota for campaign in sorted_campaigns: # Give each campaign as much as they need (or what's left) allocated = min(remaining_quota, campaign.remaining_volume) allocations[campaign.id] = allocated remaining_quota -= allocated if remaining_quota == 0: break return allocations class WeightedFairQueuingStrategy(QuotaStrategy): """ Weighted fair queuing based on campaign age and size. Campaigns that started earlier get higher weight, but we also consider the remaining volume to maintain fairness. """ def get_name(self) -> str: return "Weighted Fair Queuing" def allocate_quota( self, campaigns: list[Campaign], minute: int, available_quota: int ) -> dict[str, int]: active_campaigns = [c for c in campaigns if c.is_active(minute)] if not active_campaigns: return {} # Calculate weights based on campaign age (minutes active) # Older campaigns get exponentially higher weight weights: dict[str, float] = {} for campaign in active_campaigns: age = minute - campaign.start_minute + 1 # +1 to avoid zero # Use square root to give older campaigns advantage without being too aggressive weights[campaign.id] = age**0.5 total_weight = sum(weights.values()) allocations: dict[str, int] = {} allocated_so_far = 0 for campaign in active_campaigns[:-1]: # Calculate weighted share share = weights[campaign.id] / total_weight allocated = min(int(available_quota * share), campaign.remaining_volume) allocations[campaign.id] = allocated allocated_so_far += allocated # Give remaining quota to last campaign last_campaign = active_campaigns[-1] remaining = available_quota - allocated_so_far allocations[last_campaign.id] = min(remaining, last_campaign.remaining_volume) return allocations class PrioritySpilloverStrategy(QuotaStrategy): """ Allocate most quota to FCFS priority, with spillover to others. Gives a configurable percentage (default 90%) to earliest campaigns, and splits the remainder equally among other campaigns. """ def __init__(self, priority_percentage: float = 90.0): """ Initialize strategy. Args: priority_percentage: Percentage of quota for priority campaigns (0-100) """ self.priority_percentage = max(0.0, min(100.0, priority_percentage)) def get_name(self) -> str: return f"Priority+Spillover ({self.priority_percentage:.0f}/{100 - self.priority_percentage:.0f})" def allocate_quota( self, campaigns: list[Campaign], minute: int, available_quota: int ) -> dict[str, int]: active_campaigns = [c for c in campaigns if c.is_active(minute)] if not active_campaigns: return {} # Sort by start minute sorted_campaigns = sorted(active_campaigns, key=lambda c: c.start_minute) # Calculate priority and spillover quotas priority_quota = int(available_quota * self.priority_percentage / 100) spillover_quota = available_quota - priority_quota allocations: dict[str, int] = {} # Phase 1: Allocate priority quota using FCFS remaining_priority = priority_quota priority_satisfied: list[Campaign] = [] for campaign in sorted_campaigns: allocated = min(remaining_priority, campaign.remaining_volume) allocations[campaign.id] = allocated remaining_priority -= allocated if allocated >= campaign.remaining_volume: priority_satisfied.append(campaign) if remaining_priority == 0: break # Phase 2: Distribute spillover quota equally among all active campaigns if spillover_quota > 0: spillover_per_campaign = spillover_quota // len(active_campaigns) spillover_remainder = spillover_quota % len(active_campaigns) for i, campaign in enumerate(active_campaigns): # Add spillover to existing allocation bonus = 1 if i < spillover_remainder else 0 additional = spillover_per_campaign + bonus current_allocation = allocations.get(campaign.id, 0) can_still_send = campaign.remaining_volume - current_allocation additional_allocated = min(additional, can_still_send) allocations[campaign.id] = current_allocation + additional_allocated # Phase 3: Redistribute any leftover priority quota if remaining_priority > 0: # Find campaigns that still need more for campaign in sorted_campaigns: current_allocation = allocations.get(campaign.id, 0) can_still_send = campaign.remaining_volume - current_allocation if can_still_send > 0: additional = min(remaining_priority, can_still_send) allocations[campaign.id] = current_allocation + additional remaining_priority -= additional if remaining_priority == 0: break return allocations def get_all_strategies(priority_percentage: float = 90.0) -> list[QuotaStrategy]: """ Get all available quota allocation strategies. Args: priority_percentage: Priority percentage for PrioritySpillover strategy Returns: List of all strategy instances """ return [ EqualSplitStrategy(), ProportionalToTotalStrategy(), ProportionalToRemainingStrategy(), FirstComeFirstServedStrategy(), WeightedFairQueuingStrategy(), PrioritySpilloverStrategy(priority_percentage), ] def get_strategy_by_name(name: str, priority_percentage: float = 90.0) -> QuotaStrategy: """ Get a strategy instance by name. Args: name: Strategy name priority_percentage: Priority percentage for PrioritySpillover strategy Returns: Strategy instance Raises: ValueError: If strategy name not found """ strategies = get_all_strategies(priority_percentage) for strategy in strategies: if strategy.get_name() == name: return strategy raise ValueError(f"Strategy '{name}' not found")