converge_optimization/packs/budget_allocation/
solver.rs1use super::types::*;
4use crate::Result;
5use crate::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
6use crate::packs::PackSolver;
7
8pub struct EfficiencySolver;
16
17impl EfficiencySolver {
18 pub fn solve_allocation(
20 &self,
21 input: &BudgetAllocationInput,
22 spec: &ProblemSpec,
23 ) -> Result<(BudgetAllocationOutput, SolverReport)> {
24 let seed = spec.seed();
25
26 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 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 let _tie_break = &spec.determinism.tie_break;
50
51 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 if let Some(max) = input.constraints.max_categories {
75 final_order.truncate(max);
76 }
77
78 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 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 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 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 }
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 }
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 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; let spec = create_spec(&input, 42);
258 let (output, _) = solver.solve_allocation(&input, &spec).unwrap();
259
260 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}