Skip to main content

converge_optimization/packs/capacity_planning/
solver.rs

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