1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum CapabilityHome {
10 ConvergeOptimization,
12 Ferrox,
14 ExternalSmt,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ExposureKind {
21 Suggestor,
23 PackSuggestor,
25 Deferred,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ProblemClass {
32 TaskScheduling,
33 JobShopScheduling,
34 TimeWindowRouting,
35 Assignment,
36 NetworkFlow,
37 LinearProgramming,
38 MixedIntegerProgramming,
39 ConstraintProgramming,
40 FormationAssembly,
41 LogicalCounterexample,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SelectionGoal {
47 FastBaseline,
49 StrongerOptimization,
51 GeneralModeling,
53 LogicalCounterexample,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum CommonUseCase {
60 FieldCrewScheduling,
61 FactoryJobShop,
62 DeliveryTimeWindows,
63 AssignmentMatching,
64 SourceSinkFlow,
65 LinearProgram,
66 MixedIntegerProgram,
67 CustomCpSatModel,
68 FormationAssembly,
69 CedarPolicyCounterexample,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct SelectionRequest {
75 pub problem_class: ProblemClass,
76 pub goal: SelectionGoal,
77 pub native_available: bool,
79 pub prefer_pack: bool,
81}
82
83impl SelectionRequest {
84 #[must_use]
85 pub const fn new(problem_class: ProblemClass, goal: SelectionGoal) -> Self {
86 Self {
87 problem_class,
88 goal,
89 native_available: true,
90 prefer_pack: false,
91 }
92 }
93
94 #[must_use]
95 pub const fn without_native(mut self) -> Self {
96 self.native_available = false;
97 self
98 }
99
100 #[must_use]
101 pub const fn prefer_pack(mut self) -> Self {
102 self.prefer_pack = true;
103 self
104 }
105}
106
107impl From<CommonUseCase> for SelectionRequest {
108 fn from(use_case: CommonUseCase) -> Self {
109 match use_case {
110 CommonUseCase::FieldCrewScheduling => Self::new(
111 ProblemClass::TaskScheduling,
112 SelectionGoal::StrongerOptimization,
113 ),
114 CommonUseCase::FactoryJobShop => Self::new(
115 ProblemClass::JobShopScheduling,
116 SelectionGoal::StrongerOptimization,
117 ),
118 CommonUseCase::DeliveryTimeWindows => Self::new(
119 ProblemClass::TimeWindowRouting,
120 SelectionGoal::StrongerOptimization,
121 ),
122 CommonUseCase::AssignmentMatching => {
123 Self::new(ProblemClass::Assignment, SelectionGoal::FastBaseline)
124 }
125 CommonUseCase::SourceSinkFlow => Self::new(
126 ProblemClass::NetworkFlow,
127 SelectionGoal::StrongerOptimization,
128 ),
129 CommonUseCase::LinearProgram => Self::new(
130 ProblemClass::LinearProgramming,
131 SelectionGoal::StrongerOptimization,
132 ),
133 CommonUseCase::MixedIntegerProgram => Self::new(
134 ProblemClass::MixedIntegerProgramming,
135 SelectionGoal::StrongerOptimization,
136 ),
137 CommonUseCase::CustomCpSatModel => Self::new(
138 ProblemClass::ConstraintProgramming,
139 SelectionGoal::GeneralModeling,
140 ),
141 CommonUseCase::FormationAssembly => Self::new(
142 ProblemClass::FormationAssembly,
143 SelectionGoal::StrongerOptimization,
144 ),
145 CommonUseCase::CedarPolicyCounterexample => Self::new(
146 ProblemClass::LogicalCounterexample,
147 SelectionGoal::LogicalCounterexample,
148 ),
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct SolverCandidate {
156 pub id: &'static str,
157 pub home: CapabilityHome,
158 pub exposure: ExposureKind,
159 pub problem_class: ProblemClass,
160 pub symbol: &'static str,
161 pub crate_name: &'static str,
162 pub seed_prefix: Option<&'static str>,
163 pub plan_prefix: Option<&'static str>,
164 pub feature: Option<&'static str>,
165 pub confidence: &'static str,
166 pub when_to_use: &'static str,
167}
168
169pub const SOLVER_CATALOG: &[SolverCandidate] = &[
172 SolverCandidate {
173 id: "converge.task-scheduling.greedy",
174 home: CapabilityHome::ConvergeOptimization,
175 exposure: ExposureKind::Suggestor,
176 problem_class: ProblemClass::TaskScheduling,
177 symbol: "GreedySchedulerSuggestor",
178 crate_name: "converge-optimization",
179 seed_prefix: Some("scheduling-request:"),
180 plan_prefix: Some("scheduling-plan-greedy:"),
181 feature: None,
182 confidence: "throughput_ratio * 0.65, capped at 0.65",
183 when_to_use: "Immediate portable baseline for skilled task scheduling with time windows.",
184 },
185 SolverCandidate {
186 id: "ferrox.task-scheduling.cpsat",
187 home: CapabilityHome::Ferrox,
188 exposure: ExposureKind::Suggestor,
189 problem_class: ProblemClass::TaskScheduling,
190 symbol: "CpSatSchedulerSuggestor",
191 crate_name: "converge-ferrox-solver",
192 seed_prefix: Some("scheduling-request:"),
193 plan_prefix: Some("scheduling-plan-cpsat:"),
194 feature: Some("ortools"),
195 confidence: "optimal => throughput_ratio; feasible => throughput_ratio * 0.85",
196 when_to_use: "Quality anchor when schedule optimality or high utilization matters.",
197 },
198 SolverCandidate {
199 id: "converge.job-shop.pack",
200 home: CapabilityHome::ConvergeOptimization,
201 exposure: ExposureKind::PackSuggestor,
202 problem_class: ProblemClass::JobShopScheduling,
203 symbol: "PackSuggestor<JobShopSchedulingPack>",
204 crate_name: "converge-optimization",
205 seed_prefix: None,
206 plan_prefix: None,
207 feature: None,
208 confidence: "pack confidence 0.75",
209 when_to_use: "Portable pack baseline for job-shop scheduling through the gate path.",
210 },
211 SolverCandidate {
212 id: "ferrox.job-shop.cpsat",
213 home: CapabilityHome::Ferrox,
214 exposure: ExposureKind::Suggestor,
215 problem_class: ProblemClass::JobShopScheduling,
216 symbol: "CpSatJobShopSuggestor",
217 crate_name: "converge-ferrox-solver",
218 seed_prefix: Some("jspbench-request:"),
219 plan_prefix: Some("jspbench-plan-cpsat:"),
220 feature: Some("ortools"),
221 confidence: "optimal => 1.0; feasible => 0.85",
222 when_to_use: "Native interval/NoOverlap job-shop solver for makespan quality.",
223 },
224 SolverCandidate {
225 id: "converge.vrptw.nearest-neighbor",
226 home: CapabilityHome::ConvergeOptimization,
227 exposure: ExposureKind::Suggestor,
228 problem_class: ProblemClass::TimeWindowRouting,
229 symbol: "NearestNeighborTimeWindowRoutingSuggestor",
230 crate_name: "converge-optimization",
231 seed_prefix: Some("vrptw-request:"),
232 plan_prefix: Some("vrptw-plan-greedy:"),
233 feature: None,
234 confidence: "visit_ratio * 0.60, capped at 0.60",
235 when_to_use: "Immediate single-vehicle time-window routing baseline.",
236 },
237 SolverCandidate {
238 id: "ferrox.vrptw.cpsat",
239 home: CapabilityHome::Ferrox,
240 exposure: ExposureKind::Suggestor,
241 problem_class: ProblemClass::TimeWindowRouting,
242 symbol: "CpSatVrptwSuggestor",
243 crate_name: "converge-ferrox-solver",
244 seed_prefix: Some("vrptw-request:"),
245 plan_prefix: Some("vrptw-plan-cpsat:"),
246 feature: Some("ortools"),
247 confidence: "optimal => visit_ratio; feasible => visit_ratio * 0.85",
248 when_to_use: "Native CP-SAT TSPTW solver when visit count or time-window feasibility matters.",
249 },
250 SolverCandidate {
251 id: "converge.assignment.hungarian",
252 home: CapabilityHome::ConvergeOptimization,
253 exposure: ExposureKind::Suggestor,
254 problem_class: ProblemClass::Assignment,
255 symbol: "AssignmentSuggestor",
256 crate_name: "converge-optimization",
257 seed_prefix: Some("assignment-request:"),
258 plan_prefix: Some("assignment-plan:"),
259 feature: None,
260 confidence: "utilization ratio",
261 when_to_use: "Exact linear-sum assignment; no native Ferrox wrapper needed by default.",
262 },
263 SolverCandidate {
264 id: "converge.assignment.pack",
265 home: CapabilityHome::ConvergeOptimization,
266 exposure: ExposureKind::PackSuggestor,
267 problem_class: ProblemClass::Assignment,
268 symbol: "PackSuggestor<AssignmentPack>",
269 crate_name: "converge-optimization",
270 seed_prefix: None,
271 plan_prefix: None,
272 feature: None,
273 confidence: "pack confidence 0.8 for fully matched output",
274 when_to_use: "Gate-oriented task assignment pack.",
275 },
276 SolverCandidate {
277 id: "converge.flow.min-cost",
278 home: CapabilityHome::ConvergeOptimization,
279 exposure: ExposureKind::Suggestor,
280 problem_class: ProblemClass::NetworkFlow,
281 symbol: "FlowOptimizationSuggestor",
282 crate_name: "converge-optimization",
283 seed_prefix: Some("flow-request:"),
284 plan_prefix: Some("flow-plan:"),
285 feature: None,
286 confidence: "fulfillment ratio",
287 when_to_use: "Portable source/sink min-cost flow baseline.",
288 },
289 SolverCandidate {
290 id: "ferrox.flow.simple-min-cost",
291 home: CapabilityHome::Ferrox,
292 exposure: ExposureKind::Suggestor,
293 problem_class: ProblemClass::NetworkFlow,
294 symbol: "MinCostFlowSuggestor",
295 crate_name: "converge-ferrox-solver",
296 seed_prefix: Some("network-flow-request:"),
297 plan_prefix: Some("network-flow-plan-ortools:"),
298 feature: Some("ortools"),
299 confidence: "balanced optimal => 1.0; max-flow-min-cost => fulfillment ratio",
300 when_to_use: "Native OR-Tools integer min-cost circulation or max-flow-with-min-cost.",
301 },
302 SolverCandidate {
303 id: "ferrox.lp.glop",
304 home: CapabilityHome::Ferrox,
305 exposure: ExposureKind::Suggestor,
306 problem_class: ProblemClass::LinearProgramming,
307 symbol: "GlopLpSuggestor",
308 crate_name: "converge-ferrox-solver",
309 seed_prefix: Some("glop-request:"),
310 plan_prefix: Some("glop-plan:"),
311 feature: Some("ortools"),
312 confidence: "optimal => 1.0; feasible => 0.85",
313 when_to_use: "Continuous linear programs.",
314 },
315 SolverCandidate {
316 id: "ferrox.mip.highs",
317 home: CapabilityHome::Ferrox,
318 exposure: ExposureKind::Suggestor,
319 problem_class: ProblemClass::MixedIntegerProgramming,
320 symbol: "HighsMipSuggestor",
321 crate_name: "converge-ferrox-solver",
322 seed_prefix: Some("mip-request:"),
323 plan_prefix: Some("mip-plan:"),
324 feature: Some("highs"),
325 confidence: "optimal => 1.0; feasible/time-limit => 0.85",
326 when_to_use: "Continuous, integer, and binary MIP models.",
327 },
328 SolverCandidate {
329 id: "ferrox.cp.cpsat",
330 home: CapabilityHome::Ferrox,
331 exposure: ExposureKind::Suggestor,
332 problem_class: ProblemClass::ConstraintProgramming,
333 symbol: "CpSatSuggestor",
334 crate_name: "converge-ferrox-solver",
335 seed_prefix: Some("cpsat-request:"),
336 plan_prefix: Some("cpsat-plan:"),
337 feature: Some("ortools"),
338 confidence: "optimal => 1.0; feasible => 0.85",
339 when_to_use: "General integer/Boolean CP-SAT model when no domain Suggestor fits.",
340 },
341 SolverCandidate {
342 id: "converge.formation.matching",
343 home: CapabilityHome::ConvergeOptimization,
344 exposure: ExposureKind::Suggestor,
345 problem_class: ProblemClass::FormationAssembly,
346 symbol: "FormationAssemblySuggestor",
347 crate_name: "converge-optimization",
348 seed_prefix: Some("formation-request:"),
349 plan_prefix: Some("formation-plan:"),
350 feature: None,
351 confidence: "coverage ratio",
352 when_to_use: "Portable formation assembly baseline.",
353 },
354 SolverCandidate {
355 id: "ferrox.formation.cpsat",
356 home: CapabilityHome::Ferrox,
357 exposure: ExposureKind::Suggestor,
358 problem_class: ProblemClass::FormationAssembly,
359 symbol: "CpSatFormationSuggestor",
360 crate_name: "converge-ferrox-solver",
361 seed_prefix: Some("cpsat-formation-request:"),
362 plan_prefix: Some("cpsat-formation-plan:"),
363 feature: Some("ortools"),
364 confidence: "coverage ratio",
365 when_to_use: "Weighted formation assembly when confidence, latency, and cost hints matter.",
366 },
367 SolverCandidate {
368 id: "external.smt.counterexample",
369 home: CapabilityHome::ExternalSmt,
370 exposure: ExposureKind::Deferred,
371 problem_class: ProblemClass::LogicalCounterexample,
372 symbol: "ferrox-smt or smt-gates",
373 crate_name: "not implemented",
374 seed_prefix: None,
375 plan_prefix: None,
376 feature: None,
377 confidence: "not applicable",
378 when_to_use: "Logical satisfiability/counterexample search for policy and invariant claims.",
379 },
380];
381
382#[must_use]
384pub const fn solver_catalog() -> &'static [SolverCandidate] {
385 SOLVER_CATALOG
386}
387
388#[must_use]
390pub fn candidate_by_id(id: &str) -> Option<&'static SolverCandidate> {
391 SOLVER_CATALOG.iter().find(|candidate| candidate.id == id)
392}
393
394#[must_use]
396pub fn recommend_suggestors(request: SelectionRequest) -> Vec<&'static SolverCandidate> {
397 let mut candidates: Vec<_> = SOLVER_CATALOG
398 .iter()
399 .filter(|candidate| candidate.problem_class == request.problem_class)
400 .collect();
401
402 candidates.sort_by_key(|candidate| -score_candidate(candidate, request));
403 candidates
404}
405
406#[must_use]
408pub fn recommend_for_use_case(use_case: CommonUseCase) -> Vec<&'static SolverCandidate> {
409 recommend_suggestors(use_case.into())
410}
411
412fn score_candidate(candidate: &SolverCandidate, request: SelectionRequest) -> i32 {
413 let mut score = 0;
414
415 match request.goal {
416 SelectionGoal::FastBaseline => match candidate.home {
417 CapabilityHome::ConvergeOptimization => score += 100,
418 CapabilityHome::Ferrox => score += 40,
419 CapabilityHome::ExternalSmt => score -= 200,
420 },
421 SelectionGoal::StrongerOptimization => match candidate.home {
422 CapabilityHome::Ferrox if request.native_available => score += 100,
423 CapabilityHome::Ferrox => score += 25,
424 CapabilityHome::ConvergeOptimization => score += 70,
425 CapabilityHome::ExternalSmt => score -= 200,
426 },
427 SelectionGoal::GeneralModeling => match candidate.problem_class {
428 ProblemClass::ConstraintProgramming
429 | ProblemClass::LinearProgramming
430 | ProblemClass::MixedIntegerProgramming => score += 100,
431 _ => score += 20,
432 },
433 SelectionGoal::LogicalCounterexample => match candidate.home {
434 CapabilityHome::ExternalSmt => score += 100,
435 _ => score -= 200,
436 },
437 }
438
439 if request.prefer_pack {
440 match candidate.exposure {
441 ExposureKind::PackSuggestor => score += 20,
442 ExposureKind::Suggestor => score += 5,
443 ExposureKind::Deferred => {}
444 }
445 }
446
447 if candidate.home == CapabilityHome::Ferrox && !request.native_available {
448 score -= 60;
449 }
450
451 score
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn scheduling_quality_goal_prefers_native_then_baseline() {
460 let recommendations = recommend_for_use_case(CommonUseCase::FieldCrewScheduling);
461 assert_eq!(recommendations[0].id, "ferrox.task-scheduling.cpsat");
462 assert_eq!(recommendations[1].id, "converge.task-scheduling.greedy");
463 assert_eq!(
464 recommendations[0].seed_prefix,
465 recommendations[1].seed_prefix
466 );
467 assert_ne!(
468 recommendations[0].plan_prefix,
469 recommendations[1].plan_prefix
470 );
471 }
472
473 #[test]
474 fn scheduling_fast_goal_prefers_portable_baseline() {
475 let recommendations = recommend_suggestors(SelectionRequest::new(
476 ProblemClass::TaskScheduling,
477 SelectionGoal::FastBaseline,
478 ));
479 assert_eq!(recommendations[0].id, "converge.task-scheduling.greedy");
480 }
481
482 #[test]
483 fn no_native_demotes_ferrox_but_keeps_it_visible() {
484 let recommendations = recommend_suggestors(
485 SelectionRequest::new(
486 ProblemClass::TimeWindowRouting,
487 SelectionGoal::StrongerOptimization,
488 )
489 .without_native(),
490 );
491 assert_eq!(recommendations[0].id, "converge.vrptw.nearest-neighbor");
492 assert!(
493 recommendations
494 .iter()
495 .any(|candidate| candidate.id == "ferrox.vrptw.cpsat")
496 );
497 }
498
499 #[test]
500 fn assignment_pack_can_be_preferred_for_gate_path() {
501 let recommendations = recommend_suggestors(
502 SelectionRequest::new(ProblemClass::Assignment, SelectionGoal::FastBaseline)
503 .prefer_pack(),
504 );
505 assert_eq!(recommendations[0].id, "converge.assignment.pack");
506 }
507
508 #[test]
509 fn logical_counterexample_does_not_pick_optimization_solver() {
510 let recommendations = recommend_for_use_case(CommonUseCase::CedarPolicyCounterexample);
511 assert_eq!(recommendations.len(), 1);
512 assert_eq!(recommendations[0].home, CapabilityHome::ExternalSmt);
513 assert_eq!(recommendations[0].exposure, ExposureKind::Deferred);
514 }
515
516 #[test]
517 fn every_live_candidate_has_discovery_surface() {
518 for candidate in SOLVER_CATALOG
519 .iter()
520 .filter(|candidate| candidate.exposure != ExposureKind::Deferred)
521 {
522 assert!(
523 candidate.seed_prefix.is_some()
524 || candidate.exposure == ExposureKind::PackSuggestor,
525 "{} must declare a seed prefix or be a pack",
526 candidate.id
527 );
528 assert!(
529 candidate.plan_prefix.is_some()
530 || candidate.exposure == ExposureKind::PackSuggestor,
531 "{} must declare a plan prefix or be a pack",
532 candidate.id
533 );
534 assert!(!candidate.symbol.is_empty());
535 assert!(!candidate.when_to_use.is_empty());
536 }
537 }
538}