Skip to main content

converge_optimization/packs/budget_allocation/
solver.rs

1//! Solver for Budget Allocation pack
2
3use super::types::*;
4use crate::Result;
5use crate::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
6use crate::packs::PackSolver;
7
8/// Efficiency-based solver for budget allocation
9///
10/// Algorithm:
11/// 1. Filter categories meeting ROI threshold
12/// 2. Sort by efficiency score (ROI * priority)
13/// 3. Allocate minimum to each qualifying category
14/// 4. Distribute remaining budget proportionally by efficiency
15pub struct EfficiencySolver;
16
17impl EfficiencySolver {
18    /// Solve the budget allocation problem
19    pub fn solve_allocation(
20        &self,
21        input: &BudgetAllocationInput,
22        spec: &ProblemSpec,
23    ) -> Result<(BudgetAllocationOutput, SolverReport)> {
24        let seed = spec.seed();
25
26        // Filter categories meeting ROI threshold
27        let mut qualifying: Vec<_> = input
28            .categories
29            .iter()
30            .filter(|c| c.expected_roi >= input.constraints.min_roi_threshold)
31            .collect();
32
33        if qualifying.is_empty() {
34            let output = BudgetAllocationOutput::empty(input.total_budget);
35            let replay = ReplayEnvelope::minimal(seed);
36            let report =
37                SolverReport::infeasible("efficiency-v1", vec![], StopReason::NoFeasible, replay);
38            return Ok((output, report));
39        }
40
41        // Sort by efficiency score descending
42        qualifying.sort_by(|a, b| {
43            b.efficiency_score()
44                .partial_cmp(&a.efficiency_score())
45                .unwrap_or(std::cmp::Ordering::Equal)
46        });
47
48        // Apply tie-breaking
49        let _tie_break = &spec.determinism.tie_break;
50
51        // Group by efficiency score for tie-breaking
52        let mut final_order: Vec<&BudgetCategory> = Vec::new();
53        let mut current_score = f64::NEG_INFINITY;
54        let mut score_group: Vec<&BudgetCategory> = vec![];
55
56        for cat in qualifying {
57            if (cat.efficiency_score() - current_score).abs() < 0.001 {
58                score_group.push(cat);
59            } else {
60                if !score_group.is_empty() {
61                    score_group.sort_by(|a, b| a.id.cmp(&b.id));
62                    final_order.extend(score_group.drain(..));
63                }
64                score_group = vec![cat];
65                current_score = cat.efficiency_score();
66            }
67        }
68        if !score_group.is_empty() {
69            score_group.sort_by(|a, b| a.id.cmp(&b.id));
70            final_order.extend(score_group.drain(..));
71        }
72
73        // Limit categories if constraint specified
74        if let Some(max) = input.constraints.max_categories {
75            final_order.truncate(max);
76        }
77
78        // Phase 1: Allocate minimums
79        let mut remaining = input.total_budget;
80        let mut allocations: Vec<(&&BudgetCategory, f64)> = Vec::new();
81
82        for cat in &final_order {
83            if remaining >= cat.min_allocation {
84                allocations.push((cat, cat.min_allocation));
85                remaining -= cat.min_allocation;
86            } else if input.constraints.allow_partial && remaining > 0.0 {
87                allocations.push((cat, remaining));
88                remaining = 0.0;
89            }
90        }
91
92        // Phase 2: Distribute remaining proportionally by efficiency
93        if remaining > 0.0 {
94            let total_efficiency: f64 = allocations
95                .iter()
96                .map(|(cat, _)| cat.efficiency_score())
97                .sum();
98
99            if total_efficiency > 0.0 {
100                for (cat, amount) in &mut allocations {
101                    let share = cat.efficiency_score() / total_efficiency;
102                    let additional = remaining * share;
103                    let new_amount = (*amount + additional).min(cat.max_allocation);
104                    *amount = new_amount;
105                }
106            }
107        }
108
109        // Calculate totals
110        let total_allocated: f64 = allocations.iter().map(|(_, a)| *a).sum();
111        let total_expected_return: f64 = allocations
112            .iter()
113            .map(|(cat, a)| a * cat.expected_roi)
114            .sum();
115        let portfolio_roi = if total_allocated > 0.0 {
116            total_expected_return / total_allocated
117        } else {
118            0.0
119        };
120
121        // Build output
122        let allocation_items: Vec<CategoryAllocation> = allocations
123            .iter()
124            .map(|(cat, amount)| CategoryAllocation {
125                category_id: cat.id.clone(),
126                category_name: cat.name.clone(),
127                amount: *amount,
128                percentage: *amount / input.total_budget * 100.0,
129                expected_return: *amount * cat.expected_roi,
130                reason: format!(
131                    "Efficiency score: {:.2}, ROI: {:.1}%",
132                    cat.efficiency_score(),
133                    cat.expected_roi * 100.0
134                ),
135            })
136            .collect();
137
138        let output = BudgetAllocationOutput {
139            allocations: allocation_items,
140            total_allocated,
141            total_expected_return,
142            budget_remaining: input.total_budget - total_allocated,
143            portfolio_roi,
144        };
145
146        let replay = ReplayEnvelope::minimal(seed);
147        let report = SolverReport::optimal("efficiency-v1", total_expected_return, replay);
148
149        Ok((output, report))
150    }
151}
152
153impl PackSolver for EfficiencySolver {
154    fn id(&self) -> &'static str {
155        "efficiency-v1"
156    }
157
158    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
159        let input: BudgetAllocationInput = spec.inputs_as()?;
160        let (output, report) = self.solve_allocation(&input, spec)?;
161        let json = serde_json::to_value(&output)
162            .map_err(|e| crate::Error::invalid_input(e.to_string()))?;
163        Ok((json, report))
164    }
165
166    fn is_exact(&self) -> bool {
167        false // Greedy heuristic
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::gate::ObjectiveSpec;
175
176    fn create_test_input() -> BudgetAllocationInput {
177        BudgetAllocationInput {
178            total_budget: 100000.0,
179            categories: vec![
180                BudgetCategory {
181                    id: "marketing".to_string(),
182                    name: "Marketing".to_string(),
183                    expected_roi: 0.20,
184                    priority_weight: 3.0,
185                    min_allocation: 10000.0,
186                    max_allocation: 50000.0,
187                },
188                BudgetCategory {
189                    id: "rnd".to_string(),
190                    name: "R&D".to_string(),
191                    expected_roi: 0.30,
192                    priority_weight: 2.0,
193                    min_allocation: 15000.0,
194                    max_allocation: 60000.0,
195                },
196                BudgetCategory {
197                    id: "ops".to_string(),
198                    name: "Operations".to_string(),
199                    expected_roi: 0.10,
200                    priority_weight: 1.0,
201                    min_allocation: 5000.0,
202                    max_allocation: 30000.0,
203                },
204            ],
205            constraints: AllocationConstraints {
206                max_categories: None,
207                min_roi_threshold: 0.05,
208                allow_partial: false,
209            },
210        }
211    }
212
213    fn create_spec(input: &BudgetAllocationInput, seed: u64) -> ProblemSpec {
214        ProblemSpec::builder("test", "tenant")
215            .objective(ObjectiveSpec::maximize("roi"))
216            .inputs(input)
217            .unwrap()
218            .seed(seed)
219            .build()
220            .unwrap()
221    }
222
223    #[test]
224    fn test_allocation_order() {
225        let solver = EfficiencySolver;
226        let input = create_test_input();
227        let spec = create_spec(&input, 42);
228
229        let (output, report) = solver.solve_allocation(&input, &spec).unwrap();
230
231        assert!(report.feasible);
232        assert_eq!(output.allocations.len(), 3);
233
234        // Marketing has highest efficiency (0.20 * 3 = 0.60)
235        // R&D is second (0.30 * 2 = 0.60) - tie-broken alphabetically
236        // Should be sorted by efficiency
237    }
238
239    #[test]
240    fn test_budget_fully_allocated() {
241        let solver = EfficiencySolver;
242        let input = create_test_input();
243        let spec = create_spec(&input, 42);
244
245        let (output, _) = solver.solve_allocation(&input, &spec).unwrap();
246
247        // Budget should be mostly allocated
248        assert!(output.budget_remaining < input.total_budget * 0.1);
249    }
250
251    #[test]
252    fn test_roi_threshold_filtering() {
253        let solver = EfficiencySolver;
254        let mut input = create_test_input();
255        input.constraints.min_roi_threshold = 0.15; // Filters out ops
256
257        let spec = create_spec(&input, 42);
258        let (output, _) = solver.solve_allocation(&input, &spec).unwrap();
259
260        // Ops should not be allocated
261        let ops_alloc = output.allocations.iter().find(|a| a.category_id == "ops");
262        assert!(ops_alloc.is_none());
263    }
264
265    #[test]
266    fn test_max_categories_constraint() {
267        let solver = EfficiencySolver;
268        let mut input = create_test_input();
269        input.constraints.max_categories = Some(2);
270
271        let spec = create_spec(&input, 42);
272        let (output, _) = solver.solve_allocation(&input, &spec).unwrap();
273
274        assert!(output.allocations.len() <= 2);
275    }
276
277    #[test]
278    fn test_determinism() {
279        let solver = EfficiencySolver;
280        let input = create_test_input();
281
282        let spec1 = create_spec(&input, 12345);
283        let spec2 = create_spec(&input, 12345);
284
285        let (output1, _) = solver.solve_allocation(&input, &spec1).unwrap();
286        let (output2, _) = solver.solve_allocation(&input, &spec2).unwrap();
287
288        for (a, b) in output1.allocations.iter().zip(output2.allocations.iter()) {
289            assert_eq!(a.category_id, b.category_id);
290            assert!((a.amount - b.amount).abs() < 0.01);
291        }
292    }
293}