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