Skip to main content

ferrox/
catalog.rs

1//! Solver/Suggestor selection catalog.
2//!
3//! This module is intentionally data-oriented. Products should be able to ask
4//! "what should I register for this problem?" without reverse-engineering crate
5//! modules, feature flags, seed prefixes, or confidence semantics.
6
7/// Where the capability is owned.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum CapabilityHome {
10    /// Portable Rust baseline in `converge-optimization`.
11    ConvergeOptimization,
12    /// Native solver-backed Suggestor in Ferrox.
13    Ferrox,
14    /// Not owned by Ferrox; needs a separate SMT/SAT contract.
15    ExternalSmt,
16}
17
18/// How the capability is exposed to Converge.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ExposureKind {
21    /// Direct `Suggestor` implementation.
22    Suggestor,
23    /// `converge_pack::PackSuggestor<P>` around a pack.
24    PackSuggestor,
25    /// Deferred or external capability, documented so it is not misrouted.
26    Deferred,
27}
28
29/// Product-shaped problem class.
30#[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/// What the use case values most.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum SelectionGoal {
47    /// Prefer a portable, low-latency baseline.
48    FastBaseline,
49    /// Prefer stronger optimization if available, while keeping a baseline as fallback.
50    StrongerOptimization,
51    /// Prefer a general modeler over a domain-shaped Suggestor.
52    GeneralModeling,
53    /// This is not optimization; route toward SMT/SAT counterexample search.
54    LogicalCounterexample,
55}
56
57/// A common application use case.
58#[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/// Selection request for catalog recommendations.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct SelectionRequest {
75    pub problem_class: ProblemClass,
76    pub goal: SelectionGoal,
77    /// If false, recommendations demote native Ferrox candidates.
78    pub native_available: bool,
79    /// If true, pack surfaces are preferred over raw direct Suggestors when both fit.
80    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/// A candidate Suggestor or Pack surface.
154#[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
169/// Full known catalog. This includes external/deferred entries so product
170/// code can avoid treating CP-SAT or optimization solvers as SMT.
171pub 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/// Return the complete known solver catalog.
383#[must_use]
384pub const fn solver_catalog() -> &'static [SolverCandidate] {
385    SOLVER_CATALOG
386}
387
388/// Find a candidate by stable catalog id.
389#[must_use]
390pub fn candidate_by_id(id: &str) -> Option<&'static SolverCandidate> {
391    SOLVER_CATALOG.iter().find(|candidate| candidate.id == id)
392}
393
394/// Recommend candidates for a selection request, ordered by preference.
395#[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/// Recommend candidates for a named common use case.
407#[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}