converge_optimization/packs/capacity_planning/
mod.rs1mod invariants;
35mod solver;
36mod types;
37
38pub use invariants::*;
39pub use solver::*;
40pub use types::*;
41
42use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
43use converge_pack::gate::GateResult as Result;
44use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
45use converge_pack::{
46 CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MEDIUM, CONFIDENCE_STEP_MINOR, CONFIDENCE_STEP_TINY,
47};
48
49pub struct CapacityPlanningPack;
51
52impl Pack for CapacityPlanningPack {
53 fn name(&self) -> &'static str {
54 "capacity-planning"
55 }
56
57 fn version(&self) -> &'static str {
58 "1.0.0"
59 }
60
61 fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
62 let input: CapacityPlanningInput = serde_json::from_value(inputs.clone()).map_err(|e| {
63 converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
64 })?;
65 input.validate()
66 }
67
68 fn invariants(&self) -> &[InvariantDef] {
69 INVARIANTS
70 }
71
72 fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
73 let input: CapacityPlanningInput = spec.inputs_as()?;
74 input.validate()?;
75
76 let solver = MatchAllocationSolver;
77 let (output, report) = solver.solve_capacity(&input, spec)?;
78
79 let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
80 let confidence = calculate_confidence(&output, &input);
81
82 let plan = ProposedPlan::from_payload(
83 format!("plan-{}", spec.problem_id),
84 self.name(),
85 output.summary(),
86 &output,
87 confidence,
88 trace,
89 )?;
90
91 Ok(PackSolveResult::new(plan, report))
92 }
93
94 fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
95 let output: CapacityPlanningOutput = plan.plan_as()?;
96 Ok(check_all_invariants(&output))
97 }
98
99 fn evaluate_gate(
100 &self,
101 _plan: &ProposedPlan,
102 invariant_results: &[InvariantResult],
103 ) -> PromotionGate {
104 default_gate_evaluation(invariant_results, self.invariants())
105 }
106}
107
108fn calculate_confidence(output: &CapacityPlanningOutput, input: &CapacityPlanningInput) -> f64 {
109 if output.assignments.is_empty() {
110 return 0.0;
111 }
112
113 let mut confidence: f64 = 0.4;
114
115 if output.summary.overall_fulfillment_ratio >= 0.95 {
117 confidence += 0.3;
118 } else if output.summary.overall_fulfillment_ratio >= 0.8 {
119 confidence += CONFIDENCE_STEP_MAJOR;
120 } else if output.summary.overall_fulfillment_ratio >= 0.6 {
121 confidence += CONFIDENCE_STEP_MINOR;
122 }
123
124 if output.summary.teams_over_capacity == 0 {
126 confidence += CONFIDENCE_STEP_MEDIUM;
127 }
128
129 if !output.team_utilization.is_empty() {
131 let utils: Vec<f64> = output
132 .team_utilization
133 .iter()
134 .filter(|t| t.total_capacity > 0.0)
135 .map(|t| t.utilization_ratio)
136 .collect();
137
138 if !utils.is_empty() {
139 let mean = utils.iter().sum::<f64>() / utils.len() as f64;
140 let variance =
141 utils.iter().map(|u| (u - mean).powi(2)).sum::<f64>() / utils.len() as f64;
142 let std_dev = variance.sqrt();
143
144 if std_dev < 0.15 {
145 confidence += CONFIDENCE_STEP_MINOR;
146 }
147 }
148 }
149
150 if let Some(budget) = input.constraints.max_budget {
152 if output.summary.total_cost <= budget {
153 confidence += CONFIDENCE_STEP_TINY;
154 }
155 }
156
157 confidence.min(1.0)
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use converge_pack::gate::ObjectiveSpec;
164
165 fn create_test_input() -> CapacityPlanningInput {
166 CapacityPlanningInput {
167 demand_forecasts: vec![
168 DemandForecast {
169 period_id: "Q1-2024".to_string(),
170 resource_type: "engineering".to_string(),
171 required_skill: "backend".to_string(),
172 demand_units: 100.0,
173 priority: 1,
174 min_fulfillment_ratio: 0.8,
175 },
176 DemandForecast {
177 period_id: "Q1-2024".to_string(),
178 resource_type: "engineering".to_string(),
179 required_skill: "frontend".to_string(),
180 demand_units: 50.0,
181 priority: 2,
182 min_fulfillment_ratio: 0.7,
183 },
184 ],
185 resource_types: vec![ResourceType {
186 id: "engineering".to_string(),
187 name: "Engineering Hours".to_string(),
188 unit: "hours".to_string(),
189 cost_per_unit: 100.0,
190 }],
191 teams: vec![
192 Team {
193 id: "team-a".to_string(),
194 name: "Backend Team".to_string(),
195 skills: vec!["backend".to_string()],
196 resource_types: vec!["engineering".to_string()],
197 available_capacity: 120.0,
198 max_utilization: 0.85,
199 headcount: 6,
200 },
201 Team {
202 id: "team-b".to_string(),
203 name: "Frontend Team".to_string(),
204 skills: vec!["frontend".to_string()],
205 resource_types: vec!["engineering".to_string()],
206 available_capacity: 80.0,
207 max_utilization: 0.85,
208 headcount: 4,
209 },
210 ],
211 constraints: PlanningConstraints {
212 target_utilization: 0.75,
213 max_budget: Some(20000.0),
214 min_overall_fulfillment: 0.8,
215 allow_cross_team: false,
216 strict_skill_matching: true,
217 },
218 }
219 }
220
221 #[test]
222 fn test_pack_name() {
223 let pack = CapacityPlanningPack;
224 assert_eq!(pack.name(), "capacity-planning");
225 assert_eq!(pack.version(), "1.0.0");
226 }
227
228 #[test]
229 fn test_validate_inputs() {
230 let pack = CapacityPlanningPack;
231 let input = create_test_input();
232 let json = serde_json::to_value(&input).unwrap();
233 assert!(pack.validate_inputs(&json).is_ok());
234 }
235
236 #[test]
237 fn test_validate_inputs_empty_demands() {
238 let pack = CapacityPlanningPack;
239 let mut input = create_test_input();
240 input.demand_forecasts = vec![];
241 let json = serde_json::to_value(&input).unwrap();
242 assert!(pack.validate_inputs(&json).is_err());
243 }
244
245 #[test]
246 fn test_solve_basic() {
247 let pack = CapacityPlanningPack;
248 let input = create_test_input();
249
250 let spec = ProblemSpec::builder("test-001", "test-tenant")
251 .objective(ObjectiveSpec::maximize("fulfillment"))
252 .inputs(&input)
253 .unwrap()
254 .seed(42)
255 .build()
256 .unwrap();
257
258 let result = pack.solve(&spec).unwrap();
259 assert!(result.is_feasible());
260
261 let output: CapacityPlanningOutput = result.plan.plan_as().unwrap();
262 assert!(!output.assignments.is_empty());
263 assert!(output.summary.overall_fulfillment_ratio > 0.0);
264 }
265
266 #[test]
267 fn test_solve_with_skill_matching() {
268 let pack = CapacityPlanningPack;
269 let input = create_test_input();
270
271 let spec = ProblemSpec::builder("test-002", "test-tenant")
272 .objective(ObjectiveSpec::maximize("fulfillment"))
273 .inputs(&input)
274 .unwrap()
275 .seed(42)
276 .build()
277 .unwrap();
278
279 let result = pack.solve(&spec).unwrap();
280 let output: CapacityPlanningOutput = result.plan.plan_as().unwrap();
281
282 let backend_assignments: Vec<_> = output
284 .assignments
285 .iter()
286 .filter(|a| a.demand_id.contains("backend"))
287 .collect();
288 assert!(backend_assignments.iter().all(|a| a.team_id == "team-a"));
289
290 let frontend_assignments: Vec<_> = output
291 .assignments
292 .iter()
293 .filter(|a| a.demand_id.contains("frontend"))
294 .collect();
295 assert!(frontend_assignments.iter().all(|a| a.team_id == "team-b"));
296 }
297
298 #[test]
299 fn test_check_invariants() {
300 let pack = CapacityPlanningPack;
301 let input = create_test_input();
302
303 let spec = ProblemSpec::builder("test-003", "test-tenant")
304 .objective(ObjectiveSpec::maximize("fulfillment"))
305 .inputs(&input)
306 .unwrap()
307 .seed(42)
308 .build()
309 .unwrap();
310
311 let result = pack.solve(&spec).unwrap();
312 let invariants = pack.check_invariants(&result.plan).unwrap();
313
314 let all_pass = invariants.iter().all(|r| r.passed);
316 assert!(all_pass);
317 }
318
319 #[test]
320 fn test_gate_promotes() {
321 let pack = CapacityPlanningPack;
322 let input = create_test_input();
323
324 let spec = ProblemSpec::builder("test-004", "test-tenant")
325 .objective(ObjectiveSpec::maximize("fulfillment"))
326 .inputs(&input)
327 .unwrap()
328 .seed(42)
329 .build()
330 .unwrap();
331
332 let result = pack.solve(&spec).unwrap();
333 let invariants = pack.check_invariants(&result.plan).unwrap();
334 let gate = pack.evaluate_gate(&result.plan, &invariants);
335
336 assert!(gate.is_promoted());
337 }
338
339 #[test]
340 fn test_determinism() {
341 let pack = CapacityPlanningPack;
342 let input = create_test_input();
343
344 let spec1 = ProblemSpec::builder("test-a", "tenant")
345 .objective(ObjectiveSpec::maximize("fulfillment"))
346 .inputs(&input)
347 .unwrap()
348 .seed(99999)
349 .build()
350 .unwrap();
351
352 let spec2 = ProblemSpec::builder("test-b", "tenant")
353 .objective(ObjectiveSpec::maximize("fulfillment"))
354 .inputs(&input)
355 .unwrap()
356 .seed(99999)
357 .build()
358 .unwrap();
359
360 let result1 = pack.solve(&spec1).unwrap();
361 let result2 = pack.solve(&spec2).unwrap();
362
363 let output1: CapacityPlanningOutput = result1.plan.plan_as().unwrap();
364 let output2: CapacityPlanningOutput = result2.plan.plan_as().unwrap();
365
366 assert_eq!(output1.assignments.len(), output2.assignments.len());
367 assert!((output1.summary.total_allocated - output2.summary.total_allocated).abs() < 0.01);
368 }
369
370 #[test]
371 fn test_insufficient_capacity() {
372 let pack = CapacityPlanningPack;
373 let mut input = create_test_input();
374
375 input.demand_forecasts[0].demand_units = 500.0;
377 input.constraints.min_overall_fulfillment = 0.95;
378
379 let spec = ProblemSpec::builder("test-005", "test-tenant")
380 .objective(ObjectiveSpec::maximize("fulfillment"))
381 .inputs(&input)
382 .unwrap()
383 .seed(42)
384 .build()
385 .unwrap();
386
387 let result = pack.solve(&spec).unwrap();
388
389 assert!(!result.is_feasible());
391 }
392
393 #[test]
394 fn test_utilization_metrics() {
395 let pack = CapacityPlanningPack;
396 let input = create_test_input();
397
398 let spec = ProblemSpec::builder("test-006", "test-tenant")
399 .objective(ObjectiveSpec::maximize("fulfillment"))
400 .inputs(&input)
401 .unwrap()
402 .seed(42)
403 .build()
404 .unwrap();
405
406 let result = pack.solve(&spec).unwrap();
407 let output: CapacityPlanningOutput = result.plan.plan_as().unwrap();
408
409 assert_eq!(output.team_utilization.len(), 2);
411
412 assert!(output.team_utilization.iter().all(|t| !t.is_over_utilized));
414
415 assert!(output.summary.average_utilization > 0.0);
417 assert!(output.summary.average_utilization <= 1.0);
418 }
419
420 #[test]
421 fn test_period_fulfillment() {
422 let pack = CapacityPlanningPack;
423 let input = create_test_input();
424
425 let spec = ProblemSpec::builder("test-007", "test-tenant")
426 .objective(ObjectiveSpec::maximize("fulfillment"))
427 .inputs(&input)
428 .unwrap()
429 .seed(42)
430 .build()
431 .unwrap();
432
433 let result = pack.solve(&spec).unwrap();
434 let output: CapacityPlanningOutput = result.plan.plan_as().unwrap();
435
436 assert!(!output.period_fulfillment.is_empty());
438
439 let q1 = output
440 .period_fulfillment
441 .iter()
442 .find(|p| p.period_id == "Q1-2024");
443 assert!(q1.is_some());
444 let q1 = q1.unwrap();
445
446 assert!(q1.fulfillment_ratio > 0.8);
448 }
449
450 #[test]
451 fn test_cost_calculation() {
452 let pack = CapacityPlanningPack;
453 let input = create_test_input();
454
455 let spec = ProblemSpec::builder("test-008", "test-tenant")
456 .objective(ObjectiveSpec::maximize("fulfillment"))
457 .inputs(&input)
458 .unwrap()
459 .seed(42)
460 .build()
461 .unwrap();
462
463 let result = pack.solve(&spec).unwrap();
464 let output: CapacityPlanningOutput = result.plan.plan_as().unwrap();
465
466 let sum_costs: f64 = output.assignments.iter().map(|a| a.cost).sum();
468 assert!((output.summary.total_cost - sum_costs).abs() < 0.01);
469
470 for assignment in &output.assignments {
472 assert!((assignment.cost - assignment.allocated_units * 100.0).abs() < 0.01);
473 }
474 }
475}