Skip to main content

converge_optimization/packs/capacity_planning/
solver.rs

1//! Solver for Capacity Planning pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7use std::collections::HashMap;
8
9/// Match-based allocation solver for capacity planning
10///
11/// Algorithm:
12/// 1. Sort demands by priority (highest first)
13/// 2. For each demand, find teams with matching skills and resource types
14/// 3. Allocate capacity from matching teams, respecting utilization limits
15/// 4. Track fulfillment and utilization metrics
16pub struct MatchAllocationSolver;
17
18impl MatchAllocationSolver {
19    /// Solve the capacity planning problem
20    pub fn solve_capacity(
21        &self,
22        input: &CapacityPlanningInput,
23        spec: &ProblemSpec,
24    ) -> Result<(CapacityPlanningOutput, SolverReport)> {
25        let seed = spec.seed();
26
27        // Build resource type cost lookup
28        let resource_costs: HashMap<&str, f64> = input
29            .resource_types
30            .iter()
31            .map(|r| (r.id.as_str(), r.cost_per_unit))
32            .collect();
33
34        // Track remaining capacity per team
35        let mut team_remaining: HashMap<String, f64> = input
36            .teams
37            .iter()
38            .map(|t| (t.id.clone(), t.effective_capacity()))
39            .collect();
40
41        // Track allocations per team
42        let mut team_allocated: HashMap<String, f64> =
43            input.teams.iter().map(|t| (t.id.clone(), 0.0)).collect();
44
45        // Sort demands by priority
46        let mut sorted_demands: Vec<&DemandForecast> = input.demand_forecasts.iter().collect();
47        sorted_demands.sort_by(|a, b| {
48            a.priority
49                .cmp(&b.priority)
50                .then_with(|| b.demand_units.total_cmp(&a.demand_units))
51                .then_with(|| a.period_id.cmp(&b.period_id))
52                .then_with(|| a.resource_type.cmp(&b.resource_type))
53                .then_with(|| a.required_skill.cmp(&b.required_skill))
54        });
55
56        let tie_break = &spec.determinism.tie_break;
57
58        let mut assignments = Vec::new();
59        let mut period_allocations: HashMap<String, Vec<(String, f64, f64)>> = HashMap::new(); // period -> [(demand_id, requested, allocated)]
60        let mut assignment_id = 0;
61
62        // Process each demand
63        for demand in sorted_demands {
64            let mut remaining_demand = demand.demand_units;
65            let demand_id = format!("demand-{}-{}", demand.period_id, demand.required_skill);
66
67            // Find matching teams
68            let mut matching_teams: Vec<&Team> = input
69                .teams
70                .iter()
71                .filter(|t| {
72                    // Must have the required skill (if strict matching)
73                    let skill_match = if input.constraints.strict_skill_matching {
74                        t.has_skill(&demand.required_skill)
75                    } else {
76                        true
77                    };
78
79                    // Must provide the required resource type
80                    let resource_match = t.provides_resource_type(&demand.resource_type);
81
82                    // Must have remaining capacity
83                    let has_capacity = team_remaining.get(&t.id).map_or(false, |&c| c > 0.0);
84
85                    skill_match && resource_match && has_capacity
86                })
87                .collect();
88
89            // Sort by remaining capacity (prefer teams with more capacity for load balancing)
90            matching_teams.sort_by(|a, b| {
91                let cap_a = *team_remaining.get(&a.id).unwrap_or(&0.0);
92                let cap_b = *team_remaining.get(&b.id).unwrap_or(&0.0);
93                cap_b.total_cmp(&cap_a).then_with(|| a.id.cmp(&b.id))
94            });
95
96            // Apply deterministic tie-breaking for teams with similar capacity
97            if matching_teams.len() > 1 {
98                let first_cap = team_remaining.get(&matching_teams[0].id).unwrap_or(&0.0);
99                let similar_cap: Vec<&Team> = matching_teams
100                    .iter()
101                    .filter(|t| {
102                        let cap = team_remaining.get(&t.id).unwrap_or(&0.0);
103                        (cap - first_cap).abs() < 0.01
104                    })
105                    .copied()
106                    .collect();
107
108                if similar_cap.len() > 1 {
109                    // Sort by ID for deterministic selection
110                    let mut sorted = similar_cap.clone();
111                    sorted.sort_by(|a, b| a.id.cmp(&b.id));
112                    if let Some(selected) =
113                        tie_break.select_by(&sorted, seed, |a, b| a.id.cmp(&b.id))
114                    {
115                        // Move selected to front
116                        matching_teams.retain(|t| t.id != selected.id);
117                        matching_teams.insert(0, selected);
118                    }
119                }
120            }
121
122            let mut allocated_for_demand = 0.0;
123
124            // Allocate from matching teams
125            for team in matching_teams {
126                if remaining_demand <= 0.0 {
127                    break;
128                }
129
130                let available = team_remaining.get(&team.id).unwrap_or(&0.0);
131                let allocate = remaining_demand.min(*available);
132
133                if allocate > 0.0 {
134                    let cost_per_unit = resource_costs
135                        .get(demand.resource_type.as_str())
136                        .unwrap_or(&0.0);
137                    let cost = allocate * cost_per_unit;
138
139                    assignments.push(ResourceAssignment {
140                        id: format!("assign-{}", assignment_id),
141                        team_id: team.id.clone(),
142                        period_id: demand.period_id.clone(),
143                        resource_type: demand.resource_type.clone(),
144                        demand_id: demand_id.clone(),
145                        allocated_units: allocate,
146                        cost,
147                    });
148
149                    assignment_id += 1;
150                    remaining_demand -= allocate;
151                    allocated_for_demand += allocate;
152
153                    // Update tracking
154                    if let Some(rem) = team_remaining.get_mut(&team.id) {
155                        *rem -= allocate;
156                    }
157                    if let Some(alloc) = team_allocated.get_mut(&team.id) {
158                        *alloc += allocate;
159                    }
160                }
161            }
162
163            // Track period allocations
164            period_allocations
165                .entry(demand.period_id.clone())
166                .or_default()
167                .push((demand_id.clone(), demand.demand_units, allocated_for_demand));
168        }
169
170        // Build team utilization metrics
171        let team_utilization: Vec<TeamUtilization> = input
172            .teams
173            .iter()
174            .map(|t| {
175                let allocated = *team_allocated.get(&t.id).unwrap_or(&0.0);
176                let utilization_ratio = t.utilization(allocated);
177                TeamUtilization {
178                    team_id: t.id.clone(),
179                    team_name: t.name.clone(),
180                    total_capacity: t.available_capacity,
181                    allocated,
182                    utilization_ratio,
183                    remaining_capacity: t.available_capacity - allocated,
184                    is_over_utilized: utilization_ratio > t.max_utilization,
185                }
186            })
187            .collect();
188
189        // Build period fulfillment metrics
190        let period_fulfillment: Vec<PeriodFulfillment> = {
191            let mut periods: Vec<String> = period_allocations.keys().cloned().collect();
192            periods.sort();
193            periods
194                .into_iter()
195                .map(|period_id| {
196                    let demands = period_allocations.get(&period_id).unwrap();
197                    let total_demand: f64 = demands.iter().map(|(_, req, _)| req).sum();
198                    let total_allocated: f64 = demands.iter().map(|(_, _, alloc)| alloc).sum();
199                    let fulfillment_ratio = if total_demand > 0.0 {
200                        total_allocated / total_demand
201                    } else {
202                        1.0
203                    };
204
205                    let unmet_demands: Vec<UnmetDemand> = demands
206                        .iter()
207                        .filter(|(_, req, alloc)| alloc < req)
208                        .map(|(demand_id, requested, allocated)| {
209                            // Find the original demand to get the skill
210                            let skill = input
211                                .demand_forecasts
212                                .iter()
213                                .find(|d| {
214                                    format!("demand-{}-{}", d.period_id, d.required_skill)
215                                        == *demand_id
216                                })
217                                .map(|d| d.required_skill.clone())
218                                .unwrap_or_default();
219
220                            UnmetDemand {
221                                demand_id: demand_id.clone(),
222                                required_skill: skill,
223                                requested: *requested,
224                                allocated: *allocated,
225                                shortfall: requested - allocated,
226                                reason: "Insufficient matching capacity".to_string(),
227                            }
228                        })
229                        .collect();
230
231                    PeriodFulfillment {
232                        period_id,
233                        total_demand,
234                        total_allocated,
235                        fulfillment_ratio,
236                        unmet_demands,
237                    }
238                })
239                .collect()
240        };
241
242        // Calculate summary statistics
243        let total_demand: f64 = input.demand_forecasts.iter().map(|d| d.demand_units).sum();
244        let total_allocated: f64 = assignments.iter().map(|a| a.allocated_units).sum();
245        let total_cost: f64 = assignments.iter().map(|a| a.cost).sum();
246        let overall_fulfillment_ratio = if total_demand > 0.0 {
247            total_allocated / total_demand
248        } else {
249            1.0
250        };
251        let average_utilization = if !team_utilization.is_empty() {
252            team_utilization
253                .iter()
254                .map(|t| t.utilization_ratio)
255                .sum::<f64>()
256                / team_utilization.len() as f64
257        } else {
258            0.0
259        };
260        let teams_over_capacity = team_utilization
261            .iter()
262            .filter(|t| t.is_over_utilized)
263            .count();
264        let unmet_demands_count = period_fulfillment
265            .iter()
266            .map(|p| p.unmet_demands.len())
267            .sum();
268
269        let plan_status = if overall_fulfillment_ratio >= input.constraints.min_overall_fulfillment
270            && teams_over_capacity == 0
271        {
272            "Feasible plan meets all constraints".to_string()
273        } else if overall_fulfillment_ratio < input.constraints.min_overall_fulfillment {
274            format!(
275                "Fulfillment {:.1}% below minimum {:.1}%",
276                overall_fulfillment_ratio * 100.0,
277                input.constraints.min_overall_fulfillment * 100.0
278            )
279        } else {
280            format!("{} teams exceed maximum utilization", teams_over_capacity)
281        };
282
283        let summary = CapacityPlanSummary {
284            total_demand,
285            total_allocated,
286            overall_fulfillment_ratio,
287            total_cost,
288            average_utilization,
289            teams_over_capacity,
290            unmet_demands: unmet_demands_count,
291            plan_status,
292        };
293
294        let output = CapacityPlanningOutput {
295            assignments,
296            team_utilization,
297            period_fulfillment,
298            summary,
299        };
300
301        // Determine solver report status
302        let replay = ReplayEnvelope::minimal(seed);
303        let report = if overall_fulfillment_ratio >= input.constraints.min_overall_fulfillment {
304            SolverReport::feasible(
305                "match-alloc-v1",
306                overall_fulfillment_ratio,
307                StopReason::Feasible,
308                replay,
309            )
310        } else {
311            SolverReport::infeasible("match-alloc-v1", vec![], StopReason::NoFeasible, replay)
312        };
313
314        Ok((output, report))
315    }
316}
317
318impl PackSolver for MatchAllocationSolver {
319    fn id(&self) -> &'static str {
320        "match-alloc-v1"
321    }
322
323    fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
324        let input: CapacityPlanningInput = spec.inputs_as()?;
325        let (output, report) = self.solve_capacity(&input, spec)?;
326        let json = serde_json::to_value(&output)
327            .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
328        Ok((json, report))
329    }
330
331    fn is_exact(&self) -> bool {
332        false // Greedy algorithm, not guaranteed optimal
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use converge_pack::gate::ObjectiveSpec;
340
341    fn create_test_input() -> CapacityPlanningInput {
342        CapacityPlanningInput {
343            demand_forecasts: vec![
344                DemandForecast {
345                    period_id: "Q1-2024".to_string(),
346                    resource_type: "engineering".to_string(),
347                    required_skill: "backend".to_string(),
348                    demand_units: 100.0,
349                    priority: 1,
350                    min_fulfillment_ratio: 0.8,
351                },
352                DemandForecast {
353                    period_id: "Q1-2024".to_string(),
354                    resource_type: "engineering".to_string(),
355                    required_skill: "frontend".to_string(),
356                    demand_units: 50.0,
357                    priority: 2,
358                    min_fulfillment_ratio: 0.7,
359                },
360            ],
361            resource_types: vec![ResourceType {
362                id: "engineering".to_string(),
363                name: "Engineering Hours".to_string(),
364                unit: "hours".to_string(),
365                cost_per_unit: 100.0,
366            }],
367            teams: vec![
368                Team {
369                    id: "team-a".to_string(),
370                    name: "Backend Team".to_string(),
371                    skills: vec!["backend".to_string()],
372                    resource_types: vec!["engineering".to_string()],
373                    available_capacity: 120.0,
374                    max_utilization: 0.85,
375                    headcount: 6,
376                },
377                Team {
378                    id: "team-b".to_string(),
379                    name: "Frontend Team".to_string(),
380                    skills: vec!["frontend".to_string()],
381                    resource_types: vec!["engineering".to_string()],
382                    available_capacity: 80.0,
383                    max_utilization: 0.85,
384                    headcount: 4,
385                },
386            ],
387            constraints: PlanningConstraints {
388                target_utilization: 0.75,
389                max_budget: Some(20000.0),
390                min_overall_fulfillment: 0.8,
391                allow_cross_team: false,
392                strict_skill_matching: true,
393            },
394        }
395    }
396
397    fn create_spec(input: &CapacityPlanningInput, seed: u64) -> ProblemSpec {
398        ProblemSpec::builder("test", "tenant")
399            .objective(ObjectiveSpec::maximize("fulfillment"))
400            .inputs(input)
401            .unwrap()
402            .seed(seed)
403            .build()
404            .unwrap()
405    }
406
407    #[test]
408    fn test_basic_allocation() {
409        let solver = MatchAllocationSolver;
410        let input = create_test_input();
411        let spec = create_spec(&input, 42);
412
413        let (output, report) = solver.solve_capacity(&input, &spec).unwrap();
414
415        assert!(report.feasible);
416        assert!(!output.assignments.is_empty());
417        assert!(output.summary.overall_fulfillment_ratio > 0.0);
418    }
419
420    #[test]
421    fn test_skill_matching() {
422        let solver = MatchAllocationSolver;
423        let input = create_test_input();
424        let spec = create_spec(&input, 42);
425
426        let (output, _) = solver.solve_capacity(&input, &spec).unwrap();
427
428        // Backend demand should be allocated to team-a (backend team)
429        let backend_assignments: Vec<_> = output
430            .assignments
431            .iter()
432            .filter(|a| a.demand_id.contains("backend"))
433            .collect();
434        assert!(!backend_assignments.is_empty());
435        assert!(backend_assignments.iter().all(|a| a.team_id == "team-a"));
436
437        // Frontend demand should be allocated to team-b (frontend team)
438        let frontend_assignments: Vec<_> = output
439            .assignments
440            .iter()
441            .filter(|a| a.demand_id.contains("frontend"))
442            .collect();
443        assert!(!frontend_assignments.is_empty());
444        assert!(frontend_assignments.iter().all(|a| a.team_id == "team-b"));
445    }
446
447    #[test]
448    fn test_respects_utilization_limits() {
449        let solver = MatchAllocationSolver;
450        let input = create_test_input();
451        let spec = create_spec(&input, 42);
452
453        let (output, _) = solver.solve_capacity(&input, &spec).unwrap();
454
455        // Team A: capacity 120, max_util 0.85 -> effective 102
456        // Backend demand is 100, should fit within effective capacity
457        let team_a_util = output
458            .team_utilization
459            .iter()
460            .find(|t| t.team_id == "team-a")
461            .unwrap();
462        assert!(!team_a_util.is_over_utilized);
463    }
464
465    #[test]
466    fn test_insufficient_capacity() {
467        let solver = MatchAllocationSolver;
468        let mut input = create_test_input();
469        // Increase demand beyond capacity
470        input.demand_forecasts[0].demand_units = 500.0;
471        input.constraints.min_overall_fulfillment = 0.95;
472
473        let spec = create_spec(&input, 42);
474        let (output, report) = solver.solve_capacity(&input, &spec).unwrap();
475
476        // Should be infeasible because we can't meet 95% of 550 units with ~170 effective capacity
477        assert!(!report.feasible);
478        assert!(output.summary.overall_fulfillment_ratio < 0.95);
479    }
480
481    #[test]
482    fn test_cost_calculation() {
483        let solver = MatchAllocationSolver;
484        let input = create_test_input();
485        let spec = create_spec(&input, 42);
486
487        let (output, _) = solver.solve_capacity(&input, &spec).unwrap();
488
489        // Cost should be allocated_units * cost_per_unit
490        for assignment in &output.assignments {
491            assert!((assignment.cost - assignment.allocated_units * 100.0).abs() < 0.01);
492        }
493    }
494
495    #[test]
496    fn test_determinism() {
497        let solver = MatchAllocationSolver;
498        let input = create_test_input();
499
500        let spec1 = create_spec(&input, 12345);
501        let spec2 = create_spec(&input, 12345);
502
503        let (output1, _) = solver.solve_capacity(&input, &spec1).unwrap();
504        let (output2, _) = solver.solve_capacity(&input, &spec2).unwrap();
505
506        assert_eq!(output1.assignments.len(), output2.assignments.len());
507        assert!((output1.summary.total_allocated - output2.summary.total_allocated).abs() < 0.01);
508    }
509
510    #[test]
511    fn test_priority_ordering() {
512        let solver = MatchAllocationSolver;
513        let mut input = create_test_input();
514
515        // Add a third demand with same skill but lower priority
516        input.demand_forecasts.push(DemandForecast {
517            period_id: "Q1-2024".to_string(),
518            resource_type: "engineering".to_string(),
519            required_skill: "backend".to_string(),
520            demand_units: 50.0,
521            priority: 3,
522            min_fulfillment_ratio: 0.5,
523        });
524
525        // Reduce backend team capacity to force prioritization
526        input.teams[0].available_capacity = 100.0;
527        input.teams[0].max_utilization = 1.0;
528
529        let spec = create_spec(&input, 42);
530        let (output, _) = solver.solve_capacity(&input, &spec).unwrap();
531
532        // Higher priority backend demand (100 units, priority 1) should be fully allocated
533        // before lower priority (50 units, priority 3)
534        let total_backend: f64 = output
535            .assignments
536            .iter()
537            .filter(|a| a.demand_id.contains("backend"))
538            .map(|a| a.allocated_units)
539            .sum();
540
541        // With 100 capacity and 150 total backend demand, priority 1 should get full 100
542        assert!(total_backend >= 100.0);
543    }
544
545    #[test]
546    fn test_period_fulfillment_tracking() {
547        let solver = MatchAllocationSolver;
548        let mut input = create_test_input();
549
550        // Add a second period
551        input.demand_forecasts.push(DemandForecast {
552            period_id: "Q2-2024".to_string(),
553            resource_type: "engineering".to_string(),
554            required_skill: "backend".to_string(),
555            demand_units: 30.0,
556            priority: 1,
557            min_fulfillment_ratio: 0.8,
558        });
559
560        let spec = create_spec(&input, 42);
561        let (output, _) = solver.solve_capacity(&input, &spec).unwrap();
562
563        // Should have fulfillment records for both periods
564        assert!(output.period_fulfillment.len() >= 2);
565
566        let q1 = output
567            .period_fulfillment
568            .iter()
569            .find(|p| p.period_id == "Q1-2024");
570        let q2 = output
571            .period_fulfillment
572            .iter()
573            .find(|p| p.period_id == "Q2-2024");
574
575        assert!(q1.is_some());
576        assert!(q2.is_some());
577    }
578}