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 ScoreBasedRoutingSolver;
18
19impl ScoreBasedRoutingSolver {
20 pub fn solve_routing(
22 &self,
23 input: &LeadRoutingInput,
24 spec: &ProblemSpec,
25 ) -> Result<(LeadRoutingOutput, SolverReport)> {
26 let seed = spec.seed();
27 let config = &input.config;
28
29 let mut rep_loads: HashMap<String, i64> = input
31 .reps
32 .iter()
33 .map(|r| (r.id.clone(), r.current_load))
34 .collect();
35
36 let mut rep_new_assignments: HashMap<String, i64> =
37 input.reps.iter().map(|r| (r.id.clone(), 0)).collect();
38
39 let mut sorted_leads: Vec<&Lead> = input.leads.iter().collect();
41 sorted_leads.sort_by(|a, b| {
42 a.priority
43 .cmp(&b.priority)
44 .then_with(|| b.score.total_cmp(&a.score))
45 .then_with(|| a.id.cmp(&b.id))
46 });
47
48 let mut assignments = Vec::new();
49 let mut unassigned = Vec::new();
50 let mut total_fit_score = 0.0;
51 let mut total_value = 0.0;
52
53 for lead in sorted_leads {
55 let assignment_result = self.find_best_rep(lead, input, config, &rep_loads, spec);
56
57 match assignment_result {
58 Some((rep, fit_score, rationale)) => {
59 *rep_loads.get_mut(&rep.id).unwrap() += 1;
61 *rep_new_assignments.get_mut(&rep.id).unwrap() += 1;
62
63 assignments.push(LeadAssignment {
64 lead_id: lead.id.clone(),
65 rep_id: rep.id.clone(),
66 rep_name: rep.name.clone(),
67 fit_score,
68 scoring_rationale: rationale,
69 });
70
71 total_fit_score += fit_score;
72 total_value += lead.estimated_value;
73 }
74 None => {
75 let reason = self.determine_unassigned_reason(lead, input, config, &rep_loads);
76 unassigned.push(UnassignedLead {
77 lead_id: lead.id.clone(),
78 reason,
79 });
80 }
81 }
82 }
83
84 let rep_utilization: Vec<RepUtilization> = input
86 .reps
87 .iter()
88 .filter(|r| rep_new_assignments.get(&r.id).copied().unwrap_or(0) > 0)
89 .map(|r| {
90 let new_assignments = rep_new_assignments.get(&r.id).copied().unwrap_or(0);
91 let total_load = rep_loads.get(&r.id).copied().unwrap_or(r.current_load);
92 RepUtilization {
93 rep_id: r.id.clone(),
94 rep_name: r.name.clone(),
95 new_assignments,
96 total_load,
97 capacity: r.capacity,
98 utilization_pct: (total_load as f64 / r.capacity as f64) * 100.0,
99 }
100 })
101 .collect();
102
103 let avg_fit = if !assignments.is_empty() {
104 total_fit_score / assignments.len() as f64
105 } else {
106 0.0
107 };
108
109 let stats = RoutingStats {
110 total_leads: input.leads.len(),
111 assigned_leads: assignments.len(),
112 unassigned_leads: unassigned.len(),
113 average_fit_score: avg_fit,
114 total_estimated_value: total_value,
115 summary: if unassigned.is_empty() {
116 format!("All {} leads assigned successfully", assignments.len())
117 } else {
118 format!(
119 "Assigned {} leads, {} could not be assigned",
120 assignments.len(),
121 unassigned.len()
122 )
123 },
124 };
125
126 let output = LeadRoutingOutput {
127 assignments,
128 unassigned,
129 rep_utilization,
130 stats,
131 };
132
133 let replay = ReplayEnvelope::minimal(seed);
134 let report = if output.stats.assigned_leads > 0 {
135 SolverReport::optimal("score-routing-v1", avg_fit, replay)
136 } else {
137 SolverReport::infeasible("score-routing-v1", vec![], StopReason::NoFeasible, replay)
138 };
139
140 Ok((output, report))
141 }
142
143 fn find_best_rep<'a>(
145 &self,
146 lead: &Lead,
147 input: &'a LeadRoutingInput,
148 config: &RoutingConfig,
149 rep_loads: &HashMap<String, i64>,
150 spec: &ProblemSpec,
151 ) -> Option<(&'a SalesRep, f64, ScoringRationale)> {
152 let tie_break = &spec.determinism.tie_break;
153 let seed = spec.seed();
154
155 let mut candidates: Vec<(&SalesRep, f64, ScoringRationale)> = input
157 .reps
158 .iter()
159 .filter_map(|rep| {
160 let current_load = rep_loads.get(&rep.id).copied().unwrap_or(rep.current_load);
162 if current_load >= rep.capacity {
163 return None;
164 }
165
166 if config.require_territory_match && !rep.covers_territory(&lead.territory) {
168 return None;
169 }
170
171 let (fit_score, rationale) =
173 self.calculate_detailed_score(lead, rep, current_load, config);
174
175 if fit_score < 10.0 {
177 return None;
178 }
179
180 Some((rep, fit_score, rationale))
181 })
182 .collect();
183
184 if candidates.is_empty() {
185 return None;
186 }
187
188 candidates.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.id.cmp(&b.0.id)));
190
191 let best_score = candidates[0].1;
193 let tied: Vec<_> = candidates
194 .iter()
195 .filter(|(_, score, _)| (score - best_score).abs() < 0.01)
196 .collect();
197
198 if tied.len() == 1 {
199 let (rep, score, rationale) = candidates.remove(0);
200 Some((rep, score, rationale))
201 } else {
202 let selected = tie_break.select_by(&tied, seed, |a, b| a.0.id.cmp(&b.0.id));
204
205 if let Some(&(rep, score, rationale)) = selected {
206 Some((rep, *score, rationale.clone()))
207 } else {
208 let (rep, score, rationale) = candidates.remove(0);
210 Some((rep, score, rationale))
211 }
212 }
213 }
214
215 fn calculate_detailed_score(
217 &self,
218 lead: &Lead,
219 rep: &SalesRep,
220 current_load: i64,
221 config: &RoutingConfig,
222 ) -> (f64, ScoringRationale) {
223 let territory_score = if rep.covers_territory(&lead.territory) {
225 100.0
226 } else {
227 0.0
228 };
229
230 let segment_score = if rep.segments.contains(&lead.segment) {
231 100.0
232 } else if rep.segments.is_empty() {
233 50.0 } else {
235 0.0
236 };
237
238 let skills_score = if lead.required_skills.is_empty() {
239 100.0
240 } else {
241 let matched = lead
242 .required_skills
243 .iter()
244 .filter(|s| rep.skills.contains(*s))
245 .count();
246 (matched as f64 / lead.required_skills.len() as f64) * 100.0
247 };
248
249 let performance_score = rep.performance_score;
250
251 let capacity_factor = if config.balance_load {
253 let utilization = current_load as f64 / rep.capacity as f64;
254 1.0 - (utilization * 0.4) } else {
256 1.0
257 };
258
259 let raw_score = (territory_score * config.territory_weight)
261 + (segment_score * config.expertise_weight * 0.5)
262 + (skills_score * config.expertise_weight * 0.5)
263 + (performance_score * 0.1);
264
265 let final_score = raw_score * capacity_factor;
266
267 let mut explanation_parts = Vec::new();
269 if territory_score > 0.0 {
270 explanation_parts.push("territory match".to_string());
271 }
272 if segment_score >= 100.0 {
273 explanation_parts.push("segment match".to_string());
274 }
275 if skills_score >= 100.0 && !lead.required_skills.is_empty() {
276 explanation_parts.push("full skills match".to_string());
277 } else if skills_score > 0.0 && skills_score < 100.0 {
278 explanation_parts.push("partial skills match".to_string());
279 }
280 if capacity_factor < 1.0 {
281 explanation_parts.push(format!("load factor {:.0}%", capacity_factor * 100.0));
282 }
283
284 let explanation = if explanation_parts.is_empty() {
285 "baseline score".to_string()
286 } else {
287 explanation_parts.join(", ")
288 };
289
290 let rationale = ScoringRationale {
291 territory_score,
292 segment_score,
293 skills_score,
294 performance_score,
295 capacity_factor,
296 explanation,
297 };
298
299 (final_score, rationale)
300 }
301
302 fn determine_unassigned_reason(
304 &self,
305 lead: &Lead,
306 input: &LeadRoutingInput,
307 config: &RoutingConfig,
308 rep_loads: &HashMap<String, i64>,
309 ) -> String {
310 let any_capacity = input.reps.iter().any(|r| {
312 let load = rep_loads.get(&r.id).copied().unwrap_or(r.current_load);
313 load < r.capacity
314 });
315
316 if !any_capacity {
317 return "All reps are at full capacity".to_string();
318 }
319
320 if config.require_territory_match {
322 let territory_reps: Vec<_> = input
323 .reps
324 .iter()
325 .filter(|r| r.covers_territory(&lead.territory))
326 .collect();
327
328 if territory_reps.is_empty() {
329 return format!("No reps cover territory '{}'", lead.territory);
330 }
331
332 let available_territory_reps: Vec<_> = territory_reps
333 .iter()
334 .filter(|r| {
335 let load = rep_loads.get(&r.id).copied().unwrap_or(r.current_load);
336 load < r.capacity
337 })
338 .collect();
339
340 if available_territory_reps.is_empty() {
341 return format!(
342 "All reps covering territory '{}' are at capacity",
343 lead.territory
344 );
345 }
346 }
347
348 "No suitable rep found with available capacity".to_string()
350 }
351}
352
353impl PackSolver for ScoreBasedRoutingSolver {
354 fn id(&self) -> &'static str {
355 "score-routing-v1"
356 }
357
358 fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
359 let input: LeadRoutingInput = spec.inputs_as()?;
360 let (output, report) = self.solve_routing(&input, spec)?;
361 let json = serde_json::to_value(&output)
362 .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
363 Ok((json, report))
364 }
365
366 fn is_exact(&self) -> bool {
367 false }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use converge_pack::gate::ObjectiveSpec;
375
376 fn create_test_input() -> LeadRoutingInput {
377 LeadRoutingInput {
378 leads: vec![
379 Lead {
380 id: "lead-1".to_string(),
381 score: 85.0,
382 territory: "west".to_string(),
383 segment: "enterprise".to_string(),
384 required_skills: vec!["cloud".to_string()],
385 estimated_value: 100000.0,
386 priority: 1,
387 },
388 Lead {
389 id: "lead-2".to_string(),
390 score: 70.0,
391 territory: "east".to_string(),
392 segment: "smb".to_string(),
393 required_skills: vec![],
394 estimated_value: 25000.0,
395 priority: 3,
396 },
397 Lead {
398 id: "lead-3".to_string(),
399 score: 90.0,
400 territory: "west".to_string(),
401 segment: "enterprise".to_string(),
402 required_skills: vec!["cloud".to_string(), "ai".to_string()],
403 estimated_value: 200000.0,
404 priority: 1,
405 },
406 ],
407 reps: vec![
408 SalesRep {
409 id: "rep-1".to_string(),
410 name: "Alice Johnson".to_string(),
411 capacity: 10,
412 current_load: 7,
413 territories: vec!["west".to_string()],
414 segments: vec!["enterprise".to_string()],
415 skills: vec!["cloud".to_string(), "ai".to_string()],
416 performance_score: 92.0,
417 },
418 SalesRep {
419 id: "rep-2".to_string(),
420 name: "Bob Smith".to_string(),
421 capacity: 8,
422 current_load: 3,
423 territories: vec!["east".to_string(), "midwest".to_string()],
424 segments: vec!["smb".to_string(), "mid-market".to_string()],
425 skills: vec!["demos".to_string()],
426 performance_score: 78.0,
427 },
428 SalesRep {
429 id: "rep-3".to_string(),
430 name: "Carol Davis".to_string(),
431 capacity: 12,
432 current_load: 5,
433 territories: vec!["west".to_string(), "east".to_string()],
434 segments: vec!["enterprise".to_string(), "smb".to_string()],
435 skills: vec!["cloud".to_string()],
436 performance_score: 85.0,
437 },
438 ],
439 config: RoutingConfig::default(),
440 }
441 }
442
443 fn create_spec(input: &LeadRoutingInput, seed: u64) -> ProblemSpec {
444 ProblemSpec::builder("test", "tenant")
445 .objective(ObjectiveSpec::maximize("conversion"))
446 .inputs(input)
447 .unwrap()
448 .seed(seed)
449 .build()
450 .unwrap()
451 }
452
453 #[test]
454 fn test_basic_routing() {
455 let solver = ScoreBasedRoutingSolver;
456 let input = create_test_input();
457 let spec = create_spec(&input, 42);
458
459 let (output, report) = solver.solve_routing(&input, &spec).unwrap();
460
461 assert!(report.feasible);
462 assert_eq!(output.stats.total_leads, 3);
463 assert_eq!(output.stats.assigned_leads, 3);
464 assert!(output.unassigned.is_empty());
465 }
466
467 #[test]
468 fn test_territory_matching() {
469 let solver = ScoreBasedRoutingSolver;
470 let input = create_test_input();
471 let spec = create_spec(&input, 42);
472
473 let (output, _) = solver.solve_routing(&input, &spec).unwrap();
474
475 for assignment in &output.assignments {
477 let lead = input
478 .leads
479 .iter()
480 .find(|l| l.id == assignment.lead_id)
481 .unwrap();
482 let rep = input
483 .reps
484 .iter()
485 .find(|r| r.id == assignment.rep_id)
486 .unwrap();
487
488 assert!(
490 rep.covers_territory(&lead.territory) || !input.config.require_territory_match,
491 "Lead {} in {} assigned to rep {} covering {:?}",
492 lead.id,
493 lead.territory,
494 rep.id,
495 rep.territories
496 );
497 }
498 }
499
500 #[test]
501 fn test_capacity_constraint() {
502 let solver = ScoreBasedRoutingSolver;
503 let mut input = create_test_input();
504
505 for rep in &mut input.reps {
507 rep.current_load = rep.capacity - 1;
508 }
509
510 for i in 0..10 {
512 input.leads.push(Lead {
513 id: format!("overflow-{}", i),
514 score: 50.0,
515 territory: "west".to_string(),
516 segment: "smb".to_string(),
517 required_skills: vec![],
518 estimated_value: 10000.0,
519 priority: 5,
520 });
521 }
522
523 let spec = create_spec(&input, 42);
524 let (output, _) = solver.solve_routing(&input, &spec).unwrap();
525
526 assert!(!output.unassigned.is_empty());
528
529 for util in &output.rep_utilization {
531 assert!(util.total_load <= util.capacity);
532 }
533 }
534
535 #[test]
536 fn test_priority_ordering() {
537 let solver = ScoreBasedRoutingSolver;
538 let input = LeadRoutingInput {
539 leads: vec![
540 Lead {
541 id: "low-priority".to_string(),
542 score: 95.0,
543 territory: "west".to_string(),
544 segment: "enterprise".to_string(),
545 required_skills: vec![],
546 estimated_value: 500000.0,
547 priority: 5, },
549 Lead {
550 id: "high-priority".to_string(),
551 score: 60.0,
552 territory: "west".to_string(),
553 segment: "enterprise".to_string(),
554 required_skills: vec![],
555 estimated_value: 50000.0,
556 priority: 1, },
558 ],
559 reps: vec![SalesRep {
560 id: "rep-1".to_string(),
561 name: "Only Rep".to_string(),
562 capacity: 1, current_load: 0,
564 territories: vec!["west".to_string()],
565 segments: vec!["enterprise".to_string()],
566 skills: vec![],
567 performance_score: 80.0,
568 }],
569 config: RoutingConfig::default(),
570 };
571
572 let spec = create_spec(&input, 42);
573 let (output, _) = solver.solve_routing(&input, &spec).unwrap();
574
575 assert_eq!(output.assignments.len(), 1);
577 assert_eq!(output.assignments[0].lead_id, "high-priority");
578 assert_eq!(output.unassigned.len(), 1);
579 assert_eq!(output.unassigned[0].lead_id, "low-priority");
580 }
581
582 #[test]
583 fn test_required_territory_match() {
584 let solver = ScoreBasedRoutingSolver;
585 let input = LeadRoutingInput {
586 leads: vec![Lead {
587 id: "lead-1".to_string(),
588 score: 80.0,
589 territory: "south".to_string(), segment: "enterprise".to_string(),
591 required_skills: vec![],
592 estimated_value: 50000.0,
593 priority: 1,
594 }],
595 reps: vec![SalesRep {
596 id: "rep-1".to_string(),
597 name: "Rep 1".to_string(),
598 capacity: 10,
599 current_load: 0,
600 territories: vec!["west".to_string(), "east".to_string()],
601 segments: vec!["enterprise".to_string()],
602 skills: vec![],
603 performance_score: 80.0,
604 }],
605 config: RoutingConfig {
606 require_territory_match: true,
607 ..Default::default()
608 },
609 };
610
611 let spec = create_spec(&input, 42);
612 let (output, _) = solver.solve_routing(&input, &spec).unwrap();
613
614 assert!(output.assignments.is_empty());
616 assert_eq!(output.unassigned.len(), 1);
617 assert!(output.unassigned[0].reason.contains("territory"));
618 }
619
620 #[test]
621 fn test_scoring_rationale() {
622 let solver = ScoreBasedRoutingSolver;
623 let input = create_test_input();
624 let spec = create_spec(&input, 42);
625
626 let (output, _) = solver.solve_routing(&input, &spec).unwrap();
627
628 for assignment in &output.assignments {
629 let rationale = &assignment.scoring_rationale;
631 assert!(!rationale.explanation.is_empty());
632 assert!(rationale.capacity_factor > 0.0);
633 assert!(rationale.capacity_factor <= 1.0);
634 }
635 }
636
637 #[test]
638 fn test_determinism() {
639 let solver = ScoreBasedRoutingSolver;
640 let input = create_test_input();
641
642 let spec1 = create_spec(&input, 12345);
643 let spec2 = create_spec(&input, 12345);
644
645 let (output1, _) = solver.solve_routing(&input, &spec1).unwrap();
646 let (output2, _) = solver.solve_routing(&input, &spec2).unwrap();
647
648 assert_eq!(output1.assignments.len(), output2.assignments.len());
649 for (a1, a2) in output1.assignments.iter().zip(output2.assignments.iter()) {
650 assert_eq!(a1.lead_id, a2.lead_id);
651 assert_eq!(a1.rep_id, a2.rep_id);
652 }
653 }
654
655 #[test]
656 fn test_rep_utilization_output() {
657 let solver = ScoreBasedRoutingSolver;
658 let input = create_test_input();
659 let spec = create_spec(&input, 42);
660
661 let (output, _) = solver.solve_routing(&input, &spec).unwrap();
662
663 for util in &output.rep_utilization {
665 assert!(util.utilization_pct >= 0.0);
666 assert!(util.utilization_pct <= 100.0);
667 assert!(util.new_assignments > 0);
668 }
669 }
670}