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