1use 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
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
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(); 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.total_cmp(&cap_a).then_with(|| a.id.cmp(&b.id))
94 });
95
96 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 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 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 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 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 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 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 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 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}