Skip to main content

converge_optimization/packs/budget_allocation/
mod.rs

1//! Budget Allocation Pack
2//!
3//! JTBD: "Allocate budgets across categories maximizing ROI under constraints."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Total budget to allocate
9//! - Categories with ROI estimates and constraints
10//! - Min/max allocation limits
11//!
12//! Find:
13//! - Optimal allocation maximizing expected return
14//!
15//! ## Solver
16//!
17//! Uses efficiency-based allocation:
18//! 1. Filter categories meeting ROI threshold
19//! 2. Sort by efficiency score (ROI * priority)
20//! 3. Allocate minimum to each qualifying category
21//! 4. Distribute remaining proportionally by efficiency
22
23mod invariants;
24mod solver;
25mod types;
26
27pub use invariants::*;
28pub use solver::*;
29pub use types::*;
30
31use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
32use converge_pack::gate::GateResult as Result;
33use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
34use converge_pack::{CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MINOR};
35
36/// Budget Allocation Pack
37pub struct BudgetAllocationPack;
38
39impl Pack for BudgetAllocationPack {
40    fn name(&self) -> &'static str {
41        "budget-allocation"
42    }
43
44    fn version(&self) -> &'static str {
45        "1.0.0"
46    }
47
48    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
49        let input: BudgetAllocationInput = serde_json::from_value(inputs.clone()).map_err(|e| {
50            converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
51        })?;
52        input.validate()
53    }
54
55    fn invariants(&self) -> &[InvariantDef] {
56        INVARIANTS
57    }
58
59    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
60        let input: BudgetAllocationInput = spec.inputs_as()?;
61        input.validate()?;
62
63        let solver = EfficiencySolver;
64        let (output, report) = solver.solve_allocation(&input, spec)?;
65
66        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
67        let confidence = calculate_confidence(&output, &input);
68
69        let plan = ProposedPlan::from_payload(
70            format!("plan-{}", spec.problem_id),
71            self.name(),
72            output.summary(),
73            &output,
74            confidence,
75            trace,
76        )?;
77
78        Ok(PackSolveResult::new(plan, report))
79    }
80
81    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
82        let output: BudgetAllocationOutput = plan.plan_as()?;
83        // We need total budget for validation - use allocated + remaining
84        let total_budget = output.total_allocated + output.budget_remaining;
85        Ok(check_all_invariants(&output, total_budget))
86    }
87
88    fn evaluate_gate(
89        &self,
90        _plan: &ProposedPlan,
91        invariant_results: &[InvariantResult],
92    ) -> PromotionGate {
93        default_gate_evaluation(invariant_results, self.invariants())
94    }
95}
96
97fn calculate_confidence(output: &BudgetAllocationOutput, input: &BudgetAllocationInput) -> f64 {
98    if output.allocations.is_empty() {
99        return 0.0;
100    }
101
102    let mut confidence: f64 = 0.5;
103
104    // Higher confidence if ROI is positive
105    if output.portfolio_roi > 0.0 {
106        confidence += CONFIDENCE_STEP_MAJOR;
107    }
108
109    // Higher confidence if budget well utilized
110    let utilization = output.total_allocated / input.total_budget;
111    if utilization >= 0.8 {
112        confidence += CONFIDENCE_STEP_MAJOR;
113    } else if utilization >= 0.5 {
114        confidence += CONFIDENCE_STEP_MINOR;
115    }
116
117    // Higher confidence if we funded multiple categories
118    if output.allocations.len() >= 2 {
119        confidence += CONFIDENCE_STEP_MINOR;
120    }
121
122    confidence.min(1.0)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use converge_pack::gate::ObjectiveSpec;
129
130    fn create_test_input() -> BudgetAllocationInput {
131        BudgetAllocationInput {
132            total_budget: 100000.0,
133            categories: vec![
134                BudgetCategory {
135                    id: "marketing".to_string(),
136                    name: "Marketing".to_string(),
137                    expected_roi: 0.20,
138                    priority_weight: 2.0,
139                    min_allocation: 10000.0,
140                    max_allocation: 50000.0,
141                },
142                BudgetCategory {
143                    id: "rnd".to_string(),
144                    name: "R&D".to_string(),
145                    expected_roi: 0.25,
146                    priority_weight: 2.0,
147                    min_allocation: 15000.0,
148                    max_allocation: 60000.0,
149                },
150            ],
151            constraints: AllocationConstraints {
152                max_categories: None,
153                min_roi_threshold: 0.05,
154                allow_partial: false,
155            },
156        }
157    }
158
159    #[test]
160    fn test_pack_name() {
161        let pack = BudgetAllocationPack;
162        assert_eq!(pack.name(), "budget-allocation");
163        assert_eq!(pack.version(), "1.0.0");
164    }
165
166    #[test]
167    fn test_validate_inputs() {
168        let pack = BudgetAllocationPack;
169        let input = create_test_input();
170        let json = serde_json::to_value(&input).unwrap();
171        assert!(pack.validate_inputs(&json).is_ok());
172    }
173
174    #[test]
175    fn test_solve_basic() {
176        let pack = BudgetAllocationPack;
177        let input = create_test_input();
178
179        let spec = ProblemSpec::builder("test-001", "test-tenant")
180            .objective(ObjectiveSpec::maximize("roi"))
181            .inputs(&input)
182            .unwrap()
183            .seed(42)
184            .build()
185            .unwrap();
186
187        let result = pack.solve(&spec).unwrap();
188        assert!(result.is_feasible());
189
190        let output: BudgetAllocationOutput = result.plan.plan_as().unwrap();
191        assert!(!output.allocations.is_empty());
192    }
193
194    #[test]
195    fn test_check_invariants() {
196        let pack = BudgetAllocationPack;
197        let input = create_test_input();
198
199        let spec = ProblemSpec::builder("test-002", "test-tenant")
200            .objective(ObjectiveSpec::maximize("roi"))
201            .inputs(&input)
202            .unwrap()
203            .seed(42)
204            .build()
205            .unwrap();
206
207        let result = pack.solve(&spec).unwrap();
208        let invariants = pack.check_invariants(&result.plan).unwrap();
209
210        let all_pass = invariants.iter().all(|r| r.passed);
211        assert!(all_pass);
212    }
213
214    #[test]
215    fn test_gate_promotes() {
216        let pack = BudgetAllocationPack;
217        let input = create_test_input();
218
219        let spec = ProblemSpec::builder("test-003", "test-tenant")
220            .objective(ObjectiveSpec::maximize("roi"))
221            .inputs(&input)
222            .unwrap()
223            .seed(42)
224            .build()
225            .unwrap();
226
227        let result = pack.solve(&spec).unwrap();
228        let invariants = pack.check_invariants(&result.plan).unwrap();
229        let gate = pack.evaluate_gate(&result.plan, &invariants);
230
231        assert!(gate.is_promoted());
232    }
233
234    #[test]
235    fn test_determinism() {
236        let pack = BudgetAllocationPack;
237        let input = create_test_input();
238
239        let spec1 = ProblemSpec::builder("test-a", "tenant")
240            .objective(ObjectiveSpec::maximize("roi"))
241            .inputs(&input)
242            .unwrap()
243            .seed(99999)
244            .build()
245            .unwrap();
246
247        let spec2 = ProblemSpec::builder("test-b", "tenant")
248            .objective(ObjectiveSpec::maximize("roi"))
249            .inputs(&input)
250            .unwrap()
251            .seed(99999)
252            .build()
253            .unwrap();
254
255        let result1 = pack.solve(&spec1).unwrap();
256        let result2 = pack.solve(&spec2).unwrap();
257
258        let output1: BudgetAllocationOutput = result1.plan.plan_as().unwrap();
259        let output2: BudgetAllocationOutput = result2.plan.plan_as().unwrap();
260
261        assert!((output1.total_allocated - output2.total_allocated).abs() < 0.01);
262    }
263}