converge_optimization/packs/budget_allocation/
mod.rs1mod 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
36pub 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 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 if output.portfolio_roi > 0.0 {
106 confidence += CONFIDENCE_STEP_MAJOR;
107 }
108
109 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 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}