1use super::types::*;
4use crate::Result;
5use crate::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
6use crate::packs::PackSolver;
7use std::collections::HashMap;
8
9pub struct MatchAllocationSolver;
17
18impl MatchAllocationSolver {
19 pub fn solve_capacity(
21 &self,
22 input: &CapacityPlanningInput,
23 spec: &ProblemSpec,
24 ) -> Result<(CapacityPlanningOutput, SolverReport)> {
25 let seed = spec.seed();
26
27 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 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 let mut team_allocated: HashMap<String, f64> =
43 input.teams.iter().map(|t| (t.id.clone(), 0.0)).collect();
44
45 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 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(); let mut assignment_id = 0;
61
62 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 let mut matching_teams: Vec<&Team> = input
69 .teams
70 .iter()
71 .filter(|t| {
72 let skill_match = if input.constraints.strict_skill_matching {
74 t.has_skill(&demand.required_skill)
75 } else {
76 true
77 };
78
79 let resource_match = t.provides_resource_type(&demand.resource_type);
81
82 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 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 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 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 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 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 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 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 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 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 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}