Skip to main content

converge_optimization/packs/capacity_planning/
mod.rs

1//! Capacity Planning Pack
2//!
3//! JTBD: "Plan resource capacity across teams/periods to meet demand forecasts."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Demand forecasts by period, resource type, and required skill
9//! - Available teams with their skills, capacity, and utilization limits
10//! - Resource types with associated costs
11//! - Planning constraints (budget, minimum fulfillment, etc.)
12//!
13//! Find:
14//! - Optimal allocation of team resources to meet demand
15//! - Utilization metrics for each team
16//! - Fulfillment metrics for each period
17//!
18//! ## Solver
19//!
20//! Uses match-based allocation:
21//! 1. Sort demands by priority (highest first)
22//! 2. For each demand, find teams with matching skills and resource types
23//! 3. Allocate capacity from matching teams, respecting utilization limits
24//! 4. Track fulfillment and utilization metrics
25//!
26//! ## Invariants
27//!
28//! - `demand_met` (critical): Minimum fulfillment requirements must be met
29//! - `capacity_not_exceeded` (critical): No team should exceed their maximum utilization
30//! - `skills_matched` (critical): All assignments must match required skills
31//! - `utilization_balanced` (advisory): Team utilization should be reasonably balanced
32//! - `cost_within_budget` (advisory): Total cost should be within budget constraints
33
34mod 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
49/// Capacity Planning Pack
50pub 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    // Higher confidence if fulfillment is high
116    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    // Higher confidence if no teams are over-utilized
125    if output.summary.teams_over_capacity == 0 {
126        confidence += CONFIDENCE_STEP_MEDIUM;
127    }
128
129    // Higher confidence if utilization is balanced
130    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    // Higher confidence if within budget (if specified)
151    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        // Verify skill matching worked
283        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        // With valid input and sufficient capacity, all should pass
315        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        // Increase demand beyond available capacity
376        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        // Should not be feasible because we can't meet 95% fulfillment
390        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        // Should have utilization for both teams
410        assert_eq!(output.team_utilization.len(), 2);
411
412        // No team should be over-utilized with the test data
413        assert!(output.team_utilization.iter().all(|t| !t.is_over_utilized));
414
415        // Average utilization should be reasonable
416        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        // Should have period fulfillment data
437        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        // Should have high fulfillment with the test data
447        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        // Total cost should equal sum of assignment costs
467        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        // Each assignment cost should be units * cost_per_unit (100.0)
471        for assignment in &output.assignments {
472            assert!((assignment.cost - assignment.allocated_units * 100.0).abs() < 0.01);
473        }
474    }
475}