converge_optimization/packs/lead_routing/
mod.rs1mod invariants;
36mod solver;
37mod types;
38
39pub use invariants::*;
40pub use solver::*;
41pub use types::*;
42
43use crate::Result;
44use crate::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
45use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
46
47pub struct LeadRoutingPack;
49
50impl Pack for LeadRoutingPack {
51 fn name(&self) -> &'static str {
52 "lead-routing"
53 }
54
55 fn version(&self) -> &'static str {
56 "1.0.0"
57 }
58
59 fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
60 let input: LeadRoutingInput = serde_json::from_value(inputs.clone())
61 .map_err(|e| crate::Error::invalid_input(format!("Invalid input: {}", e)))?;
62 input.validate()
63 }
64
65 fn invariants(&self) -> &[InvariantDef] {
66 INVARIANTS
67 }
68
69 fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
70 let input: LeadRoutingInput = spec.inputs_as()?;
71 input.validate()?;
72
73 let solver = ScoreBasedRoutingSolver;
74 let (output, report) = solver.solve_routing(&input, spec)?;
75
76 let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
77 let confidence = calculate_confidence(&output, &input);
78
79 let plan = ProposedPlan::from_payload(
80 format!("plan-{}", spec.problem_id),
81 self.name(),
82 output.summary(),
83 &output,
84 confidence,
85 trace,
86 )?;
87
88 Ok(PackSolveResult::new(plan, report))
89 }
90
91 fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
92 let output: LeadRoutingOutput = plan.plan_as()?;
93
94 let input = LeadRoutingInput {
98 leads: output
99 .assignments
100 .iter()
101 .map(|a| Lead {
102 id: a.lead_id.clone(),
103 score: 0.0,
104 territory: String::new(),
105 segment: String::new(),
106 required_skills: vec![],
107 estimated_value: 0.0,
108 priority: 5,
109 })
110 .chain(output.unassigned.iter().map(|u| Lead {
111 id: u.lead_id.clone(),
112 score: 0.0,
113 territory: String::new(),
114 segment: String::new(),
115 required_skills: vec![],
116 estimated_value: 0.0,
117 priority: 5,
118 }))
119 .collect(),
120 reps: output
121 .rep_utilization
122 .iter()
123 .map(|r| SalesRep {
124 id: r.rep_id.clone(),
125 name: r.rep_name.clone(),
126 capacity: r.capacity,
127 current_load: r.total_load - r.new_assignments,
128 territories: vec![],
129 segments: vec![],
130 skills: vec![],
131 performance_score: 50.0,
132 })
133 .collect(),
134 config: RoutingConfig::default(),
135 };
136
137 Ok(check_all_invariants(&output, &input))
138 }
139
140 fn evaluate_gate(
141 &self,
142 _plan: &ProposedPlan,
143 invariant_results: &[InvariantResult],
144 ) -> PromotionGate {
145 default_gate_evaluation(invariant_results, &get_invariants())
146 }
147}
148
149fn calculate_confidence(output: &LeadRoutingOutput, _input: &LeadRoutingInput) -> f64 {
150 if output.assignments.is_empty() {
151 return 0.0;
152 }
153
154 let mut confidence = 0.5;
155
156 if output.unassigned.is_empty() {
158 confidence += 0.25;
159 } else {
160 let assignment_rate = output.stats.assigned_leads as f64 / output.stats.total_leads as f64;
162 confidence += 0.25 * assignment_rate;
163 }
164
165 if output.stats.average_fit_score >= 70.0 {
167 confidence += 0.15;
168 } else if output.stats.average_fit_score >= 50.0 {
169 confidence += 0.1;
170 }
171
172 if output.rep_utilization.len() >= 2 {
174 let utilizations: Vec<f64> = output
175 .rep_utilization
176 .iter()
177 .map(|u| u.utilization_pct)
178 .collect();
179 let avg: f64 = utilizations.iter().sum::<f64>() / utilizations.len() as f64;
180 let variance: f64 =
181 utilizations.iter().map(|u| (u - avg).powi(2)).sum::<f64>() / utilizations.len() as f64;
182 let std_dev = variance.sqrt();
183
184 if std_dev <= 15.0 {
185 confidence += 0.1;
186 }
187 } else {
188 confidence += 0.05;
189 }
190
191 confidence.min(1.0)
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::gate::ObjectiveSpec;
198
199 fn create_test_input() -> LeadRoutingInput {
200 LeadRoutingInput {
201 leads: vec![
202 Lead {
203 id: "lead-1".to_string(),
204 score: 85.0,
205 territory: "west".to_string(),
206 segment: "enterprise".to_string(),
207 required_skills: vec!["cloud".to_string()],
208 estimated_value: 100000.0,
209 priority: 1,
210 },
211 Lead {
212 id: "lead-2".to_string(),
213 score: 70.0,
214 territory: "east".to_string(),
215 segment: "smb".to_string(),
216 required_skills: vec![],
217 estimated_value: 25000.0,
218 priority: 3,
219 },
220 ],
221 reps: vec![
222 SalesRep {
223 id: "rep-1".to_string(),
224 name: "Alice Johnson".to_string(),
225 capacity: 10,
226 current_load: 5,
227 territories: vec!["west".to_string()],
228 segments: vec!["enterprise".to_string()],
229 skills: vec!["cloud".to_string()],
230 performance_score: 92.0,
231 },
232 SalesRep {
233 id: "rep-2".to_string(),
234 name: "Bob Smith".to_string(),
235 capacity: 8,
236 current_load: 3,
237 territories: vec!["east".to_string()],
238 segments: vec!["smb".to_string()],
239 skills: vec!["demos".to_string()],
240 performance_score: 78.0,
241 },
242 ],
243 config: RoutingConfig::default(),
244 }
245 }
246
247 #[test]
248 fn test_pack_name() {
249 let pack = LeadRoutingPack;
250 assert_eq!(pack.name(), "lead-routing");
251 assert_eq!(pack.version(), "1.0.0");
252 }
253
254 #[test]
255 fn test_validate_inputs() {
256 let pack = LeadRoutingPack;
257 let input = create_test_input();
258 let json = serde_json::to_value(&input).unwrap();
259 assert!(pack.validate_inputs(&json).is_ok());
260 }
261
262 #[test]
263 fn test_validate_inputs_empty_leads() {
264 let pack = LeadRoutingPack;
265 let input = LeadRoutingInput {
266 leads: vec![],
267 reps: vec![SalesRep {
268 id: "rep-1".to_string(),
269 name: "Test".to_string(),
270 capacity: 10,
271 current_load: 0,
272 territories: vec![],
273 segments: vec![],
274 skills: vec![],
275 performance_score: 50.0,
276 }],
277 config: RoutingConfig::default(),
278 };
279 let json = serde_json::to_value(&input).unwrap();
280 assert!(pack.validate_inputs(&json).is_err());
281 }
282
283 #[test]
284 fn test_solve_basic() {
285 let pack = LeadRoutingPack;
286 let input = create_test_input();
287
288 let spec = ProblemSpec::builder("test-001", "test-tenant")
289 .objective(ObjectiveSpec::maximize("conversion"))
290 .inputs(&input)
291 .unwrap()
292 .seed(42)
293 .build()
294 .unwrap();
295
296 let result = pack.solve(&spec).unwrap();
297 assert!(result.is_feasible());
298
299 let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
300 assert_eq!(output.stats.total_leads, 2);
301 assert_eq!(output.stats.assigned_leads, 2);
302 assert!(output.unassigned.is_empty());
303 }
304
305 #[test]
306 fn test_solve_with_territory_routing() {
307 let pack = LeadRoutingPack;
308 let input = create_test_input();
309
310 let spec = ProblemSpec::builder("test-002", "test-tenant")
311 .objective(ObjectiveSpec::maximize("conversion"))
312 .inputs(&input)
313 .unwrap()
314 .seed(42)
315 .build()
316 .unwrap();
317
318 let result = pack.solve(&spec).unwrap();
319 let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
320
321 for assignment in &output.assignments {
323 if assignment.lead_id == "lead-1" {
324 assert_eq!(assignment.rep_id, "rep-1");
326 } else if assignment.lead_id == "lead-2" {
327 assert_eq!(assignment.rep_id, "rep-2");
329 }
330 }
331 }
332
333 #[test]
334 fn test_check_invariants() {
335 let pack = LeadRoutingPack;
336 let input = create_test_input();
337
338 let spec = ProblemSpec::builder("test-003", "test-tenant")
339 .objective(ObjectiveSpec::maximize("conversion"))
340 .inputs(&input)
341 .unwrap()
342 .seed(42)
343 .build()
344 .unwrap();
345
346 let result = pack.solve(&spec).unwrap();
347 let invariants = pack.check_invariants(&result.plan).unwrap();
348
349 let critical_pass = invariants
351 .iter()
352 .filter(|r| {
353 r.invariant == "all_leads_assigned" || r.invariant == "capacity_not_exceeded"
354 })
355 .all(|r| r.passed);
356 assert!(critical_pass);
357 }
358
359 #[test]
360 fn test_gate_promotes() {
361 let pack = LeadRoutingPack;
362 let input = create_test_input();
363
364 let spec = ProblemSpec::builder("test-004", "test-tenant")
365 .objective(ObjectiveSpec::maximize("conversion"))
366 .inputs(&input)
367 .unwrap()
368 .seed(42)
369 .build()
370 .unwrap();
371
372 let result = pack.solve(&spec).unwrap();
373 let invariants = pack.check_invariants(&result.plan).unwrap();
374 let gate = pack.evaluate_gate(&result.plan, &invariants);
375
376 assert!(!gate.is_rejected());
378 }
379
380 #[test]
381 fn test_determinism() {
382 let pack = LeadRoutingPack;
383 let input = create_test_input();
384
385 let spec1 = ProblemSpec::builder("test-a", "tenant")
386 .objective(ObjectiveSpec::maximize("conversion"))
387 .inputs(&input)
388 .unwrap()
389 .seed(99999)
390 .build()
391 .unwrap();
392
393 let spec2 = ProblemSpec::builder("test-b", "tenant")
394 .objective(ObjectiveSpec::maximize("conversion"))
395 .inputs(&input)
396 .unwrap()
397 .seed(99999)
398 .build()
399 .unwrap();
400
401 let result1 = pack.solve(&spec1).unwrap();
402 let result2 = pack.solve(&spec2).unwrap();
403
404 let output1: LeadRoutingOutput = result1.plan.plan_as().unwrap();
405 let output2: LeadRoutingOutput = result2.plan.plan_as().unwrap();
406
407 assert_eq!(output1.assignments.len(), output2.assignments.len());
408 for (a1, a2) in output1.assignments.iter().zip(output2.assignments.iter()) {
409 assert_eq!(a1.lead_id, a2.lead_id);
410 assert_eq!(a1.rep_id, a2.rep_id);
411 }
412 }
413
414 #[test]
415 fn test_capacity_constraint() {
416 let pack = LeadRoutingPack;
417 let mut input = create_test_input();
418
419 for i in 0..20 {
421 input.leads.push(Lead {
422 id: format!("lead-extra-{}", i),
423 score: 60.0,
424 territory: "west".to_string(),
425 segment: "enterprise".to_string(),
426 required_skills: vec![],
427 estimated_value: 30000.0,
428 priority: 5,
429 });
430 }
431
432 let spec = ProblemSpec::builder("test-005", "test-tenant")
433 .objective(ObjectiveSpec::maximize("conversion"))
434 .inputs(&input)
435 .unwrap()
436 .seed(42)
437 .build()
438 .unwrap();
439
440 let result = pack.solve(&spec).unwrap();
441 let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
442
443 for util in &output.rep_utilization {
445 assert!(
446 util.total_load <= util.capacity,
447 "Rep {} has load {} but capacity {}",
448 util.rep_name,
449 util.total_load,
450 util.capacity
451 );
452 }
453
454 assert!(!output.unassigned.is_empty());
456 }
457
458 #[test]
459 fn test_scoring_rationale_included() {
460 let pack = LeadRoutingPack;
461 let input = create_test_input();
462
463 let spec = ProblemSpec::builder("test-006", "test-tenant")
464 .objective(ObjectiveSpec::maximize("conversion"))
465 .inputs(&input)
466 .unwrap()
467 .seed(42)
468 .build()
469 .unwrap();
470
471 let result = pack.solve(&spec).unwrap();
472 let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
473
474 for assignment in &output.assignments {
475 assert!(assignment.fit_score > 0.0);
476 assert!(!assignment.scoring_rationale.explanation.is_empty());
477 }
478 }
479
480 #[test]
481 fn test_rep_utilization_output() {
482 let pack = LeadRoutingPack;
483 let input = create_test_input();
484
485 let spec = ProblemSpec::builder("test-007", "test-tenant")
486 .objective(ObjectiveSpec::maximize("conversion"))
487 .inputs(&input)
488 .unwrap()
489 .seed(42)
490 .build()
491 .unwrap();
492
493 let result = pack.solve(&spec).unwrap();
494 let output: LeadRoutingOutput = result.plan.plan_as().unwrap();
495
496 assert!(!output.rep_utilization.is_empty());
498
499 for util in &output.rep_utilization {
500 assert!(util.new_assignments > 0);
501 assert!(util.utilization_pct >= 0.0);
502 assert!(util.utilization_pct <= 100.0);
503 }
504 }
505}