diff --git a/docs/src/design.md b/docs/src/design.md index 7f709edfc..1fec44b46 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -321,7 +321,7 @@ pub trait Solver { | Solver | Description | |--------|-------------| | **BruteForce** | Enumerates all configurations. `solve()` works for any aggregate problem; `find_witness()`, `find_all_witnesses()`, and `solve_with_witnesses()` are available when `P::Value` supports witnesses. Used for testing and verification. | -| **ILPSolver** | Enabled by default. Solves ILP instances directly with HiGHS via `good_lp`. Also provides `solve_reduced()` for witness-capable problems that implement `ReduceTo>`. | +| **ILPSolver** | Enabled by default. Solves `ILP` and `ILP` instances directly with HiGHS via `good_lp`. Also provides `solve_reduced::()` for witness-capable problems that implement `ReduceTo>`. | ## JSON Serialization diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 5afc10916..1116df9b1 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -100,10 +100,17 @@ For convenience, `ILPSolver::solve_reduced` combines reduce + solve + extract in a single call: ```rust,ignore -let solution = ILPSolver::new().solve_reduced(&problem).unwrap(); +let solution = ILPSolver::new() + .solve_reduced::(&problem) + .unwrap(); assert!(problem.evaluate(&solution).is_valid()); ``` +The ILP domain is explicit because a source type may provide more than one +direct ILP reduction. Both `bool` and `i32` are supported. `solve` and +`solve_reduced` return `ILPSolveError`, which distinguishes infeasibility, +timeout, unboundedness, unsupported dynamic input, and backend failure. + ### Example 2: Reduction path search — integer factoring to spin glass Real-world problems often require **chaining** multiple reductions. Here we factor the integer 6 by reducing `Factoring` through the reduction graph to `SpinGlass`, through automatic reduction path search. ([full source](https://github.com/CodingThrust/problem-reductions/blob/main/examples/chained_reduction_factoring_to_spinglass.rs)) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 70fa1e5af..1ab880628 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1220,12 +1220,12 @@ impl CreateArgs { #[derive(clap::Args)] #[command(after_help = "\ Examples: - pred solve problem.json # ILP solver (default, auto-reduces to ILP) + pred solve problem.json # deterministic registered backend or fallback pred solve problem.json --solver brute-force # brute-force (exhaustive search) - pred solve problem.json --solver customized # customized (structure-exploiting exact solver) + pred solve problem.json --solver ilp # require the registered fixed ILP pipeline pred solve reduced.json # solve a reduction bundle pred solve reduced.json -o solution.json # save result to file - pred create MIS --graph 0-1,1-2 | pred solve - # read from stdin when an ILP path exists + pred create MIS --graph 0-1,1-2 | pred solve - # read from stdin pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 | pred solve - --solver brute-force pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2 | pred solve - --solver brute-force pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --sets \"0,1,2;3,4,5;1,3;2,4;0,5\" | pred solve - --solver brute-force @@ -1241,14 +1241,9 @@ Solve via explicit reduction: Input: a problem JSON from `pred create`, or a reduction bundle from `pred reduce`. When given a bundle, the target is solved and the solution is mapped back to the source. -The ILP solver auto-reduces non-ILP problems before solving. -Problems without an ILP reduction path, such as `GroupingBySwapping`, -`LengthBoundedDisjointPaths`, `MinMaxMulticenter`, and `StringToStringCorrection`, -currently need `--solver brute-force`. - -Customized solver: exact witness recovery for select problems via structure-exploiting -backends. Currently supports MinimumCardinalityKey, AdditionalKey, PrimeAttributeName, -BoyceCoddNormalFormViolation, PartialFeedbackEdgeSet, and RootedTreeArrangement. +By default, solve deterministically selects the exact variant's registered native +backend, then its fixed ILP pipeline, and otherwise brute force. `--solver ilp` +requires a registered ILP pipeline; it never searches the reduction graph. ILP backend (default: HiGHS). To use CPLEX instead: cargo install problemreductions-cli --features cplex @@ -1256,9 +1251,9 @@ ILP backend (default: HiGHS). To use CPLEX instead: pub struct SolveArgs { /// Problem JSON file (from `pred create`) or reduction bundle (from `pred reduce`). Use - for stdin. pub input: PathBuf, - /// Solver: ilp (default), brute-force, or customized - #[arg(long, default_value = "ilp")] - pub solver: String, + /// Solver override: ilp or brute-force. Omit for deterministic default dispatch. + #[arg(long)] + pub solver: Option, /// Timeout in seconds (0 = no limit) #[arg(long, default_value = "0")] pub timeout: u64, diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs index d7e5daf8d..4d190c6cb 100644 --- a/problemreductions-cli/src/commands/inspect.rs +++ b/problemreductions-cli/src/commands/inspect.rs @@ -1,4 +1,6 @@ -use crate::dispatch::{load_problem, read_input, ProblemJson, ReductionBundle}; +use crate::dispatch::{ + load_problem, read_input, solver_capabilities_view, ProblemJson, ReductionBundle, +}; use crate::output::OutputConfig; use anyhow::Result; use problemreductions::rules::ReductionGraph; @@ -40,19 +42,21 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { } text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn())); - let solvers = problem.available_solvers(); - let solver_summary = solvers - .iter() - .map(|solver| { - if *solver == "ilp" { - "ilp (default)".to_string() - } else { - (*solver).to_string() - } - }) - .collect::>() - .join(", "); - text.push_str(&format!("Solvers: {solver_summary}\n")); + let solver_view = solver_capabilities_view(&problem)?; + text.push_str(&format!("Default solver: {}\n", solver_view.default_solver)); + text.push_str(&format!("Solvers: {}\n", solver_view.solvers.join(", "))); + if let Some(native) = solver_view.capabilities.native.as_ref() { + text.push_str(&format!( + "Native implementation: {}\n", + native.implementation + )); + } + if let Some(ilp) = solver_view.capabilities.ilp.as_ref() { + text.push_str(&format!( + "ILP pipeline: {}\n", + ilp.reduction_path.join(" -> ") + )); + } // Reductions let outgoing = graph.outgoing_reductions(name); @@ -67,7 +71,9 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { "variant": variant, "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), - "solvers": solvers, + "solvers": solver_view.solvers, + "default_solver": solver_view.default_solver, + "solver_capabilities": solver_view.capabilities, "reduces_to": targets, }); diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 80207d44c..3b6ba801e 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -1,6 +1,10 @@ -use crate::dispatch::{load_problem, read_input, BundleReplay, ProblemJson, ReductionBundle}; +use crate::dispatch::{ + load_problem, read_input, solve_result_json, solver_request, BundleReplay, ProblemJson, + ReductionBundle, +}; use crate::output::OutputConfig; use anyhow::{Context, Result}; +use problemreductions::solvers::{DeterministicSolveResult, SolverExecution, SolverRequest}; use std::path::Path; use std::time::Duration; @@ -28,62 +32,58 @@ fn parse_input(path: &Path) -> Result { } } -fn solve_result_text(problem: &str, solver: &str, result: &crate::dispatch::SolveResult) -> String { - let mut text = format!("Problem: {}\nSolver: {}", problem, solver); - if let Some(config) = &result.config { - text.push_str(&format!("\nSolution: {:?}", config)); +fn solver_text(solver: &SolverExecution) -> String { + match solver { + SolverExecution::Native { implementation } => format!("native ({implementation})"), + SolverExecution::Ilp { reduction_path } => { + format!("ilp ({})", reduction_path.join(" -> ")) + } + SolverExecution::BruteForce => "brute-force".to_string(), } - text.push_str(&format!("\nEvaluation: {}", result.evaluation)); - text } -fn solve_result_json( - problem: &str, - solver: &str, - result: &crate::dispatch::SolveResult, -) -> serde_json::Value { - let mut json = serde_json::json!({ - "problem": problem, - "solver": solver, - "evaluation": result.evaluation, - }); +fn solve_result_text(problem: &str, result: &DeterministicSolveResult) -> String { + let mut text = format!( + "Problem: {}\nSolver: {}", + problem, + solver_text(&result.solver) + ); if let Some(config) = &result.config { - json["solution"] = serde_json::json!(config); + text.push_str(&format!("\nSolution: {:?}", config)); } - json + text.push_str(&format!("\nEvaluation: {}", result.evaluation)); + text } fn plain_problem_output( problem: &str, - solver: &str, - result: &crate::dispatch::SolveResult, + result: &DeterministicSolveResult, ) -> (String, serde_json::Value) { ( - solve_result_text(problem, solver, result), - solve_result_json(problem, solver, result), + solve_result_text(problem, result), + solve_result_json(problem, result), ) } -pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) -> Result<()> { - if solver_name != "brute-force" && solver_name != "ilp" && solver_name != "customized" { - anyhow::bail!( - "Unknown solver: {}. Available solvers: brute-force, ilp, customized", - solver_name - ); - } +pub fn solve( + input: &Path, + solver_name: Option<&str>, + timeout: u64, + out: &OutputConfig, +) -> Result<()> { + let request = solver_request(solver_name)?; let parsed = parse_input(input)?; if timeout > 0 { - let solver_name = solver_name.to_string(); let out = out.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = match parsed { SolveInput::Problem(pj) => { - solve_problem(&pj.problem_type, &pj.variant, pj.data, &solver_name, &out) + solve_problem(&pj.problem_type, &pj.variant, pj.data, request, &out) } - SolveInput::Bundle(b) => solve_bundle(b, &solver_name, &out), + SolveInput::Bundle(b) => solve_bundle(b, request, &out), }; tx.send(result).ok(); }); @@ -94,9 +94,9 @@ pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) } else { match parsed { SolveInput::Problem(pj) => { - solve_problem(&pj.problem_type, &pj.variant, pj.data, solver_name, out) + solve_problem(&pj.problem_type, &pj.variant, pj.data, request, out) } - SolveInput::Bundle(b) => solve_bundle(b, solver_name, out), + SolveInput::Bundle(b) => solve_bundle(b, request, out), } } } @@ -106,85 +106,44 @@ fn solve_problem( problem_type: &str, variant: &std::collections::BTreeMap, data: serde_json::Value, - solver_name: &str, + request: SolverRequest, out: &OutputConfig, ) -> Result<()> { let problem = load_problem(problem_type, variant, data)?; let name = problem.problem_name(); - - match solver_name { - "brute-force" => { - let result = problem.solve_brute_force(); - let (text, json) = plain_problem_output(name, "brute-force", &result); - let result = out.emit_with_default_name("", &text, &json); - if out.output.is_none() && crate::output::stderr_is_tty() { - out.info("\nHint: use -o to save full solution details as JSON."); - } - result - } - "ilp" => { - let result = problem.solve_with_ilp().map_err(add_ilp_solver_hint)?; - let solver_desc = if name == "ILP" { - "ilp".to_string() - } else { - "ilp (via ILP)".to_string() - }; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let text = solve_result_text(name, &solver_desc, &result); - let mut json = solve_result_json(name, "ilp", &result); - if name != "ILP" { - json["reduced_to"] = serde_json::json!("ILP"); - } - let result = out.emit_with_default_name("", &text, &json); - if out.output.is_none() && crate::output::stderr_is_tty() { - out.info("\nHint: use -o to save full solution details as JSON."); - } - result - } - "customized" => { - let result = problem - .solve_with_customized() - .map_err(add_customized_solver_hint)?; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let (text, json) = plain_problem_output(name, "customized", &result); - let result = out.emit_with_default_name("", &text, &json); - if out.output.is_none() && crate::output::stderr_is_tty() { - out.info("\nHint: use -o to save full solution details as JSON."); - } - result - } - _ => unreachable!(), + let result = problem + .solve_deterministically(request) + .map_err(add_solver_hint)?; + let (text, json) = plain_problem_output(name, &result); + let emitted = out.emit_with_default_name("", &text, &json); + if out.output.is_none() && crate::output::stderr_is_tty() { + out.info("\nHint: use -o to save full solution details as JSON."); } + emitted } /// Solve a reduction bundle: solve the target problem, then map the solution back. -fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) -> Result<()> { +fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputConfig) -> Result<()> { let replay = BundleReplay::prepare(&bundle)?; - let target_result = match solver_name { - "brute-force" => replay.target.solve_brute_force_witness().ok_or_else(|| { - anyhow::anyhow!( - "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", - replay.target_name - ) - })?, - "ilp" => replay.target.solve_with_ilp().map_err(add_ilp_solver_hint)?, - "customized" => replay - .target - .solve_with_customized() - .map_err(add_customized_solver_hint)?, - _ => unreachable!(), - }; + let target_result = replay + .target + .solve_deterministically(request) + .map_err(add_solver_hint)?; + let target_config = target_result.config.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", + replay.target_name + ) + })?; - let (source_config, source_eval) = replay.extract(&target_result.config); + let (source_config, source_eval) = replay.extract(target_config); - let solver_desc = format!("{} (via {})", solver_name, replay.target_name); + let solver_desc = format!( + "{} (via {})", + solver_text(&target_result.solver), + replay.target_name + ); let text = format!( "Problem: {}\nSolver: {}\nSolution: {:?}\nEvaluation: {}", replay.source_name, solver_desc, source_config, source_eval, @@ -192,13 +151,12 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) let json = serde_json::json!({ "problem": replay.source_name, - "solver": solver_name, - "reduced_to": replay.target_name, + "solver": &target_result.solver, "solution": source_config, "evaluation": source_eval, "intermediate": { "problem": replay.target_name, - "solution": target_result.config, + "solution": target_config, "evaluation": target_result.evaluation, }, }); @@ -210,22 +168,9 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) result } -fn add_customized_solver_hint(err: anyhow::Error) -> anyhow::Error { - let message = err.to_string(); - if message.contains("unsupported by customized solver") { - anyhow::anyhow!( - "{message}\n\nHint: the customized solver only supports select problems (FD-based models, PartialFeedbackEdgeSet, RootedTreeArrangement).\nTry `--solver brute-force` or `--solver ilp` instead." - ) - } else { - err - } -} - -fn add_ilp_solver_hint(err: anyhow::Error) -> anyhow::Error { +fn add_solver_hint(err: anyhow::Error) -> anyhow::Error { let message = err.to_string(); - if (message.starts_with("No reduction path from ") && message.ends_with(" to ILP")) - || message.contains("witness-capable") - { + if message.starts_with("No ILP pipeline is registered for ") { anyhow::anyhow!( "{message}\n\nHint: try `--solver brute-force` for direct exhaustive search on small instances." ) @@ -237,18 +182,17 @@ fn add_ilp_solver_hint(err: anyhow::Error) -> anyhow::Error { #[cfg(test)] mod tests { use super::*; - use crate::dispatch::SolveResult; use crate::output::OutputConfig; use crate::test_support::aggregate_bundle; #[test] fn test_solve_value_only_problem_omits_solution() { - let result = SolveResult { + let result = DeterministicSolveResult { + solver: SolverExecution::BruteForce, config: None, evaluation: "Sum(56)".to_string(), }; - let (text, json) = - plain_problem_output("CliTestAggregateValueSource", "brute-force", &result); + let (text, json) = plain_problem_output("CliTestAggregateValueSource", &result); assert!(text.contains("Evaluation: Sum(56)"), "{text}"); assert!(!text.contains("Solution:"), "{text}"); assert!(json.get("solution").is_none(), "{json}"); @@ -264,7 +208,7 @@ mod tests { auto_json: false, }; - let err = solve_bundle(bundle, "brute-force", &out).unwrap_err(); + let err = solve_bundle(bundle, SolverRequest::BruteForce, &out).unwrap_err(); assert!( err.to_string().contains("witness"), "unexpected error: {err}" diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 4849373b7..5bd39ed75 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -1,8 +1,10 @@ use anyhow::{Context, Result}; use problemreductions::registry::{DynProblem, LoadedDynProblem}; -use problemreductions::rules::{MinimizeSteps, ReductionGraph, ReductionMode}; -use problemreductions::solvers::{CustomizedSolver, ILPSolver}; -use problemreductions::types::ProblemSize; +use problemreductions::rules::ReductionGraph; +use problemreductions::solvers::{ + solve_deterministically, solver_capabilities, DeterministicSolveResult, ExactProblemKey, + SolverRequest, +}; use serde_json::Value; use std::any::Any; use std::collections::BTreeMap; @@ -37,81 +39,96 @@ impl std::ops::Deref for LoadedProblem { } impl LoadedProblem { - pub fn solve_brute_force_value(&self) -> String { - self.inner.solve_brute_force_value() + pub fn solve_deterministically( + &self, + request: SolverRequest, + ) -> Result { + solve_deterministically(&self.inner, request).map_err(anyhow::Error::from) } +} - pub fn solve_brute_force_witness(&self) -> Option { - let (config, evaluation) = self.inner.solve_brute_force_witness()?; - Some(WitnessSolveResult { config, evaluation }) - } +#[derive(Clone, Debug, serde::Serialize)] +pub struct NativeSolverCapabilityView { + pub implementation: &'static str, +} - pub fn solve_brute_force(&self) -> SolveResult { - let evaluation = self.solve_brute_force_value(); - let config = self.solve_brute_force_witness().map(|result| result.config); - SolveResult { config, evaluation } - } +#[derive(Clone, Debug, serde::Serialize)] +pub struct IlpSolverCapabilityView { + pub reduction_path: Vec, +} - pub fn supports_ilp_solver(&self) -> bool { - let name = self.problem_name(); - let variant = self.variant_map(); - name == "ILP" || { - let graph = ReductionGraph::new(); - let ilp_variants = graph.variants_for("ILP"); - let input_size = ProblemSize::new(vec![]); - ilp_variants.iter().any(|dv| { - graph - .find_cheapest_path_mode( - name, - &variant, - "ILP", - dv, - ReductionMode::Witness, - &input_size, - &MinimizeSteps, - ) - .is_some() - }) - } - } +#[derive(Clone, Debug, serde::Serialize)] +pub struct SolverCapabilityDetailsView { + pub native: Option, + pub ilp: Option, + pub brute_force: bool, +} - pub fn supports_customized_solver(&self) -> bool { - CustomizedSolver::supports_problem(self.as_any()) - } +#[derive(Clone, Debug, serde::Serialize)] +pub struct SolverCapabilitiesView { + pub solvers: Vec<&'static str>, + pub default_solver: &'static str, + pub capabilities: SolverCapabilityDetailsView, +} - pub fn solve_with_customized(&self) -> Result { - let solver = CustomizedSolver::new(); - let config = solver - .solve_dyn(self.as_any()) - .ok_or_else(|| anyhow::anyhow!("Problem unsupported by customized solver"))?; - let evaluation = self.evaluate_dyn(&config); - Ok(WitnessSolveResult { config, evaluation }) +pub fn solver_capabilities_view(problem: &LoadedProblem) -> Result { + let key = ExactProblemKey::new(problem.problem_name(), problem.variant_map()); + let registered = solver_capabilities(&key) + .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}"))?; + let native = registered.native.map(|entry| NativeSolverCapabilityView { + implementation: entry.implementation, + }); + let ilp = registered.ilp.map(|pipeline| IlpSolverCapabilityView { + reduction_path: pipeline.path_labels(), + }); + let default_solver = if native.is_some() { + "native" + } else if ilp.is_some() { + "ilp" + } else { + "brute-force" + }; + let mut solvers = Vec::with_capacity(3); + if native.is_some() { + solvers.push("native"); + } + if ilp.is_some() { + solvers.push("ilp"); } + solvers.push("brute-force"); + + Ok(SolverCapabilitiesView { + solvers, + default_solver, + capabilities: SolverCapabilityDetailsView { + native, + ilp, + brute_force: true, + }, + }) +} - #[cfg_attr(not(feature = "mcp"), allow(dead_code))] - pub fn available_solvers(&self) -> Vec<&'static str> { - let mut solvers = Vec::new(); - if self.supports_ilp_solver() { - solvers.push("ilp"); - } - solvers.push("brute-force"); - if self.supports_customized_solver() { - solvers.push("customized"); +pub fn solver_request(solver_name: Option<&str>) -> Result { + match solver_name { + None => Ok(SolverRequest::Default), + Some("ilp") => Ok(SolverRequest::Ilp), + Some("brute-force") => Ok(SolverRequest::BruteForce), + Some(other) => { + anyhow::bail!("Unknown solver: {other}. Available solver overrides: brute-force, ilp") } - solvers } +} - /// Solve using the ILP solver. If the problem is not ILP, auto-reduce to ILP first. - pub fn solve_with_ilp(&self) -> Result { - let name = self.problem_name(); - let variant = self.variant_map(); - let solver = ILPSolver::new(); - let config = solver - .try_solve_via_reduction(name, &variant, self.as_any()) - .map_err(|err| anyhow::anyhow!(err))?; - let evaluation = self.evaluate_dyn(&config); - Ok(WitnessSolveResult { config, evaluation }) +pub fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> serde_json::Value { + let mut json = serde_json::json!({ + "problem": problem, + "solver": &result.solver, + "evaluation": result.evaluation, + }); + if let Some(config) = &result.config { + json["solution"] = serde_json::json!(config); } + json } /// A validated reduction bundle ready to replay: @@ -298,24 +315,6 @@ pub struct PathStep { pub variant: BTreeMap, } -/// Result of solving a problem. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SolveResult { - /// The solution configuration when the problem supports witness extraction. - pub config: Option>, - /// Evaluation of the solution. - pub evaluation: String, -} - -/// Result of solving a witness-capable problem. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WitnessSolveResult { - /// The solution configuration. - pub config: Vec, - /// Evaluation of the solution. - pub evaluation: String, -} - #[cfg(test)] mod tests { use super::*; @@ -406,13 +405,15 @@ mod tests { ) .unwrap(); - let result = loaded.solve_brute_force(); + let result = loaded + .solve_deterministically(SolverRequest::BruteForce) + .unwrap(); assert_eq!(result.config, None); assert_eq!(result.evaluation, "Sum(56)"); } #[test] - fn test_available_solvers_excludes_customized_for_unsupported_problem() { + fn test_default_uses_brute_force_without_registered_backend() { let loaded = load_problem( AGGREGATE_SOURCE_NAME, &BTreeMap::new(), @@ -420,11 +421,17 @@ mod tests { ) .unwrap(); - assert!(!loaded.available_solvers().contains(&"customized")); + let result = loaded + .solve_deterministically(SolverRequest::Default) + .unwrap(); + assert_eq!( + result.solver, + problemreductions::solvers::SolverExecution::BruteForce + ); } #[test] - fn test_solve_with_customized_rejects_unsupported_problem() { + fn test_explicit_ilp_requires_registered_pipeline() { let loaded = load_problem( AGGREGATE_SOURCE_NAME, &BTreeMap::new(), @@ -432,26 +439,69 @@ mod tests { ) .unwrap(); - let err = loaded.solve_with_customized().unwrap_err(); + let err = loaded + .solve_deterministically(SolverRequest::Ilp) + .unwrap_err(); assert!( - err.to_string().contains("unsupported by customized solver"), + err.to_string().contains("No ILP pipeline is registered"), "unexpected error: {err}" ); } #[test] - fn test_solve_with_ilp_rejects_aggregate_only_problem() { + fn solver_request_accepts_only_documented_overrides() { + assert_eq!(solver_request(None).unwrap(), SolverRequest::Default); + assert_eq!(solver_request(Some("ilp")).unwrap(), SolverRequest::Ilp); + assert_eq!( + solver_request(Some("brute-force")).unwrap(), + SolverRequest::BruteForce + ); + for rejected in ["auto", "customized", "native", "implementation-id"] { + let error = solver_request(Some(rejected)).unwrap_err(); + assert!(error.to_string().contains(rejected), "{error}"); + } + } + + #[test] + fn solve_result_json_preserves_structured_solver_contract() { + let result = DeterministicSolveResult { + solver: problemreductions::solvers::SolverExecution::Ilp { + reduction_path: vec!["Source".to_string(), "ILP".to_string()], + }, + config: Some(vec![1, 0]), + evaluation: "Max(1)".to_string(), + }; + let json = solve_result_json("Source", &result); + + assert_eq!(json["problem"], "Source"); + assert_eq!(json["solver"]["kind"], "ilp"); + assert_eq!( + json["solver"]["reduction_path"], + serde_json::json!(["Source", "ILP"]) + ); + assert_eq!(json["solution"], serde_json::json!([1, 0])); + assert!(json.get("reduced_to").is_none()); + } + + #[test] + #[cfg(any(feature = "highs", feature = "cplex", feature = "lp-solvers"))] + fn solver_capabilities_view_centralizes_default_and_available_order() { + use problemreductions::models::graph::RootedTreeArrangement; + use problemreductions::Problem; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(2, vec![(0, 1)]), 1); let loaded = load_problem( - AGGREGATE_SOURCE_NAME, - &BTreeMap::new(), - serde_json::to_value(AggregateValueSource::sample()).unwrap(), + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), ) .unwrap(); + let view = solver_capabilities_view(&loaded).unwrap(); - let err = loaded.solve_with_ilp().unwrap_err(); - assert!( - err.to_string().contains("witness-capable"), - "unexpected error: {err}" - ); + assert_eq!(view.default_solver, "native"); + assert_eq!(view.solvers, ["native", "ilp", "brute-force"]); + assert!(view.capabilities.native.is_some()); + assert!(view.capabilities.ilp.is_some()); + assert!(view.capabilities.brute_force); } } diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 702199e49..afcd3a294 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -70,7 +70,7 @@ fn main() -> anyhow::Result<()> { Commands::Inspect(args) => commands::inspect::inspect(&args.input, &out), Commands::Create(args) => commands::create::create(&args, &out), Commands::Solve(args) => { - commands::solve::solve(&args.input, &args.solver, args.timeout, &out) + commands::solve::solve(&args.input, args.solver.as_deref(), args.timeout, &out) } Commands::Reduce(args) => { commands::reduce::reduce(&args.input, args.to.as_deref(), args.via.as_deref(), &out) diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index f03e93dda..06df66dea 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -286,7 +286,7 @@ mod tests { let server = McpServer::new(); let problem_json = create_test_mis(&server); let result = server.inspect_problem_inner(&problem_json); - assert!(result.is_ok()); + assert!(result.is_ok(), "inspect failed: {result:?}"); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert_eq!(json["type"], "MaximumIndependentSet"); assert_eq!(json["kind"], "problem"); @@ -340,7 +340,7 @@ mod tests { assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert!(json["solution"].is_array()); - assert_eq!(json["solver"], "brute-force"); + assert_eq!(json["solver"]["kind"], "brute-force"); } #[test] @@ -354,7 +354,7 @@ mod tests { } #[test] - fn test_solve_customized_supported_problem() { + fn deterministic_solver_dispatch_defaults_supported_problem_to_native() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinimumCardinalityKey", @@ -367,10 +367,14 @@ mod tests { }) .to_string(); - let result = server.solve_inner(&problem_json, Some("customized"), None); + let result = server.solve_inner(&problem_json, None, None); assert!(result.is_ok(), "solve failed: {:?}", result); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["solver"], "customized"); + assert_eq!(json["solver"]["kind"], "native"); + assert_eq!( + json["solver"]["implementation"], + "fd-minimum-cardinality-key" + ); assert!(json["solution"].is_array(), "{json}"); } @@ -378,8 +382,37 @@ mod tests { fn test_solve_unknown_solver() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - let result = server.solve_inner(&problem_json, Some("unknown"), None); - assert!(result.is_err()); + for rejected in ["auto", "customized", "native", "fd-minimum-cardinality-key"] { + let error = server + .solve_inner(&problem_json, Some(rejected), None) + .unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("Unknown solver: {rejected}")), + "unexpected error for {rejected}: {error}" + ); + } + } + + #[test] + fn deterministic_solver_dispatch_mcp_output_is_repeatable_for_each_solver_class() { + let server = McpServer::new(); + let problem_json = serde_json::json!({ + "type": "RootedTreeArrangement", + "variant": {"graph": "SimpleGraph"}, + "data": { + "graph": {"num_vertices": 3, "edges": [[0, 1], [1, 2]]}, + "bound": 3 + } + }) + .to_string(); + + for solver in [None, Some("ilp"), Some("brute-force")] { + let first = server.solve_inner(&problem_json, solver, None).unwrap(); + let second = server.solve_inner(&problem_json, solver, None).unwrap(); + assert_eq!(first, second, "{solver:?} MCP output changed"); + } } #[test] @@ -396,7 +429,7 @@ mod tests { } #[test] - fn test_solve_customized_bundle_rejects_unsupported_target_without_panicking() { + fn test_solve_bundle_rejects_removed_customized_override() { let server = McpServer::new(); let problem_json = create_test_mis(&server); let bundle_json = server.reduce_inner(&problem_json, "QUBO").unwrap(); @@ -404,7 +437,7 @@ mod tests { assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( - err.contains("unsupported by customized solver"), + err.contains("Unknown solver: customized"), "unexpected error: {err}" ); } @@ -422,19 +455,15 @@ mod tests { } #[test] - fn test_inspect_minmaxmulticenter_lists_bruteforce_only() { + fn test_inspect_minmaxmulticenter_reports_registered_ilp_pipeline() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinMaxMulticenter", "variant": {"graph": "SimpleGraph", "weight": "i32"}, "data": { "graph": { - "inner": { - "nodes": [null, null, null, null], - "node_holes": [], - "edge_property": "undirected", - "edges": [[0, 1, null], [1, 2, null], [2, 3, null]] - } + "num_vertices": 4, + "edges": [[0, 1], [1, 2], [2, 3]] }, "vertex_weights": [1, 1, 1, 1], "edge_lengths": [1, 1, 1], @@ -444,19 +473,14 @@ mod tests { .to_string(); let result = server.inspect_problem_inner(&problem_json); - assert!(result.is_ok()); + assert!(result.is_ok(), "inspect failed: {result:?}"); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - let solvers: Vec<&str> = json["solvers"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert_eq!(solvers, vec!["brute-force"]); + assert_eq!(json["default_solver"], "ilp"); + assert!(json["solver_capabilities"]["ilp"]["reduction_path"].is_array()); } #[test] - fn test_inspect_minimum_cardinality_key_lists_customized_solver() { + fn test_inspect_minimum_cardinality_key_reports_native_solver() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinimumCardinalityKey", @@ -472,15 +496,10 @@ mod tests { let result = server.inspect_problem_inner(&problem_json); assert!(result.is_ok(), "inspect failed: {:?}", result); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - let solvers: Vec<&str> = json["solvers"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert!( - solvers.contains(&"customized"), - "inspect should list customized when supported, got: {json}" + assert_eq!(json["default_solver"], "native"); + assert_eq!( + json["solver_capabilities"]["native"]["implementation"], + "fd-minimum-cardinality-key" ); } @@ -492,7 +511,7 @@ mod tests { let result = server.solve_inner(&problem_json, Some("brute-force"), None); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["solver"], "brute-force"); + assert_eq!(json["solver"]["kind"], "brute-force"); } #[test] @@ -520,7 +539,10 @@ mod tests { let result = server.solve_inner(&aggregate_problem_json(), Some("ilp"), None); assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(err.contains("witness-capable"), "unexpected error: {err}"); + assert!( + err.contains("No ILP pipeline is registered"), + "unexpected error: {err}" + ); } #[test] diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index a0e2f1135..bd6fbceec 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -10,6 +10,7 @@ use problemreductions::registry::collect_schemas; use problemreductions::rules::{ CustomCost, MinimizeSteps, ReductionGraph, ReductionMode, TraversalFlow, }; +use problemreductions::solvers::SolverRequest; use problemreductions::topology::{ Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; @@ -21,8 +22,8 @@ use serde::Serialize; use std::collections::BTreeMap; use crate::dispatch::{ - load_problem, serialize_any_problem, BundleReplay, PathStep, ProblemJson, ProblemJsonOutput, - ReductionBundle, + load_problem, serialize_any_problem, solve_result_json, solver_capabilities_view, + solver_request, BundleReplay, PathStep, ProblemJson, ProblemJsonOutput, ReductionBundle, }; use crate::problem_name::{aliases_for, resolve_problem_ref, unknown_problem_error}; @@ -104,7 +105,7 @@ pub struct ReduceParams { pub struct SolveParams { #[schemars(description = "Problem JSON string (from create_problem or reduce)")] pub problem_json: String, - #[schemars(description = "Solver: 'ilp' (default), 'brute-force', or 'customized'")] + #[schemars(description = "Solver override: 'ilp' or 'brute-force'; omit for default dispatch")] pub solver: Option, #[schemars(description = "Timeout in seconds (0 = no limit, default: 0)")] pub timeout: Option, @@ -752,7 +753,7 @@ impl McpServer { let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); targets.sort(); targets.dedup(); - let solvers = problem.available_solvers(); + let solver_view = solver_capabilities_view(&problem)?; let result = serde_json::json!({ "kind": "problem", @@ -760,7 +761,9 @@ impl McpServer { "variant": variant, "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), - "solvers": solvers, + "solvers": solver_view.solvers, + "default_solver": solver_view.default_solver, + "solver_capabilities": solver_view.capabilities, "reduces_to": targets, }); Ok(serde_json::to_string_pretty(&result)?) @@ -866,13 +869,7 @@ impl McpServer { solver: Option<&str>, timeout: Option, ) -> anyhow::Result { - let solver_name = solver.unwrap_or("ilp"); - if solver_name != "brute-force" && solver_name != "ilp" && solver_name != "customized" { - anyhow::bail!( - "Unknown solver: {}. Available solvers: brute-force, ilp, customized", - solver_name - ); - } + let request = solver_request(solver)?; let json: serde_json::Value = serde_json::from_str(problem_json)?; let timeout_secs = timeout.unwrap_or(0); @@ -884,22 +881,18 @@ impl McpServer { if timeout_secs > 0 { let json_clone = json.clone(); - let solver_name = solver_name.to_string(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = if is_bundle { match serde_json::from_value::(json_clone) { - Ok(b) => solve_bundle_inner(b, &solver_name), + Ok(b) => solve_bundle_inner(b, request), Err(e) => Err(anyhow::Error::from(e)), } } else { match serde_json::from_value::(json_clone) { - Ok(pj) => solve_problem_inner( - &pj.problem_type, - &pj.variant, - pj.data, - &solver_name, - ), + Ok(pj) => { + solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, request) + } Err(e) => Err(anyhow::Error::from(e)), } }; @@ -911,10 +904,10 @@ impl McpServer { } } else if is_bundle { let bundle: ReductionBundle = serde_json::from_value(json)?; - solve_bundle_inner(bundle, solver_name) + solve_bundle_inner(bundle, request) } else { let pj: ProblemJson = serde_json::from_value(json)?; - solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, solver_name) + solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, request) } } } @@ -1027,7 +1020,7 @@ impl McpServer { .map_err(|e| e.to_string()) } - /// Solve a problem instance using brute-force, ILP, or customized solver + /// Solve a problem using deterministic default dispatch or an explicit override #[tool( name = "solve", annotations(read_only_hint = true, open_world_hint = false) @@ -1145,22 +1138,6 @@ fn ser(problem: T) -> anyhow::Result { util::ser(problem) } -fn solve_result_json( - problem: &str, - solver: &str, - result: &crate::dispatch::SolveResult, -) -> serde_json::Value { - let mut json = serde_json::json!({ - "problem": problem, - "solver": solver, - "evaluation": result.evaluation, - }); - if let Some(config) = &result.config { - json["solution"] = serde_json::json!(config); - } - json -} - fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { util::variant_map(pairs) } @@ -1474,69 +1451,37 @@ fn solve_problem_inner( problem_type: &str, variant: &BTreeMap, data: serde_json::Value, - solver_name: &str, + request: SolverRequest, ) -> anyhow::Result { let problem = load_problem(problem_type, variant, data)?; let name = problem.problem_name(); - - match solver_name { - "brute-force" => { - let result = problem.solve_brute_force(); - let json = solve_result_json(name, "brute-force", &result); - Ok(serde_json::to_string_pretty(&json)?) - } - "ilp" => { - let result = problem.solve_with_ilp()?; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let mut json = solve_result_json(name, "ilp", &result); - if name != "ILP" { - json["reduced_to"] = serde_json::json!("ILP"); - } - Ok(serde_json::to_string_pretty(&json)?) - } - "customized" => { - let result = problem.solve_with_customized()?; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let json = solve_result_json(name, "customized", &result); - Ok(serde_json::to_string_pretty(&json)?) - } - _ => unreachable!(), - } + let result = problem.solve_deterministically(request)?; + let json = solve_result_json(name, &result); + Ok(serde_json::to_string_pretty(&json)?) } /// Solve a reduction bundle: solve the target, then map the solution back. -fn solve_bundle_inner(bundle: ReductionBundle, solver_name: &str) -> anyhow::Result { +fn solve_bundle_inner(bundle: ReductionBundle, request: SolverRequest) -> anyhow::Result { let replay = BundleReplay::prepare(&bundle)?; - let target_result = match solver_name { - "brute-force" => replay.target.solve_brute_force_witness().ok_or_else(|| { + let target_result = replay.target.solve_deterministically(request)?; + let target_config = target_result.config.as_ref().ok_or_else(|| { anyhow::anyhow!( "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", replay.target_name ) - })?, - "ilp" => replay.target.solve_with_ilp()?, - "customized" => replay.target.solve_with_customized()?, - _ => unreachable!(), - }; + })?; - let (source_config, source_eval) = replay.extract(&target_result.config); + let (source_config, source_eval) = replay.extract(target_config); let json = serde_json::json!({ "problem": replay.source_name, - "solver": solver_name, - "reduced_to": replay.target_name, + "solver": &target_result.solver, "solution": source_config, "evaluation": source_eval, "intermediate": { "problem": replay.target_name, - "solution": target_result.config, + "solution": target_config, "evaluation": target_result.evaluation, }, }); diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7bc3f386a..8081a35f7 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -189,8 +189,12 @@ fn test_solve_balanced_complete_bipartite_subgraph_default_solver_uses_ilp() { let stdout = String::from_utf8(solve.stdout).unwrap(); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(json["problem"], "BalancedCompleteBipartiteSubgraph"); - assert_eq!(json["solver"], "ilp"); - assert_eq!(json["reduced_to"], "ILP"); + assert_eq!(json["solver"]["kind"], "ilp"); + assert!(json["solver"]["reduction_path"] + .as_array() + .and_then(|path| path.last()) + .and_then(|step| step.as_str()) + .is_some_and(|step| step.starts_with("ILP<"))); assert_eq!(json["evaluation"], "Or(true)"); assert!( json["solution"] @@ -1572,12 +1576,12 @@ fn test_solve_d2cif_default_solver_uses_ilp() { ); let stdout = String::from_utf8(solve_output.stdout).unwrap(); assert!( - stdout.contains("\"solver\": \"ilp\""), + stdout.contains("\"kind\": \"ilp\""), "expected ILP solver output, got: {stdout}" ); assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "expected auto-reduction marker, got: {stdout}" + stdout.contains("\"reduction_path\""), + "expected registered ILP pipeline metadata, got: {stdout}" ); std::fs::remove_file(&output_file).ok(); @@ -2712,7 +2716,7 @@ fn test_solve_brute_force() { ); let stdout = String::from_utf8(output.stdout).unwrap(); // auto_json: data commands output JSON when stdout is not a TTY (as in tests) - assert!(stdout.contains("\"solver\": \"brute-force\"")); + assert!(stdout.contains("\"kind\": \"brute-force\"")); assert!(stdout.contains("\"solution\"")); std::fs::remove_file(&problem_file).ok(); @@ -2744,11 +2748,11 @@ fn test_solve_ilp() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("\"solver\": \"ilp\"")); + assert!(stdout.contains("\"kind\": \"ilp\"")); assert!(stdout.contains("\"solution\"")); assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "MIS solved with ILP should show auto-reduction: {stdout}" + stdout.contains("\"reduction_path\""), + "MIS solved with ILP should report its registered pipeline: {stdout}" ); std::fs::remove_file(&problem_file).ok(); @@ -2756,7 +2760,7 @@ fn test_solve_ilp() { #[test] fn test_solve_ilp_default() { - // Default solver is ilp + // MIS has no native solver, so its registered ILP pipeline is the default. let problem_file = std::env::temp_dir().join("pred_test_solve_default.json"); let create_out = pred() .args([ @@ -2783,16 +2787,15 @@ fn test_solve_ilp_default() { let stdout = String::from_utf8(output.stdout).unwrap(); // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("\"solver\": \"ilp\"") && stdout.contains("\"reduced_to\": \"ILP\""), - "MIS with default solver should show auto-reduction: {stdout}" + stdout.contains("\"kind\": \"ilp\"") && stdout.contains("\"reduction_path\""), + "MIS with default solver should report its registered ILP pipeline: {stdout}" ); std::fs::remove_file(&problem_file).ok(); } #[test] -fn test_solve_ilp_shows_via_ilp() { - // When solving a non-ILP problem with ILP solver, output should show "via ILP" +fn test_solve_ilp_reports_registered_pipeline() { let problem_file = std::env::temp_dir().join("pred_test_solve_via_ilp.json"); let create_out = pred() .args([ @@ -2819,8 +2822,8 @@ fn test_solve_ilp_shows_via_ilp() { let stdout = String::from_utf8(output.stdout).unwrap(); // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "Non-ILP problem solved with ILP should show auto-reduction indicator, got: {stdout}" + stdout.contains("\"reduction_path\""), + "Non-ILP problem solved with ILP should report its registered pipeline, got: {stdout}" ); assert!(stdout.contains("\"problem\": \"MaximumIndependentSet\"")); @@ -2865,7 +2868,7 @@ fn test_solve_json_output() { let content = std::fs::read_to_string(&result_file).unwrap(); let json: serde_json::Value = serde_json::from_str(&content).unwrap(); assert!(json["solution"].is_array()); - assert_eq!(json["solver"], "brute-force"); + assert_eq!(json["solver"]["kind"], "brute-force"); std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&result_file).ok(); @@ -3021,13 +3024,13 @@ fn test_solve_direct_ilp_i32_problem() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("\"problem\": \"ILP\""), "{stdout}"); - assert!(stdout.contains("\"solver\": \"ilp\""), "{stdout}"); + assert!(stdout.contains("\"kind\": \"ilp\""), "{stdout}"); std::fs::remove_file(&problem_file).ok(); } #[test] -fn test_solve_sequencing_to_minimize_weighted_completion_time_default_solver() { +fn test_solve_partial_ilp_route_defaults_to_brute_force() { let problem_file = std::env::temp_dir() .join("pred_test_solve_sequencing_to_minimize_weighted_completion_time.json"); @@ -3066,7 +3069,7 @@ fn test_solve_sequencing_to_minimize_weighted_completion_time_default_solver() { stdout.contains("\"problem\": \"SequencingToMinimizeWeightedCompletionTime\""), "{stdout}" ); - assert!(stdout.contains("\"solver\": \"ilp\""), "{stdout}"); + assert!(stdout.contains("\"kind\": \"brute-force\""), "{stdout}"); assert!(stdout.contains("\"solution\": ["), "{stdout}"); std::fs::remove_file(&problem_file).ok(); @@ -3105,7 +3108,7 @@ fn test_solve_unknown_solver() { } #[test] -fn test_solve_help_mentions_bruteforce_only_models() { +fn test_solve_help_describes_deterministic_dispatch_and_overrides() { let output = pred().args(["solve", "--help"]).output().unwrap(); assert!( output.status.success(), @@ -3113,7 +3116,11 @@ fn test_solve_help_mentions_bruteforce_only_models() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("MinMaxMulticenter"), "stdout: {stdout}"); + assert!( + stdout.contains("deterministically selects"), + "stdout: {stdout}" + ); + assert!(stdout.contains("never searches"), "stdout: {stdout}"); assert!(stdout.contains("--solver brute-force"), "stdout: {stdout}"); } @@ -4361,11 +4368,8 @@ fn test_solve_minmaxmulticenter_default_solver_uses_ilp() { String::from_utf8_lossy(&solve_out.stderr) ); let stdout = String::from_utf8(solve_out.stdout).unwrap(); - assert!(stdout.contains("\"solver\": \"ilp\""), "stdout: {stdout}"); - assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "stdout: {stdout}" - ); + assert!(stdout.contains("\"kind\": \"ilp\""), "stdout: {stdout}"); + assert!(stdout.contains("\"reduction_path\""), "stdout: {stdout}"); std::fs::remove_file(&problem_file).ok(); } @@ -5541,11 +5545,11 @@ fn test_solve_sum_of_squares_partition_default_solver_uses_ilp() { let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stdout.contains("\"solver\": \"ilp\""), + stdout.contains("\"kind\": \"ilp\""), "stdout should report the ILP solver, got: {stdout}" ); assert!( - stdout.contains("\"reduced_to\": \"ILP\""), + stdout.contains("\"reduction_path\""), "stdout should report the ILP reduction target, got: {stdout}" ); @@ -7070,7 +7074,7 @@ fn test_solve_multiple_copy_file_allocation_brute_force() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stdout.contains("\"solver\": \"brute-force\""), + stdout.contains("\"kind\": \"brute-force\""), "MultipleCopyFileAllocation should solve with brute-force: {stdout}" ); @@ -8481,7 +8485,7 @@ fn test_create_sequencing_within_intervals_rejects_overflow() { } #[test] -fn test_solve_customized_unsupported_problem_shows_hint() { +fn deterministic_solver_dispatch_rejects_non_override_solver_names() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_unsupported.json"); let create_out = pred() .args([ @@ -8496,27 +8500,69 @@ fn test_solve_customized_unsupported_problem_shows_hint() { .unwrap(); assert!(create_out.status.success()); - let output = pred() - .args([ - "solve", - problem_file.to_str().unwrap(), - "--solver", - "customized", - ]) - .output() - .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unsupported by customized solver"), - "expected customized solver hint, got: {stderr}" - ); + for rejected in ["auto", "customized", "native", "fd-minimum-cardinality-key"] { + let output = pred() + .args([ + "solve", + problem_file.to_str().unwrap(), + "--solver", + rejected, + ]) + .output() + .unwrap(); + assert!(!output.status.success(), "accepted --solver {rejected}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!("Unknown solver: {rejected}")), + "unexpected error for {rejected}: {stderr}" + ); + } + + std::fs::remove_file(&problem_file).ok(); +} + +#[test] +fn deterministic_solver_dispatch_cli_output_is_repeatable_for_each_solver_class() { + let problem_file = std::env::temp_dir().join("pred_test_solver_repeatability.json"); + let problem = serde_json::json!({ + "type": "RootedTreeArrangement", + "variant": {"graph": "SimpleGraph"}, + "data": { + "graph": {"num_vertices": 3, "edges": [[0, 1], [1, 2]]}, + "bound": 3 + } + }); + std::fs::write(&problem_file, serde_json::to_vec(&problem).unwrap()).unwrap(); + + for solver in [None, Some("ilp"), Some("brute-force")] { + let run = || { + let mut command = pred(); + command.args(["--json", "solve", problem_file.to_str().unwrap()]); + if let Some(solver) = solver { + command.args(["--solver", solver]); + } + command.output().unwrap() + }; + let first = run(); + let second = run(); + assert!( + first.status.success(), + "first {solver:?} solve failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + assert!( + second.status.success(), + "second {solver:?} solve failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + assert_eq!(first.stdout, second.stdout, "{solver:?} output changed"); + } std::fs::remove_file(&problem_file).ok(); } #[test] -fn test_solve_customized_minimum_cardinality_key() { +fn deterministic_solver_dispatch_defaults_minimum_cardinality_key_to_native() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_mck.json"); let create_out = pred() .args([ @@ -8538,12 +8584,7 @@ fn test_solve_customized_minimum_cardinality_key() { ); let output = pred() - .args([ - "solve", - problem_file.to_str().unwrap(), - "--solver", - "customized", - ]) + .args(["solve", problem_file.to_str().unwrap()]) .output() .unwrap(); assert!( @@ -8552,9 +8593,11 @@ fn test_solve_customized_minimum_cardinality_key() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("customized"), - "expected 'customized' in output, got: {stdout}" + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["solver"]["kind"], "native"); + assert_eq!( + json["solver"]["implementation"], + "fd-minimum-cardinality-key" ); assert!( stdout.contains("Min("), @@ -8565,7 +8608,7 @@ fn test_solve_customized_minimum_cardinality_key() { } #[test] -fn test_solve_customized_bundle_does_not_panic() { +fn test_solve_bundle_rejects_removed_customized_override_without_panicking() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_bundle_problem.json"); let bundle_file = std::env::temp_dir().join("pred_test_solve_customized_bundle.json"); @@ -8611,15 +8654,15 @@ fn test_solve_customized_bundle_does_not_panic() { let stderr = String::from_utf8_lossy(&solve_out.stderr); assert!( !stderr.contains("panicked at"), - "customized bundle solve should fail gracefully, got: {stderr}" + "removed override should fail gracefully, got: {stderr}" ); assert!( !solve_out.status.success(), - "customized solver should not silently succeed on unsupported bundle target" + "removed solver override should not silently succeed" ); assert!( - stderr.contains("unsupported by customized solver"), - "expected customized solver error, got: {stderr}" + stderr.contains("Unknown solver: customized"), + "expected removed solver error, got: {stderr}" ); std::fs::remove_file(&problem_file).ok(); @@ -8627,7 +8670,7 @@ fn test_solve_customized_bundle_does_not_panic() { } #[test] -fn test_inspect_minimum_cardinality_key_lists_customized_solver() { +fn test_inspect_minimum_cardinality_key_reports_native_capability() { let problem_file = std::env::temp_dir().join("pred_test_inspect_customized_mck.json"); let create_out = pred() .args([ @@ -8660,15 +8703,10 @@ fn test_inspect_minimum_cardinality_key_lists_customized_solver() { let stdout = String::from_utf8(inspect_out.stdout).unwrap(); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let solvers: Vec<&str> = json["solvers"] - .as_array() - .unwrap() - .iter() - .map(|value| value.as_str().unwrap()) - .collect(); - assert!( - solvers.contains(&"customized"), - "inspect should list customized when supported, got: {json}" + assert_eq!(json["default_solver"], "native"); + assert_eq!( + json["solver_capabilities"]["native"]["implementation"], + "fd-minimum-cardinality-key" ); std::fs::remove_file(&problem_file).ok(); diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index ba290ce40..1235d53b1 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -158,7 +158,6 @@ impl TimetableDesign { ((craftsman * self.num_tasks) + task) * self.num_periods + period } - #[cfg(feature = "ilp-solver")] pub(crate) fn solve_via_required_assignments(&self) -> Option> { #[derive(Clone)] struct PairRequirement { diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e648997a4..6e110e612 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -406,6 +406,7 @@ pub use graph::{ AggregateReductionChain, NeighborInfo, NeighborTree, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, }; +pub(crate) use traits::DynReductionResult; pub use traits::{ AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, }; diff --git a/src/solvers/brute_force.rs b/src/solvers/brute_force.rs index caf8ca817..9fca85076 100644 --- a/src/solvers/brute_force.rs +++ b/src/solvers/brute_force.rs @@ -25,7 +25,16 @@ impl BruteForce { P: Problem, P::Value: Aggregate, { - self.find_all_witnesses(problem).into_iter().next() + let total = self.solve(problem); + + if !P::Value::supports_witnesses() { + return None; + } + + DimsIterator::new(problem.dims()).find(|config| { + let value = problem.evaluate(config); + P::Value::contributes_to_witnesses(&value, &total) + }) } /// Find all witness configurations for witness-supporting aggregates. diff --git a/src/solvers/customized/mod.rs b/src/solvers/customized/mod.rs deleted file mode 100644 index 3553e4d19..000000000 --- a/src/solvers/customized/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Customized solver module. -//! -//! Provides exact witness recovery for problems that have dedicated -//! structure-exploiting backends, without requiring ILP reduction paths. - -pub(crate) mod fd_subset_search; -pub(crate) mod partial_feedback_edge_set; -pub(crate) mod rooted_tree_arrangement; -mod solver; - -pub use solver::CustomizedSolver; diff --git a/src/solvers/ilp/mod.rs b/src/solvers/ilp/mod.rs index b09109814..c061a84a7 100644 --- a/src/solvers/ilp/mod.rs +++ b/src/solvers/ilp/mod.rs @@ -23,5 +23,4 @@ mod solver; -pub use solver::ILPSolver; -pub use solver::SolveViaReductionError; +pub use solver::{ILPSolveError, ILPSolver}; diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 51b2a0df2..81eacc124 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -1,15 +1,45 @@ //! ILP solver implementation using HiGHS. use crate::models::algebraic::{Comparison, ObjectiveSense, VariableDomain, ILP}; -use crate::models::misc::TimetableDesign; -use crate::rules::{ReduceTo, ReductionMode, ReductionResult}; +use crate::rules::{ReduceTo, ReductionResult}; #[cfg(not(feature = "ilp-highs"))] use good_lp::default_solver; #[cfg(feature = "ilp-highs")] use good_lp::highs; #[cfg(feature = "ilp-highs")] use good_lp::solvers::highs::HighsParallelType; -use good_lp::{variable, ProblemVariables, Solution, SolverModel, Variable}; +use good_lp::{ + variable, ProblemVariables, ResolutionError, Solution, SolutionStatus, SolverModel, Variable, +}; + +/// A failure to produce a proven-optimal ILP solution. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ILPSolveError { + /// The constraints have no feasible assignment. + #[error("the ILP is infeasible")] + Infeasible, + /// The objective is unbounded. + #[error("the ILP objective is unbounded")] + Unbounded, + /// The configured time limit was reached before optimality was proven. + #[error("the ILP solver reached its time limit before proving optimality")] + Timeout, + /// The selected backend failed for another reason. + #[error("the ILP backend failed: {0}")] + BackendFailure(String), + /// Type-erased dispatch received a value other than a supported ILP variant. + #[error("the ILP backend supports only ILP and ILP")] + UnsupportedProblemType, +} + +fn classify_backend_error(error: ResolutionError, time_limit: Option) -> ILPSolveError { + match error { + ResolutionError::Infeasible => ILPSolveError::Infeasible, + ResolutionError::Unbounded => ILPSolveError::Unbounded, + ResolutionError::Other("NoSolutionFound") if time_limit.is_some() => ILPSolveError::Timeout, + other => ILPSolveError::BackendFailure(other.to_string()), + } +} /// An ILP solver using the HiGHS backend. /// @@ -30,9 +60,9 @@ use good_lp::{variable, ProblemVariables, Solution, SolverModel, Variable}; /// ); /// /// let solver = ILPSolver::new(); -/// if let Some(solution) = solver.solve(&ilp) { -/// println!("Solution: {:?}", solution); -/// } +/// let solution = solver.solve(&ilp)?; +/// println!("Solution: {:?}", solution); +/// # Ok::<(), problemreductions::solvers::ILPSolveError>(()) /// ``` #[derive(Debug, Clone, Default)] pub struct ILPSolver { @@ -40,33 +70,6 @@ pub struct ILPSolver { pub time_limit: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SolveViaReductionError { - WitnessPathRequired { name: String }, - NoReductionPath { name: String }, - NoSolution { name: String }, -} - -impl std::fmt::Display for SolveViaReductionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SolveViaReductionError::WitnessPathRequired { name } => write!( - f, - "ILP solving requires a witness-capable source problem and reduction path; only aggregate-value solving is available for {}.", - name - ), - SolveViaReductionError::NoReductionPath { name } => { - write!(f, "No reduction path from {} to ILP", name) - } - SolveViaReductionError::NoSolution { name } => { - write!(f, "ILP solver found no solution for {}", name) - } - } - } -} - -impl std::error::Error for SolveViaReductionError {} - impl ILPSolver { /// Create a new ILP solver with default settings. pub fn new() -> Self { @@ -82,13 +85,17 @@ impl ILPSolver { /// Solve an ILP problem directly. /// - /// Returns `None` if the problem is infeasible or the solver fails. + /// Returns a classified error when the problem is infeasible, the time + /// limit is reached, or the backend fails. /// The returned solution is a configuration vector where each element /// is the variable value (config index = value). - pub fn solve(&self, problem: &ILP) -> Option> { + pub fn solve(&self, problem: &ILP) -> Result, ILPSolveError> { let n = problem.num_vars; if n == 0 { - return problem.is_feasible(&[]).then_some(vec![]); + return problem + .is_feasible(&[]) + .then_some(vec![]) + .ok_or(ILPSolveError::Infeasible); } // Derive tighter per-variable upper bounds from single-variable ≤ constraints. @@ -173,7 +180,23 @@ impl ILPSolver { } // Solve - let solution = model.solve().ok()?; + #[cfg(feature = "ilp-highs")] + let effective_time_limit = self.time_limit; + #[cfg(not(feature = "ilp-highs"))] + let effective_time_limit = None; + let solution = model + .solve() + .map_err(|error| classify_backend_error(error, effective_time_limit))?; + + match solution.status() { + SolutionStatus::Optimal => {} + SolutionStatus::TimeLimit => return Err(ILPSolveError::Timeout), + SolutionStatus::GapLimit => { + return Err(ILPSolveError::BackendFailure( + "the backend stopped at its gap limit before proving optimality".to_string(), + )); + } + } // Extract solution: config index = value (no lower bound offset) let result: Vec = vars @@ -184,12 +207,12 @@ impl ILPSolver { }) .collect(); - Some(result) + Ok(result) } - /// Solve any problem that reduces to `ILP`. + /// Solve any problem that reduces directly to `ILP`. /// - /// This method first reduces the problem to a binary ILP, solves the ILP, + /// This method first reduces the problem to the selected ILP domain, solves the ILP, /// and then extracts the solution back to the original problem space. /// /// # Example @@ -207,143 +230,29 @@ impl ILPSolver { /// /// // Solve using ILP solver /// let solver = ILPSolver::new(); - /// if let Some(solution) = solver.solve_reduced(&problem) { - /// println!("Solution: {:?}", solution); - /// } + /// let solution = solver.solve_reduced::(&problem)?; + /// println!("Solution: {:?}", solution); + /// # Ok::<(), problemreductions::solvers::ILPSolveError>(()) /// ``` - pub fn solve_reduced

(&self, problem: &P) -> Option> + pub fn solve_reduced(&self, problem: &P) -> Result, ILPSolveError> where - P: ReduceTo>, + V: VariableDomain, + P: ReduceTo>, { let reduction = problem.reduce_to(); let ilp_solution = self.solve(reduction.target_problem())?; - Some(reduction.extract_solution(&ilp_solution)) + Ok(reduction.extract_solution(&ilp_solution)) } - /// Solve a type-erased problem directly when a native solver hook exists. - /// - /// Returns `None` if the input type has no direct solver or the solver finds no solution. - pub fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { + /// Solve a type-erased supported ILP variant directly. + pub(crate) fn solve_dyn(&self, any: &dyn std::any::Any) -> Result, ILPSolveError> { if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } - if let Some(problem) = any.downcast_ref::() { - return problem.solve_via_required_assignments(); - } - None - } - - fn supports_direct_dyn(&self, any: &dyn std::any::Any) -> bool { - any.is::>() || any.is::>() || any.is::() - } - - /// Two-level path selection: - /// 1. Dijkstra finds the cheapest path to each ILP variant using - /// `MinimizeStepsThenOverhead` (additive edge costs: step count + log overhead). - /// 2. Across ILP variants, we pick the path whose composed final output size - /// is smallest — this is the actual ILP problem size the solver will face. - fn best_path_to_ilp( - &self, - graph: &crate::rules::ReductionGraph, - name: &str, - variant: &std::collections::BTreeMap, - mode: ReductionMode, - instance: &dyn std::any::Any, - ) -> Option { - let ilp_variants = graph.variants_for("ILP"); - let input_size = crate::rules::ReductionGraph::compute_source_size(name, instance); - let mut best_path: Option = None; - let mut best_cost = f64::INFINITY; - - for dv in &ilp_variants { - if let Some(path) = graph.find_cheapest_path_mode( - name, - variant, - "ILP", - dv, - mode, - &input_size, - &crate::rules::MinimizeStepsThenOverhead, - ) { - // Use composed final output size for cross-variant comparison, - // since this determines the actual ILP problem size. - let final_size = graph - .evaluate_path_overhead(&path, &input_size) - .unwrap_or_default(); - let cost = final_size.total() as f64; - if cost < best_cost { - best_cost = cost; - best_path = Some(path); - } - } - } - - best_path - } - - pub fn try_solve_via_reduction( - &self, - name: &str, - variant: &std::collections::BTreeMap, - instance: &dyn std::any::Any, - ) -> Result, SolveViaReductionError> { - if self.supports_direct_dyn(instance) { - return self - .solve_dyn(instance) - .ok_or_else(|| SolveViaReductionError::NoSolution { - name: name.to_string(), - }); - } - - let graph = crate::rules::ReductionGraph::new(); - - let Some(path) = - self.best_path_to_ilp(&graph, name, variant, ReductionMode::Witness, instance) - else { - if self - .best_path_to_ilp(&graph, name, variant, ReductionMode::Aggregate, instance) - .is_some() - { - return Err(SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - }); - } - - return Err(SolveViaReductionError::NoReductionPath { - name: name.to_string(), - }); - }; - - let chain = graph.reduce_along_path(&path, instance).ok_or_else(|| { - SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - } - })?; - let ilp_solution = self.solve_dyn(chain.target_problem_any()).ok_or_else(|| { - SolveViaReductionError::NoSolution { - name: name.to_string(), - } - })?; - Ok(chain.extract_solution(&ilp_solution)) - } - - /// Solve a type-erased problem by finding a reduction path to ILP. - /// - /// Tries all ILP variants, picks the cheapest path, reduces, solves, - /// and extracts the solution back. Falls back to direct ILP solve if - /// the problem is already an ILP type. - /// - /// Returns `None` if no path to ILP exists or the solver finds no solution. - pub fn solve_via_reduction( - &self, - name: &str, - variant: &std::collections::BTreeMap, - instance: &dyn std::any::Any, - ) -> Option> { - self.try_solve_via_reduction(name, variant, instance).ok() + Err(ILPSolveError::UnsupportedProblemType) } } diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index 9a1283cfc..a91b8fd94 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -1,17 +1,28 @@ //! Solvers for computational problems. mod brute_force; -pub mod customized; pub mod decision_search; +mod native; +#[cfg(feature = "ilp-solver")] +mod pipelines; +mod registry; +mod resolver; #[cfg(feature = "ilp-solver")] pub mod ilp; pub use brute_force::BruteForce; -pub use customized::CustomizedSolver; +pub use registry::{ + solver_capabilities, ExactProblemKey, IlpSolverCapability, NativeSolverCapability, + RegistryBuildError, SolverCapabilities, +}; +pub use resolver::{ + solve_deterministically, DeterministicSolveError, DeterministicSolveResult, SolverExecution, + SolverRequest, +}; #[cfg(feature = "ilp-solver")] -pub use ilp::ILPSolver; +pub use ilp::{ILPSolveError, ILPSolver}; use crate::traits::Problem; diff --git a/src/solvers/customized/fd_subset_search.rs b/src/solvers/native/fd_subset_search.rs similarity index 100% rename from src/solvers/customized/fd_subset_search.rs rename to src/solvers/native/fd_subset_search.rs diff --git a/src/solvers/native/mod.rs b/src/solvers/native/mod.rs new file mode 100644 index 000000000..6625219fa --- /dev/null +++ b/src/solvers/native/mod.rs @@ -0,0 +1,9 @@ +//! Dedicated native solver backends. +//! +//! Each backend is registered for one exact problem variant. Dispatch is +//! performed by the solver capability registry rather than a downcast chain. + +pub(crate) mod fd_subset_search; +pub(crate) mod partial_feedback_edge_set; +pub(crate) mod rooted_tree_arrangement; +mod solver; diff --git a/src/solvers/customized/partial_feedback_edge_set.rs b/src/solvers/native/partial_feedback_edge_set.rs similarity index 100% rename from src/solvers/customized/partial_feedback_edge_set.rs rename to src/solvers/native/partial_feedback_edge_set.rs diff --git a/src/solvers/customized/rooted_tree_arrangement.rs b/src/solvers/native/rooted_tree_arrangement.rs similarity index 100% rename from src/solvers/customized/rooted_tree_arrangement.rs rename to src/solvers/native/rooted_tree_arrangement.rs diff --git a/src/solvers/customized/solver.rs b/src/solvers/native/solver.rs similarity index 72% rename from src/solvers/customized/solver.rs rename to src/solvers/native/solver.rs index a980a9bb6..de5dc46a0 100644 --- a/src/solvers/customized/solver.rs +++ b/src/solvers/native/solver.rs @@ -1,76 +1,72 @@ -//! CustomizedSolver: structure-exploiting exact witness solver. -//! -//! Uses direct downcast dispatch to call dedicated backends for -//! supported problem types, returning `None` for unsupported problems. +//! Exact native solvers and their exact-variant registrations. use super::fd_subset_search::{ self, compute_closure, find_essential_attributes, find_essential_attributes_restricted, is_minimal_key, is_superkey, BranchDecision, }; use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; -use crate::models::misc::{AdditionalKey, BoyceCoddNormalFormViolation}; +use crate::models::misc::{AdditionalKey, BoyceCoddNormalFormViolation, TimetableDesign}; use crate::models::set::{MinimumCardinalityKey, PrimeAttributeName}; +use crate::solvers::registry::NativeSolverRegistration; use crate::topology::SimpleGraph; +use crate::traits::Problem; use std::collections::HashSet; -/// A solver that uses problem-specific backends for exact witness recovery. -/// -/// Unlike `BruteForce`, which enumerates all configurations, `CustomizedSolver` -/// exploits problem structure (functional-dependency closure, cycle hitting, -/// tree arrangement) to prune search and find witnesses more efficiently. -/// -/// Returns `None` for unsupported problem types. -#[derive(Default)] -pub struct CustomizedSolver; - -impl CustomizedSolver { - /// Create a new `CustomizedSolver`. - pub fn new() -> Self { - Self - } - - /// Check whether a type-erased problem is supported by the customized solver. - pub fn supports_problem(any: &dyn std::any::Any) -> bool { - any.is::() - || any.is::() - || any.is::() - || any.is::() - || any.is::>() - || any.is::>() - } - - /// Attempt to solve a type-erased problem using a dedicated backend. - /// - /// Returns `Some(config)` if a satisfying witness is found, `None` if - /// the problem type is unsupported or no witness exists. - pub fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { - if let Some(p) = any.downcast_ref::() { - return solve_minimum_cardinality_key(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_additional_key(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_prime_attribute_name(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_bcnf_violation(p); - } - if let Some(p) = any.downcast_ref::>() { - return super::partial_feedback_edge_set::find_witness(p); - } - if let Some(p) = any.downcast_ref::>() { - return super::rooted_tree_arrangement::find_witness(p); +macro_rules! register_native_solver { + ($problem:ty, $implementation:literal, $solve:path) => { + inventory::submit! { + NativeSolverRegistration { + source_name: <$problem as Problem>::NAME, + source_variant_fn: <$problem as Problem>::variant, + implementation: $implementation, + solve_fn: |any| { + let problem = any.downcast_ref::<$problem>().expect( + "native solver registration received the wrong concrete type", + ); + $solve(problem) + }, + } } - None - } + }; } +register_native_solver!( + MinimumCardinalityKey, + "fd-minimum-cardinality-key", + solve_minimum_cardinality_key +); +register_native_solver!(AdditionalKey, "fd-additional-key", solve_additional_key); +register_native_solver!( + PrimeAttributeName, + "fd-prime-attribute-name", + solve_prime_attribute_name +); +register_native_solver!( + BoyceCoddNormalFormViolation, + "fd-bcnf-violation", + solve_bcnf_violation +); +register_native_solver!( + PartialFeedbackEdgeSet, + "partial-feedback-edge-set", + super::partial_feedback_edge_set::find_witness +); +register_native_solver!( + RootedTreeArrangement, + "rooted-tree-arrangement", + super::rooted_tree_arrangement::find_witness +); +register_native_solver!( + TimetableDesign, + "timetable-required-assignments", + TimetableDesign::solve_via_required_assignments +); + /// Solve MinimumCardinalityKey: find a minimal key with smallest cardinality. /// /// Uses iterative deepening by cardinality to guarantee the first solution /// found has the minimum number of attributes. -fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option> { +pub(crate) fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option> { let n = problem.num_attributes(); let deps = problem.dependencies().to_vec(); @@ -113,7 +109,7 @@ fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option Option> { +pub(crate) fn solve_additional_key(problem: &AdditionalKey) -> Option> { let n_attrs = problem.num_attributes(); let deps = problem.dependencies().to_vec(); let relation_attrs = problem.relation_attrs(); @@ -176,7 +172,7 @@ fn solve_additional_key(problem: &AdditionalKey) -> Option> { } /// Solve PrimeAttributeName: find a candidate key containing the query attribute. -fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option> { +pub(crate) fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option> { let n = problem.num_attributes(); let deps = problem.dependencies().to_vec(); let query = problem.query_attribute(); @@ -220,7 +216,7 @@ fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option /// Solve BoyceCoddNormalFormViolation: find a subset X of target_subset such that /// the closure of X contains some but not all of target_subset \ X. -fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option> { +pub(crate) fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option> { let n_attrs = problem.num_attributes(); let deps = problem.functional_deps().to_vec(); let target = problem.target_subset(); @@ -263,5 +259,5 @@ fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option { + inventory::submit! { + IlpPipelineRegistration { + path: &[ + $(StaticProblemStep { + name: $name, + variant: &[$(($key, $value)),*], + }),+ + ], + } + } + }; +} + +register_ilp_pipeline! { + ("AcyclicPartition", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BMF", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BalancedCompleteBipartiteSubgraph", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BicliqueCover", []), + ("BMF", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BiconnectivityAugmentation", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BinPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BottleneckTravelingSalesman", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BoundedComponentSpanningForest", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("CapacityAssignment", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("CircuitSAT", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ClosestString", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ClosestSubstring", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("Clustering", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveBlockMinimization", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveOnesMatrixAugmentation", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveOnesSubmatrix", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsistencyOfDatabaseFrequencyTables", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionOptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("DirectedHamiltonianPath", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DirectedTwoCommodityIntegralFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("DisjointConnectingPaths", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("EulerianPath", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ExactCoverBy3Sets", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ExpectedRetrievalCost", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Factoring", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("FeasibleRegisterAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("FlowShopScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("GraphPartitioning", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HamiltonianCircuit", [("graph", "SimpleGraph")]), + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HamiltonianPath", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HighlyConnectedDeletion", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ILP", [("variable", "i32")]), +} + +// This exact variant also has a native backend. Default dispatch selects the +// native registration, while an explicit ILP override executes this pipeline. +register_ilp_pipeline! { + ("RootedTreeArrangement", [("graph", "SimpleGraph")]), + ("RootedTreeStorageAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowBundles", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowHomologousArcs", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowWithMultipliers", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IsomorphicSpanningTree", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KClique", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KColoring", [("graph", "SimpleGraph"), ("k", "KN")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KColoring", [("graph", "SimpleGraph"), ("k", "K3")]), + ("Clustering", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "KN")]), + ("Satisfiability", []), + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "K2")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "K3")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Knapsack", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LengthBoundedDisjointPaths", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestCommonSubsequence", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestPath", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MaximalIS", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Maximum2Satisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "One")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCommonEdgeSubgraph", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumContactMapOverlap", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumDomaticNumber", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumEdgeWeightedKClique", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumEdgeWeightedKClique", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "KingsSubgraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "KingsSubgraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "TriangularSubgraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumLeafSpanningTree", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MaximumLikelihoodRanking", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumMatching", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "One")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinMaxMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumCapacitatedSpanningTree", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumCutIntoBoundedSets", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumDiscretePlanarInverseKinematics", []), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumEdgeCostFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumExternalMacroDataCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumFaultDetectionTestSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumFeedbackVertexSet", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumGraphBandwidth", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumHittingSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumInternalMacroDataCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMatrixCover", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMaximalMatching", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMetricDimension", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMultiwayCut", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumTardinessSequencing", [("weight", "One")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumTardinessSequencing", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumHittingSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumWeightDecoding", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MixedChinesePostman", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MonochromaticTriangle", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MultipleCopyFileAllocation", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MultiprocessorScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Numerical3DimensionalMatching", []), + ("NumericalMatchingWithTargetSums", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("NumericalMatchingWithTargetSums", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("OpenShopScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("OptimumCommunicationSpanningTree", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PaintShop", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartiallyOrderedKnapsack", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Partition", []), + ("MultiprocessorScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoCliques", [("graph", "SimpleGraph")]), + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoPathsOfLength2", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoTriangles", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PathConstrainedNetworkFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("PrecedenceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PreemptiveScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("QuadraticAssignment", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RectilinearPictureCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RegisterSufficiency", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ResourceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RootedTreeStorageAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("RuralPostman", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("Satisfiability", []), + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SchedulingToMinimizeWeightedCompletionTime", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SchedulingWithIndividualDeadlines", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeMaximumCumulativeCost", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeTardyTaskWeight", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeWeightedTardiness", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SequencingWithDeadlinesAndSetUpTimes", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingWithReleaseTimesAndDeadlines", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingWithinIntervals", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SetSplitting", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ShortestCommonSupersequence", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ShortestWeightConstrainedPath", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SparseMatrixCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StackerCrane", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StringToStringCorrection", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StrongConnectivityAugmentation", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SubgraphIsomorphism", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SumOfSquaresPartition", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ThreeDimensionalMatching", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ThreePartition", []), + ("ResourceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("TravelingSalesman", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("UndirectedFlowLowerBounds", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("UndirectedTwoCommodityIntegralFlow", []), + ("ILP", [("variable", "i32")]), +} diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs new file mode 100644 index 000000000..e229700b9 --- /dev/null +++ b/src/solvers/registry.rs @@ -0,0 +1,385 @@ +//! Deterministic solver capabilities for exact problem variants. + +use crate::registry::VariantEntry; +use crate::rules::registry::{reduction_entries, ReduceFn, ReductionEntry}; +#[cfg(feature = "ilp-solver")] +use crate::rules::DynReductionResult; +use serde::Serialize; +use std::any::Any; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::OnceLock; + +/// Canonical identity of one concrete problem variant. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct ExactProblemKey { + pub name: String, + pub variant: BTreeMap, +} + +impl ExactProblemKey { + pub fn new(name: impl Into, variant: BTreeMap) -> Self { + Self { + name: name.into(), + variant, + } + } + + fn from_static(step: &StaticProblemStep) -> Self { + Self::new( + step.name, + step.variant + .iter() + .map(|&(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + } + + /// Format the key using the catalog's canonical problem notation. + pub fn label(&self) -> String { + if self.variant.is_empty() { + return self.name.clone(); + } + let values = self + .variant + .values() + .cloned() + .collect::>() + .join(", "); + format!("{}<{values}>", self.name) + } + + fn is_supported_ilp(&self) -> bool { + self.name == "ILP" + && matches!( + self.variant.get("variable").map(String::as_str), + Some("bool" | "i32") + ) + } +} + +/// A compile-time path node used by fixed ILP pipeline declarations. +#[derive(Clone, Copy)] +pub(crate) struct StaticProblemStep { + pub name: &'static str, + pub variant: &'static [(&'static str, &'static str)], +} + +/// A fixed ILP pipeline declaration. +/// +/// Every adjacent pair is resolved to one exact witness reduction while the +/// registry is constructed. Runtime solving executes the resolved function +/// pointers and never searches the reduction graph. +pub(crate) struct IlpPipelineRegistration { + pub(crate) path: &'static [StaticProblemStep], +} + +inventory::collect!(IlpPipelineRegistration); + +type NativeSolveFn = fn(&dyn Any) -> Option>; + +/// A dedicated solver registered for one exact problem variant. +#[derive(Debug)] +pub(crate) struct NativeSolverRegistration { + pub(crate) source_name: &'static str, + pub(crate) source_variant_fn: fn() -> Vec<(&'static str, &'static str)>, + pub(crate) implementation: &'static str, + pub(crate) solve_fn: NativeSolveFn, +} + +impl NativeSolverRegistration { + fn source_key(&self) -> ExactProblemKey { + ExactProblemKey::new( + self.source_name, + (self.source_variant_fn)() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + } +} + +inventory::collect!(NativeSolverRegistration); + +#[derive(Debug)] +pub(crate) struct CompiledIlpPipeline { + path: Vec, + reducers: Vec, +} + +impl CompiledIlpPipeline { + pub(crate) fn path(&self) -> &[ExactProblemKey] { + &self.path + } + + pub(crate) fn path_labels(&self) -> Vec { + self.path.iter().map(ExactProblemKey::label).collect() + } + + #[cfg(feature = "ilp-solver")] + pub(crate) fn solve( + &self, + source: &dyn Any, + solver: &super::ILPSolver, + ) -> Result, super::ILPSolveError> { + if self.reducers.is_empty() { + return solver.solve_dyn(source); + } + + let mut reductions: Vec> = Vec::new(); + for reducer in &self.reducers { + let input = reductions + .last() + .map(|step| step.target_problem_any()) + .unwrap_or(source); + reductions.push(reducer(input)); + } + + let target = reductions + .last() + .expect("non-empty fixed pipeline must produce a target") + .target_problem_any(); + let solution = solver.solve_dyn(target)?; + Ok(reductions.iter().rev().fold(solution, |current, step| { + step.extract_solution_dyn(¤t) + })) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct RegisteredSolverCapabilities<'a> { + pub(crate) native: Option<&'static NativeSolverRegistration>, + pub(crate) ilp: Option<&'a CompiledIlpPipeline>, +} + +impl std::fmt::Debug for RegisteredSolverCapabilities<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SolverCapabilities") + .field("native", &self.native.map(|entry| entry.implementation)) + .field("ilp", &self.ilp.map(CompiledIlpPipeline::path)) + .finish() + } +} + +/// Read-only metadata for a registered native solver. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct NativeSolverCapability { + pub implementation: &'static str, +} + +/// Read-only metadata for a registered fixed ILP pipeline. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct IlpSolverCapability { + path: Vec, +} + +impl IlpSolverCapability { + pub fn path(&self) -> &[ExactProblemKey] { + &self.path + } + + pub fn path_labels(&self) -> Vec { + self.path.iter().map(ExactProblemKey::label).collect() + } +} + +/// Read-only solver capabilities for one exact problem variant. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SolverCapabilities { + pub native: Option, + pub ilp: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct SolverCapabilityRegistry { + native: BTreeMap, + ilp: BTreeMap, +} + +impl SolverCapabilityRegistry { + pub(crate) fn lookup(&self, key: &ExactProblemKey) -> RegisteredSolverCapabilities<'_> { + RegisteredSolverCapabilities { + native: self.native.get(key).copied(), + ilp: self.ilp.get(key), + } + } + + #[cfg(test)] + pub(crate) fn native_entries( + &self, + ) -> impl Iterator + '_ { + self.native.iter().map(|(key, entry)| (key, *entry)) + } + + #[cfg(test)] + pub(crate) fn ilp_entries( + &self, + ) -> impl Iterator { + self.ilp.iter() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RegistryBuildError { + #[error("solver registration references unknown exact variant {0}")] + UnknownVariant(String), + #[error("duplicate native solver registration for {0}")] + DuplicateNative(String), + #[error("duplicate ILP pipeline registration for {0}")] + DuplicateIlp(String), + #[error("ILP pipeline must contain at least one node")] + EmptyPipeline, + #[error("ILP pipeline for {0} does not end at ILP or ILP")] + UnsupportedTarget(String), + #[error("ILP pipeline for {0} continues after reaching a supported ILP node")] + ContinuesAfterIlp(String), + #[error("ILP pipeline edge {source_label} -> {target_label} resolves to {matches} witness reductions")] + InvalidEdge { + source_label: String, + target_label: String, + matches: usize, + }, +} + +fn registered_variant_keys() -> BTreeSet { + inventory::iter::() + .map(|entry| ExactProblemKey::new(entry.name, entry.variant_map())) + .collect() +} + +fn edge_key(entry: &ReductionEntry, source: bool) -> ExactProblemKey { + let (name, variant) = if source { + (entry.source_name, entry.source_variant()) + } else { + (entry.target_name, entry.target_variant()) + }; + ExactProblemKey::new( + name, + variant + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) +} + +fn build_registry( + variants: &BTreeSet, + native_entries: impl IntoIterator, + pipeline_entries: impl IntoIterator, + reductions: &[&'static ReductionEntry], +) -> Result { + let mut registry = SolverCapabilityRegistry::default(); + let mut reduction_index = + BTreeMap::<(ExactProblemKey, ExactProblemKey), Vec<&'static ReductionEntry>>::new(); + for entry in reductions + .iter() + .copied() + .filter(|entry| entry.capabilities.witness && entry.reduce_fn.is_some()) + { + reduction_index + .entry((edge_key(entry, true), edge_key(entry, false))) + .or_default() + .push(entry); + } + + for native in native_entries { + let source = native.source_key(); + if !variants.contains(&source) { + return Err(RegistryBuildError::UnknownVariant(source.label())); + } + if registry.native.insert(source.clone(), native).is_some() { + return Err(RegistryBuildError::DuplicateNative(source.label())); + } + } + + for registration in pipeline_entries { + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let source = path + .first() + .cloned() + .ok_or(RegistryBuildError::EmptyPipeline)?; + + for step in &path { + if !variants.contains(step) { + return Err(RegistryBuildError::UnknownVariant(step.label())); + } + } + if !path.last().is_some_and(ExactProblemKey::is_supported_ilp) { + return Err(RegistryBuildError::UnsupportedTarget(source.label())); + } + if path[..path.len() - 1] + .iter() + .any(ExactProblemKey::is_supported_ilp) + { + return Err(RegistryBuildError::ContinuesAfterIlp(source.label())); + } + + let mut reducers = Vec::with_capacity(path.len().saturating_sub(1)); + for pair in path.windows(2) { + let matches = reduction_index + .get(&(pair[0].clone(), pair[1].clone())) + .map(Vec::as_slice) + .unwrap_or_default(); + if matches.len() != 1 { + return Err(RegistryBuildError::InvalidEdge { + source_label: pair[0].label(), + target_label: pair[1].label(), + matches: matches.len(), + }); + } + reducers.push( + matches[0] + .reduce_fn + .expect("indexed only entries with reduce_fn"), + ); + } + + if registry + .ilp + .insert(source.clone(), CompiledIlpPipeline { path, reducers }) + .is_some() + { + return Err(RegistryBuildError::DuplicateIlp(source.label())); + } + } + + Ok(registry) +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +pub(crate) fn solver_capability_registry( +) -> Result<&'static SolverCapabilityRegistry, &'static RegistryBuildError> { + REGISTRY + .get_or_init(|| { + build_registry( + ®istered_variant_keys(), + inventory::iter::(), + inventory::iter::(), + &reduction_entries(), + ) + }) + .as_ref() +} + +/// Return read-only solver metadata for one exact problem variant. +pub fn solver_capabilities( + key: &ExactProblemKey, +) -> Result { + let registered = solver_capability_registry()?.lookup(key); + Ok(SolverCapabilities { + native: registered.native.map(|entry| NativeSolverCapability { + implementation: entry.implementation, + }), + ilp: registered.ilp.map(|pipeline| IlpSolverCapability { + path: pipeline.path.clone(), + }), + }) +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/registry.rs"] +mod tests; diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs new file mode 100644 index 000000000..340d9b6e9 --- /dev/null +++ b/src/solvers/resolver.rs @@ -0,0 +1,165 @@ +//! Shared deterministic solver dispatch. + +#[cfg(feature = "ilp-solver")] +use super::registry::CompiledIlpPipeline; +use super::registry::{ + solver_capability_registry, ExactProblemKey, NativeSolverRegistration, RegistryBuildError, +}; +use crate::registry::LoadedDynProblem; +use serde::Serialize; + +/// Public solver override. Omission is represented by [`SolverRequest::Default`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SolverRequest { + #[default] + Default, + Ilp, + BruteForce, +} + +/// Information about the backend execution that produced a solve result. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum SolverExecution { + Native { implementation: &'static str }, + Ilp { reduction_path: Vec }, + BruteForce, +} + +/// Type-erased result returned by deterministic solver dispatch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeterministicSolveResult { + pub solver: SolverExecution, + pub config: Option>, + pub evaluation: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum DeterministicSolveError { + #[error("solver capability registry is invalid: {0}")] + InvalidRegistry(&'static RegistryBuildError), + #[error("No ILP pipeline is registered for {0}")] + MissingIlpCapability(String), + #[error("native solver found no solution for {problem}")] + NativeNoSolution { problem: String }, + #[cfg(feature = "ilp-solver")] + #[error("ILP solver failed for {problem}: {source}")] + IlpSolve { + problem: String, + #[source] + source: super::ILPSolveError, + }, +} + +fn problem_key(problem: &LoadedDynProblem) -> ExactProblemKey { + ExactProblemKey::new(problem.problem_name(), problem.variant_map()) +} + +fn solve_native( + problem: &LoadedDynProblem, + registration: &'static NativeSolverRegistration, +) -> Result { + let config = (registration.solve_fn)(problem.as_any()).ok_or_else(|| { + DeterministicSolveError::NativeNoSolution { + problem: problem_key(problem).label(), + } + })?; + let evaluation = problem.evaluate_dyn(&config); + Ok(DeterministicSolveResult { + solver: SolverExecution::Native { + implementation: registration.implementation, + }, + config: Some(config), + evaluation, + }) +} + +#[cfg(feature = "ilp-solver")] +fn solve_ilp( + problem: &LoadedDynProblem, + pipeline: &CompiledIlpPipeline, +) -> Result { + let config = pipeline + .solve(problem.as_any(), &super::ILPSolver::new()) + .map_err(|source| DeterministicSolveError::IlpSolve { + problem: problem_key(problem).label(), + source, + })?; + let evaluation = problem.evaluate_dyn(&config); + Ok(DeterministicSolveResult { + solver: SolverExecution::Ilp { + reduction_path: pipeline.path_labels(), + }, + config: Some(config), + evaluation, + }) +} + +fn solve_brute_force(problem: &LoadedDynProblem) -> DeterministicSolveResult { + match problem.solve_brute_force_witness() { + Some((config, evaluation)) => DeterministicSolveResult { + solver: SolverExecution::BruteForce, + config: Some(config), + evaluation, + }, + None => DeterministicSolveResult { + solver: SolverExecution::BruteForce, + config: None, + evaluation: problem.solve_brute_force_value(), + }, + } +} + +/// Solve a loaded problem using deterministic exact-variant dispatch. +/// +/// Default dispatch is native, then the registered fixed ILP pipeline, then +/// brute force. Once selected, backend failure is returned without fallback. +pub fn solve_deterministically( + problem: &LoadedDynProblem, + request: SolverRequest, +) -> Result { + if request == SolverRequest::BruteForce { + return Ok(solve_brute_force(problem)); + } + + let registry = + solver_capability_registry().map_err(DeterministicSolveError::InvalidRegistry)?; + let key = problem_key(problem); + let capabilities = registry.lookup(&key); + + match request { + SolverRequest::BruteForce => unreachable!("handled before registry initialization"), + SolverRequest::Ilp => { + let pipeline = capabilities + .ilp + .ok_or_else(|| DeterministicSolveError::MissingIlpCapability(key.label()))?; + #[cfg(feature = "ilp-solver")] + { + solve_ilp(problem, pipeline) + } + #[cfg(not(feature = "ilp-solver"))] + { + let _ = pipeline; + Err(DeterministicSolveError::MissingIlpCapability(key.label())) + } + } + SolverRequest::Default => { + if let Some(native) = capabilities.native { + return solve_native(problem, native); + } + if let Some(pipeline) = capabilities.ilp { + #[cfg(feature = "ilp-solver")] + { + return solve_ilp(problem, pipeline); + } + #[cfg(not(feature = "ilp-solver"))] + let _ = pipeline; + } + Ok(solve_brute_force(problem)) + } + } +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/resolver.rs"] +mod tests; diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 43dc121f4..57d696277 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -502,10 +502,8 @@ fn model_specs_are_self_consistent() { #[cfg(feature = "ilp-solver")] #[test] fn model_specs_are_optimal() { - use crate::registry::find_variant_entry; - use crate::solvers::ILPSolver; - - let ilp_solver = ILPSolver::new(); + use crate::registry::{find_variant_entry, load_dyn}; + use crate::solvers::{solve_deterministically, SolverRequest}; let specs = crate::models::graph::canonical_model_example_specs() .into_iter() @@ -520,13 +518,19 @@ fn model_specs_are_optimal() { // Try brute force first for small instances (fast, avoids expensive ILP chains) let dims = spec.instance.dims_dyn(); let log_space: f64 = dims.iter().map(|&d| (d as f64).log2()).sum(); + let solve_registered_ilp = || { + let loaded = load_dyn(name, &variant, spec.instance.serialize_json()).ok()?; + solve_deterministically(&loaded, SolverRequest::Ilp) + .ok()? + .config + }; let best_config = if log_space <= 20.0 { find_variant_entry(name, &variant) .and_then(|entry| (entry.solve_witness_fn)(spec.instance.as_any())) .map(|(config, _)| config) - .or_else(|| ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any())) + .or_else(solve_registered_ilp) } else { - ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any()) + solve_registered_ilp() }; if let Some(best_config) = best_config { diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index 45184be81..2f540040e 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -1,8 +1,6 @@ use crate::models::misc::TimetableDesign; use crate::solvers::BruteForce; use crate::traits::Problem; -#[cfg(feature = "ilp-solver")] -use std::collections::BTreeMap; fn timetable_design_flat_index( num_tasks: usize, @@ -130,20 +128,18 @@ fn test_timetable_design_bruteforce_solver_finds_solution() { assert!(problem.evaluate(&solution.unwrap())); } -#[cfg(feature = "ilp-solver")] #[test] -fn test_timetable_design_issue_example_is_solved_via_ilp_solver_dispatch() { +fn test_timetable_design_issue_example_is_solved_via_native_backend() { let problem = super::issue_example_problem(); - let solution = crate::solvers::ILPSolver::new() - .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) - .expect("expected ILP solver dispatch to find a satisfying timetable"); + let solution = problem + .solve_via_required_assignments() + .expect("expected native backend to find a satisfying timetable"); assert!(problem.evaluate(&solution)); } -#[cfg(feature = "ilp-solver")] #[test] -fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { +fn test_timetable_design_unsat_instance_returns_none_via_native_backend() { let problem = TimetableDesign::new( 1, 2, @@ -153,9 +149,7 @@ fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { vec![vec![1], vec![1]], ); - assert!(crate::solvers::ILPSolver::new() - .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) - .is_none()); + assert!(problem.solve_via_required_assignments().is_none()); } #[test] diff --git a/src/unit_tests/rules/acyclicpartition_ilp.rs b/src/unit_tests/rules/acyclicpartition_ilp.rs index 97efc979f..de050bc5f 100644 --- a/src/unit_tests/rules/acyclicpartition_ilp.rs +++ b/src/unit_tests/rules/acyclicpartition_ilp.rs @@ -76,7 +76,7 @@ fn test_infeasible_instance() { let reduction: ReductionAcyclicPartitionToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs index 9c4cc1109..ffb36daf3 100644 --- a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -46,7 +46,7 @@ fn test_infeasible_instance() { let reduction: ReductionBCBSToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = crate::solvers::ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/binpacking_ilp.rs b/src/unit_tests/rules/binpacking_ilp.rs index 772eb4601..0573c82d9 100644 --- a/src/unit_tests/rules/binpacking_ilp.rs +++ b/src/unit_tests/rules/binpacking_ilp.rs @@ -135,7 +135,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs index 70452a9e1..03aee897a 100644 --- a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs @@ -92,7 +92,7 @@ fn test_no_hamiltonian_cycle_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Path graph should have no Hamiltonian cycle" ); } diff --git a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs index bd97a4a96..19f94f6bf 100644 --- a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs @@ -81,7 +81,7 @@ fn test_infeasible_instance() { let reduction: ReductionBCSFToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/clustering_ilp.rs b/src/unit_tests/rules/clustering_ilp.rs index a35273e37..2da5f263f 100644 --- a/src/unit_tests/rules/clustering_ilp.rs +++ b/src/unit_tests/rules/clustering_ilp.rs @@ -74,5 +74,5 @@ fn test_clustering_to_ilp_infeasible_instance_is_infeasible() { let problem = infeasible_instance(); let reduction: ReductionClusteringToILP = ReduceTo::>::reduce_to(&problem); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_none()); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); } diff --git a/src/unit_tests/rules/coloring_ilp.rs b/src/unit_tests/rules/coloring_ilp.rs index f436f39b5..41eab4e0a 100644 --- a/src/unit_tests/rules/coloring_ilp.rs +++ b/src/unit_tests/rules/coloring_ilp.rs @@ -113,7 +113,7 @@ fn test_ilp_infeasible_triangle_2_colors() { // ILP should be infeasible let result = ilp_solver.solve(ilp); assert!( - result.is_none(), + result.is_err(), "Triangle with 2 colors should be infeasible" ); } @@ -202,7 +202,7 @@ fn test_complete_graph_k4_with_3_colors_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_none(), "K4 with 3 colors should be infeasible"); + assert!(result.is_err(), "K4 with 3 colors should be infeasible"); } #[test] @@ -234,7 +234,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution)); diff --git a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs index 1cb1d59ad..157a03fa6 100644 --- a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -65,7 +65,7 @@ fn test_cdft_to_ilp_unsat_instance_is_infeasible() { let problem = small_no_instance(); let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem); let solver = ILPSolver::new(); - assert!(solver.solve(reduction.target_problem()).is_none()); + assert!(solver.solve(reduction.target_problem()).is_err()); } #[test] @@ -73,7 +73,7 @@ fn test_cdft_to_ilp_solve_reduced() { let problem = small_yes_instance(); let solver = ILPSolver::new(); let solution = solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should find a satisfying assignment"); assert!(problem.evaluate(&solution)); } diff --git a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs index e13a85326..ac027fb8c 100644 --- a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs @@ -89,7 +89,7 @@ fn test_directedhamiltonianpath_to_ilp_no_path() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Graph with no Hamiltonian path should be infeasible" ); } diff --git a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs index 42f09bd3a..ec3d8e3eb 100644 --- a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs @@ -94,7 +94,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible flow instance should produce infeasible ILP" ); } @@ -106,7 +106,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_disallows_using_other_commodity_ let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "commodity 1 must conserve flow at commodity 2's source in the ILP reduction" ); } diff --git a/src/unit_tests/rules/eulerianpath_ilp.rs b/src/unit_tests/rules/eulerianpath_ilp.rs index ce690f067..1c8b3fd57 100644 --- a/src/unit_tests/rules/eulerianpath_ilp.rs +++ b/src/unit_tests/rules/eulerianpath_ilp.rs @@ -88,7 +88,7 @@ fn test_eulerianpath_to_ilp_infeasible_no_instance() { // The ILP must report infeasibility for a NO instance. let solution = ILPSolver::new().solve(reduction.target_problem()); assert!( - solution.is_none(), + solution.is_err(), "ILP must be infeasible for a degree-unbalanced NO instance, got {:?}", solution ); diff --git a/src/unit_tests/rules/factoring_ilp.rs b/src/unit_tests/rules/factoring_ilp.rs index 85bc86003..f33a717ec 100644 --- a/src/unit_tests/rules/factoring_ilp.rs +++ b/src/unit_tests/rules/factoring_ilp.rs @@ -161,7 +161,7 @@ fn test_infeasible_target_too_large() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_none(), "Should be infeasible"); + assert!(result.is_err(), "Should be infeasible"); } #[test] diff --git a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs index b4c0c36ef..db3a43c5e 100644 --- a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs +++ b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs @@ -41,7 +41,7 @@ fn test_feasible_register_assignment_to_ilp_infeasible() { let reduction = ReduceTo::>::reduce_to(&source); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "register-conflict source instance should reduce to an infeasible ILP" ); } diff --git a/src/unit_tests/rules/flowshopscheduling_ilp.rs b/src/unit_tests/rules/flowshopscheduling_ilp.rs index 23195381c..15dd3b795 100644 --- a/src/unit_tests/rules/flowshopscheduling_ilp.rs +++ b/src/unit_tests/rules/flowshopscheduling_ilp.rs @@ -33,7 +33,7 @@ fn test_flowshopscheduling_to_ilp_infeasible() { let problem = FlowShopScheduling::new(2, vec![vec![5, 5], vec![5, 5], vec![5, 5]], 6); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible FSS should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/graphpartitioning_ilp.rs b/src/unit_tests/rules/graphpartitioning_ilp.rs index bb0ec4e4e..cf27d091d 100644 --- a/src/unit_tests/rules/graphpartitioning_ilp.rs +++ b/src/unit_tests/rules/graphpartitioning_ilp.rs @@ -104,7 +104,10 @@ fn test_odd_vertices_reduce_to_infeasible_ilp() { assert_eq!(ilp.constraints[0].rhs, 1.5); let solver = ILPSolver::new(); - assert_eq!(solver.solve(ilp), None); + assert_eq!( + solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -125,7 +128,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert_eq!(problem.evaluate(&solution), Min(Some(3))); diff --git a/src/unit_tests/rules/hamiltonianpath_ilp.rs b/src/unit_tests/rules/hamiltonianpath_ilp.rs index ce36c3422..03fd75d18 100644 --- a/src/unit_tests/rules/hamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/hamiltonianpath_ilp.rs @@ -77,7 +77,7 @@ fn test_hamiltonianpath_to_ilp_no_path() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Disconnected graph should have no Hamiltonian path" ); } diff --git a/src/unit_tests/rules/integralflowbundles_ilp.rs b/src/unit_tests/rules/integralflowbundles_ilp.rs index 6a3268ebf..12dde8a1a 100644 --- a/src/unit_tests/rules/integralflowbundles_ilp.rs +++ b/src/unit_tests/rules/integralflowbundles_ilp.rs @@ -94,7 +94,7 @@ fn test_integral_flow_bundles_to_ilp_extract_solution_is_identity() { fn test_integral_flow_bundles_to_ilp_unsat_instance_is_infeasible() { let problem = no_instance(); let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_none()); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); } #[test] diff --git a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs index 6ed29abea..a03765d13 100644 --- a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -47,7 +47,7 @@ fn solve_target_via_ilp( problem: &crate::models::graph::DirectedTwoCommodityIntegralFlow, ) -> Option> { let reduction = ReduceTo::>::reduce_to(problem); - let ilp_solution = ILPSolver::new().solve(reduction.target_problem())?; + let ilp_solution = ILPSolver::new().solve(reduction.target_problem()).ok()?; let extracted = reduction.extract_solution(&ilp_solution); problem.evaluate(&extracted).0.then_some(extracted) } diff --git a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs index 457ef61de..d792c61e0 100644 --- a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs @@ -102,9 +102,7 @@ fn test_ksatisfiability_to_feasible_register_assignment_unsatisfiable_instance() let fra_to_ilp = ReduceTo::>::reduce_to(reduction.target_problem()); assert!( - ILPSolver::new() - .solve(fra_to_ilp.target_problem()) - .is_none(), + ILPSolver::new().solve(fra_to_ilp.target_problem()).is_err(), "an unsatisfiable source formula should yield an infeasible FRA instance" ); } diff --git a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs index 7f92fcbaa..b27b46739 100644 --- a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs @@ -34,7 +34,7 @@ fn solve_threshold_schedule_via_ilp( target.precedences().to_vec(), ); let pcs_to_ilp = ReduceTo::>::reduce_to(&pcs); - let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem())?; + let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem()).ok()?; let slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution); let mut config = vec![0usize; target.num_tasks() * target.d_max()]; diff --git a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs index 76363ba44..83fb3e168 100644 --- a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs +++ b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs @@ -78,7 +78,7 @@ fn test_ksatisfiability_to_timetabledesign_closed_loop() { let reduction = ReduceTo::::reduce_to(&source); let target_solution = ILPSolver::new() - .solve_reduced(reduction.target_problem()) + .solve_reduced::(reduction.target_problem()) .expect("satisfiable source instance should produce a feasible timetable"); assert!(reduction.target_problem().evaluate(&target_solution).0); @@ -95,8 +95,8 @@ fn test_ksatisfiability_to_timetabledesign_unsatisfiable() { assert!( ILPSolver::new() - .solve_reduced(reduction.target_problem()) - .is_none(), + .solve_reduced::(reduction.target_problem()) + .is_err(), "unsatisfiable 3SAT instance should produce an infeasible timetable" ); } diff --git a/src/unit_tests/rules/maximummatching_ilp.rs b/src/unit_tests/rules/maximummatching_ilp.rs index 02c9c6061..4ca49d5b0 100644 --- a/src/unit_tests/rules/maximummatching_ilp.rs +++ b/src/unit_tests/rules/maximummatching_ilp.rs @@ -242,7 +242,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index 54daaed04..bffe90ca4 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -121,7 +121,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimumedgecostflow_ilp.rs b/src/unit_tests/rules/minimumedgecostflow_ilp.rs index 080748317..03d7ad096 100644 --- a/src/unit_tests/rules/minimumedgecostflow_ilp.rs +++ b/src/unit_tests/rules/minimumedgecostflow_ilp.rs @@ -104,7 +104,7 @@ fn test_minimumedgecostflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMECFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs index a831575ce..f5f3d06a8 100644 --- a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs +++ b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs @@ -80,7 +80,7 @@ fn test_reduction_is_infeasible_when_an_internal_vertex_has_no_covering_pair() { assert_eq!(problem.evaluate(&[0]), Min(None)); assert_eq!(problem.evaluate(&[1]), Min(None)); - assert!(ILPSolver::new().solve(ilp).is_none()); + assert!(ILPSolver::new().solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/minimummultiwaycut_ilp.rs b/src/unit_tests/rules/minimummultiwaycut_ilp.rs index 99260a2ab..b5a6e5046 100644 --- a/src/unit_tests/rules/minimummultiwaycut_ilp.rs +++ b/src/unit_tests/rules/minimummultiwaycut_ilp.rs @@ -131,7 +131,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimumsetcovering_ilp.rs b/src/unit_tests/rules/minimumsetcovering_ilp.rs index cd16428a2..4e154c1a6 100644 --- a/src/unit_tests/rules/minimumsetcovering_ilp.rs +++ b/src/unit_tests/rules/minimumsetcovering_ilp.rs @@ -184,7 +184,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimumweightdecoding_ilp.rs b/src/unit_tests/rules/minimumweightdecoding_ilp.rs index 5f589bf46..3f5dd0c8e 100644 --- a/src/unit_tests/rules/minimumweightdecoding_ilp.rs +++ b/src/unit_tests/rules/minimumweightdecoding_ilp.rs @@ -91,7 +91,7 @@ fn test_minimumweightdecoding_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMinimumWeightDecodingToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/monochromatictriangle_ilp.rs b/src/unit_tests/rules/monochromatictriangle_ilp.rs index f55c96ee4..7e7cc9119 100644 --- a/src/unit_tests/rules/monochromatictriangle_ilp.rs +++ b/src/unit_tests/rules/monochromatictriangle_ilp.rs @@ -64,7 +64,7 @@ fn test_monochromatic_triangle_to_ilp_infeasible_k6() { let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "K6 should be infeasible by R(3,3)=6" ); } diff --git a/src/unit_tests/rules/naesatisfiability_ilp.rs b/src/unit_tests/rules/naesatisfiability_ilp.rs index bd2efef04..d4e7504ae 100644 --- a/src/unit_tests/rules/naesatisfiability_ilp.rs +++ b/src/unit_tests/rules/naesatisfiability_ilp.rs @@ -74,7 +74,7 @@ fn test_naesatisfiability_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); // The ILP should be infeasible: x1 ≥ 1 (at least one true) AND x1 ≤ 0 (at least one false) assert!( - ilp_solver.solve(ilp).is_none(), + ilp_solver.solve(ilp).is_err(), "ILP should be infeasible for unsatisfiable NAE-SAT" ); } diff --git a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs index b6a1dd3ee..f0318d543 100644 --- a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs @@ -60,7 +60,7 @@ fn test_numericalmatchingwithtargetsums_to_ilp_unsatisfiable() { let reduction = ReduceTo::>::reduce_to(&problem); let result = ILPSolver::new().solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Unsatisfiable instance should have no ILP solution" ); } diff --git a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs index 4f4e3a363..b921910b7 100644 --- a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs @@ -57,7 +57,7 @@ fn test_precedenceconstrainedscheduling_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionPCSToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible scheduling instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/preemptivescheduling_ilp.rs b/src/unit_tests/rules/preemptivescheduling_ilp.rs index 95a0258a5..210b8aa3b 100644 --- a/src/unit_tests/rules/preemptivescheduling_ilp.rs +++ b/src/unit_tests/rules/preemptivescheduling_ilp.rs @@ -55,6 +55,16 @@ fn test_preemptivescheduling_to_ilp_closed_loop() { ); } +#[test] +fn test_solve_reduced_supports_direct_ilp_i32_reductions() { + let problem = small_instance(); + let solution = ILPSolver::new() + .solve_reduced::(&problem) + .expect("direct ILP reduction should be solvable"); + + assert!(problem.evaluate(&solution).0.is_some()); +} + #[test] fn test_preemptivescheduling_to_ilp_medium_closed_loop() { let p = medium_instance(); @@ -87,7 +97,7 @@ fn test_preemptivescheduling_to_ilp_infeasible() { let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); let sol = ILPSolver::new().solve(reduction.target_problem()); // 1 processor, t0 at slot 0, t1 at slot 1 → always feasible - assert!(sol.is_some(), "should be feasible"); + assert!(sol.is_ok(), "should be feasible"); } // ─── extract_solution ────────────────────────────────────────────────────── diff --git a/src/unit_tests/rules/registersufficiency_ilp.rs b/src/unit_tests/rules/registersufficiency_ilp.rs index 8b5e9ec77..504f86727 100644 --- a/src/unit_tests/rules/registersufficiency_ilp.rs +++ b/src/unit_tests/rules/registersufficiency_ilp.rs @@ -64,7 +64,7 @@ fn test_register_sufficiency_to_ilp_infeasible() { let reduction = ReduceTo::>::reduce_to(&source); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "register-sufficiency instance with bound one should be infeasible" ); } diff --git a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs index f29497710..8acd6b484 100644 --- a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs @@ -52,7 +52,7 @@ fn test_resourceconstrainedscheduling_to_ilp_infeasible() { ResourceConstrainedScheduling::new(1, vec![5], vec![vec![6], vec![6], vec![6]], 1); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible RCS should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs index d21d180aa..93a6f1958 100644 --- a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs +++ b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs @@ -34,13 +34,13 @@ fn test_rootedtreestorageassignment_to_ilp_bf_vs_ilp() { let ilp_result = ilp_solver.solve(reduction.target_problem()); match ilp_result { - Some(ilp_solution) => { + Ok(ilp_solution) => { let extracted = reduction.extract_solution(&ilp_solution); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.0, "ILP solution should be feasible"); assert!(bf_value.0, "BF should also find feasible solution"); } - None => { + Err(_) => { assert!(!bf_value.0, "both should agree on infeasibility"); } } @@ -63,10 +63,7 @@ fn test_rootedtreestorageassignment_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); let ilp_result = ilp_solver.solve(reduction.target_problem()); assert!(bf_witness.is_none(), "source should be infeasible"); - assert!( - ilp_result.is_none(), - "reduced ILP should also be infeasible" - ); + assert!(ilp_result.is_err(), "reduced ILP should also be infeasible"); } #[test] diff --git a/src/unit_tests/rules/sat_coloring.rs b/src/unit_tests/rules/sat_coloring.rs index 929bb7651..a193f02bb 100644 --- a/src/unit_tests/rules/sat_coloring.rs +++ b/src/unit_tests/rules/sat_coloring.rs @@ -321,7 +321,7 @@ fn test_jl_parity_sat_to_coloring() { let ilp_solver = crate::solvers::ILPSolver::new(); let target = result.target_problem(); let target_sol = ilp_solver - .solve_reduced(target) + .solve_reduced::(target) .expect("ILP should find a coloring"); let extracted = result.extract_solution(&target_sol); let best_source: HashSet> = BruteForce::new() diff --git a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs index 569da137e..72c20ef12 100644 --- a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs @@ -57,7 +57,7 @@ fn test_schedulingwithindividualdeadlines_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIDToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should yield infeasible ILP" ); } diff --git a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index 5f599cb07..1bd5baa1b 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -98,7 +98,7 @@ fn test_cyclic_precedence_instance_is_infeasible() { let ilp = reduction.target_problem(); assert!( - ILPSolver::new().solve(ilp).is_none(), + ILPSolver::new().solve(ilp).is_err(), "cyclic precedences should make the ILP infeasible" ); } diff --git a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs index 1d68ac783..f09f97d6b 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -43,7 +43,7 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_infeasible() { SequencingToMinimizeWeightedTardiness::new(vec![10, 10], vec![1, 1], vec![1, 1], 0); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible STMWT should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 23f97a1a8..8e6541bea 100644 --- a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -53,7 +53,7 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_infeasible() { SequencingWithDeadlinesAndSetUpTimes::new(vec![2, 2], vec![1, 1], vec![0, 0], vec![0]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } @@ -90,13 +90,13 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_bf_vs_ilp_small() { let reduction = ReduceTo::>::reduce_to(&problem); let ilp_result = ILPSolver::new().solve(reduction.target_problem()); - let ilp_feasible = ilp_result.is_some(); + let ilp_feasible = ilp_result.is_ok(); assert_eq!( bf_feasible, ilp_feasible, "BF and ILP should agree on feasibility" ); - if let Some(ilp_solution) = ilp_result { + if let Ok(ilp_solution) = ilp_result { let extracted = reduction.extract_solution(&ilp_solution); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs index fa04ef222..32c0b2082 100644 --- a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs +++ b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs @@ -69,7 +69,7 @@ fn test_sequencingwithinintervals_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance (forced overlap) should yield infeasible ILP" ); } diff --git a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 4ed4daca9..6da8a68e4 100644 --- a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -42,7 +42,7 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_infeasible() { let problem = SequencingWithReleaseTimesAndDeadlines::new(vec![2, 2], vec![0, 0], vec![2, 2]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible SWRTD should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/setsplitting_ilp.rs b/src/unit_tests/rules/setsplitting_ilp.rs index b15762c82..d30286c0e 100644 --- a/src/unit_tests/rules/setsplitting_ilp.rs +++ b/src/unit_tests/rules/setsplitting_ilp.rs @@ -64,7 +64,7 @@ fn test_setsplitting_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); assert!( - ilp_solver.solve(ilp).is_none(), + ilp_solver.solve(ilp).is_err(), "ILP should be infeasible for unsplittable instance" ); } diff --git a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs index 6584fb275..26343fc58 100644 --- a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs @@ -52,13 +52,13 @@ fn test_shortestweightconstrainedpath_to_ilp_bf_vs_ilp() { let ilp_result = ilp_solver.solve(reduction.target_problem()); match ilp_result { - Some(ilp_solution) => { + Ok(ilp_solution) => { let extracted = reduction.extract_solution(&ilp_solution); let ilp_value = problem.evaluate(&extracted); // Both should agree on the optimal length assert_eq!(ilp_value, bf_value); } - None => { + Err(_) => { // ILP found no feasible solution; brute force should agree assert_eq!(bf_value, Min(None)); } diff --git a/src/unit_tests/rules/steinertree_ilp.rs b/src/unit_tests/rules/steinertree_ilp.rs index 7925f7ed4..4f3dbfb98 100644 --- a/src/unit_tests/rules/steinertree_ilp.rs +++ b/src/unit_tests/rules/steinertree_ilp.rs @@ -75,7 +75,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { fn test_solve_reduced_uses_new_rule() { let problem = canonical_instance(); let solution = ILPSolver::new() - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should find the Steiner tree via ILP"); assert_eq!(problem.evaluate(&solution), Min(Some(6))); } diff --git a/src/unit_tests/rules/stringtostringcorrection_ilp.rs b/src/unit_tests/rules/stringtostringcorrection_ilp.rs index 3b1983b03..d9d6dbea6 100644 --- a/src/unit_tests/rules/stringtostringcorrection_ilp.rs +++ b/src/unit_tests/rules/stringtostringcorrection_ilp.rs @@ -62,7 +62,7 @@ fn test_stringtostringcorrection_to_ilp_infeasible() { let reduction: ReductionSTSCToILP = ReduceTo::>::reduce_to(&problem); let ilp_solver = ILPSolver::new(); assert!( - ilp_solver.solve(reduction.target_problem()).is_none(), + ilp_solver.solve(reduction.target_problem()).is_err(), "reduced ILP should also be infeasible" ); } diff --git a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs index 924fcc0ec..8e963a52a 100644 --- a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs @@ -90,7 +90,7 @@ fn test_infeasible_budget() { let reduction: ReductionSCAToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/subgraphisomorphism_ilp.rs b/src/unit_tests/rules/subgraphisomorphism_ilp.rs index a662292f0..026c5e79a 100644 --- a/src/unit_tests/rules/subgraphisomorphism_ilp.rs +++ b/src/unit_tests/rules/subgraphisomorphism_ilp.rs @@ -78,7 +78,7 @@ fn test_subgraphisomorphism_to_ilp_infeasible() { let reduction: ReductionSubIsoToILP = ReduceTo::>::reduce_to(&problem); let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); - assert!(result.is_none(), "K3 in path should be infeasible"); + assert!(result.is_err(), "K3 in path should be infeasible"); } #[test] diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index 0873a4b65..6ae678b8c 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -115,7 +115,7 @@ fn test_threedimensionalmatching_to_ilp_infeasible_instance() { "source instance should be infeasible" ); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "reduced ILP should be infeasible" ); } @@ -138,7 +138,7 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { assert_eq!(problem.evaluate(&direct_source), Or(true)); assert!( - solver.solve(indirect.target_problem()).is_some(), + solver.solve(indirect.target_problem()).is_ok(), "indirect ILP should agree on feasibility" ); assert!(direct.target_problem().num_vars < indirect.target_problem().num_vars); diff --git a/src/unit_tests/rules/timetabledesign_ilp.rs b/src/unit_tests/rules/timetabledesign_ilp.rs index f4cdd9522..556bcee1e 100644 --- a/src/unit_tests/rules/timetabledesign_ilp.rs +++ b/src/unit_tests/rules/timetabledesign_ilp.rs @@ -55,7 +55,7 @@ fn test_timetabledesign_to_ilp_infeasible() { let problem = TimetableDesign::new(1, 1, 1, vec![vec![true]], vec![vec![true]], vec![vec![2]]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible TD should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/travelingsalesman_ilp.rs b/src/unit_tests/rules/travelingsalesman_ilp.rs index cb0040030..03c472daf 100644 --- a/src/unit_tests/rules/travelingsalesman_ilp.rs +++ b/src/unit_tests/rules/travelingsalesman_ilp.rs @@ -104,7 +104,7 @@ fn test_no_hamiltonian_cycle_infeasible() { let result = ilp_solver.solve(ilp); assert!( - result.is_none(), + result.is_err(), "Path graph should have no Hamiltonian cycle (infeasible ILP)" ); } @@ -136,7 +136,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); let metric = problem.evaluate(&solution); diff --git a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs index 2969919dd..a8391993c 100644 --- a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs @@ -73,7 +73,7 @@ fn test_undirectedflowlowerbounds_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionUFLBToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs index 398f8c485..f6f91eec9 100644 --- a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -99,7 +99,7 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionU2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible flow instance should yield infeasible ILP" ); } diff --git a/src/unit_tests/solvers/brute_force.rs b/src/unit_tests/solvers/brute_force.rs index 75d31d717..725f494d7 100644 --- a/src/unit_tests/solvers/brute_force.rs +++ b/src/unit_tests/solvers/brute_force.rs @@ -2,6 +2,8 @@ use super::*; use crate::solvers::Solver; use crate::traits::Problem; use crate::types::{Max, Min, Or, Sum}; +use std::cell::Cell; +use std::rc::Rc; #[derive(Clone)] struct MaxSumProblem { @@ -87,6 +89,29 @@ struct SumProblem { weights: Vec, } +#[derive(Clone)] +struct CountingSatProblem { + evaluations: Rc>, +} + +impl Problem for CountingSatProblem { + const NAME: &'static str = "CountingSatProblem"; + type Value = Or; + + fn dims(&self) -> Vec { + vec![2, 2] + } + + fn evaluate(&self, config: &[usize]) -> Self::Value { + self.evaluations.set(self.evaluations.get() + 1); + Or(config == [0, 0]) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + impl Problem for SumProblem { const NAME: &'static str = "SumProblem"; type Value = Sum; @@ -162,6 +187,19 @@ fn test_solver_find_witness_for_satisfaction_problem() { assert_eq!(problem.evaluate(&witness.unwrap()), Or(true)); } +#[test] +fn test_solver_find_witness_stops_after_first_optimal_configuration() { + let evaluations = Rc::new(Cell::new(0)); + let problem = CountingSatProblem { + evaluations: Rc::clone(&evaluations), + }; + + assert_eq!(BruteForce::new().find_witness(&problem), Some(vec![0, 0])); + // Four evaluations compute the aggregate; the witness pass stops at the + // first configuration instead of collecting every optimal witness. + assert_eq!(evaluations.get(), 5); +} + #[test] fn test_solver_find_witness_returns_none_for_sum_problem() { let problem = SumProblem { diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 310ab6fa2..d83350d7d 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -16,7 +16,7 @@ fn test_ilp_solver_basic_maximize() { let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); let sol = solution.unwrap(); // Solution should be valid @@ -40,7 +40,7 @@ fn test_ilp_solver_basic_minimize() { let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); let sol = solution.unwrap(); // Solution should be valid @@ -86,11 +86,11 @@ fn test_ilp_empty_problem() { let ilp = ILP::::empty(); let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert_eq!(solution, Some(vec![])); + assert_eq!(solution, Ok(vec![])); } #[test] -fn test_ilp_empty_problem_with_infeasible_constraint_returns_none() { +fn test_ilp_empty_problem_with_infeasible_constraint_returns_infeasible() { let ilp = ILP::::new( 0, vec![LinearConstraint::le(vec![], -1.0)], @@ -99,7 +99,27 @@ fn test_ilp_empty_problem_with_infeasible_constraint_returns_none() { ); let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert_eq!(solution, None); + assert_eq!(solution, Err(ILPSolveError::Infeasible)); +} + +#[test] +fn test_backend_errors_are_classified_without_losing_the_cause() { + assert_eq!( + classify_backend_error(ResolutionError::Infeasible, None), + ILPSolveError::Infeasible + ); + assert_eq!( + classify_backend_error(ResolutionError::Unbounded, None), + ILPSolveError::Unbounded + ); + assert_eq!( + classify_backend_error(ResolutionError::Other("NoSolutionFound"), Some(0.1)), + ILPSolveError::Timeout + ); + assert!(matches!( + classify_backend_error(ResolutionError::Other("SolveError"), None), + ILPSolveError::BackendFailure(message) if message.contains("SolveError") + )); } #[test] @@ -262,49 +282,34 @@ fn test_ilp_with_time_limit() { ); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); } #[test] -fn test_ilp_solve_via_reduction_success() { +fn test_registered_ilp_pipeline_success() { use crate::models::graph::MaximumIndependentSet; + use crate::registry::load_dyn; + use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; use crate::topology::SimpleGraph; use std::collections::BTreeMap; - let solver = ILPSolver::new(); let problem = MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]); let variant = BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "i32".to_string()), ]); - let result = solver.try_solve_via_reduction("MaximumIndependentSet", &variant, &problem); - assert!(result.is_ok()); - let sol = result.unwrap(); - let eval = problem.evaluate(&sol); + let loaded = load_dyn( + "MaximumIndependentSet", + &variant, + serde_json::to_value(&problem).unwrap(), + ) + .unwrap(); + let result = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert!(matches!(result.solver, SolverExecution::Ilp { .. })); + let eval = problem.evaluate(result.config.as_ref().unwrap()); assert!(eval.is_valid()); } -#[test] -fn test_ilp_solve_via_reduction_no_path() { - use std::collections::BTreeMap; - - // Use a problem name that doesn't exist in the graph - let solver = ILPSolver::new(); - let ilp = ILP::::new( - 2, - vec![LinearConstraint::le(vec![(0, 1.0), (1, 1.0)], 1.0)], - vec![(0, 1.0)], - ObjectiveSense::Maximize, - ); - // solve_via_reduction on an ILP itself should succeed directly - let result = solver.try_solve_via_reduction( - "ILP", - &BTreeMap::from([("type".to_string(), "bool".to_string())]), - &ilp, - ); - assert!(result.is_ok()); -} - #[test] fn test_ilp_solve_dyn_bool() { let solver = ILPSolver::new(); @@ -315,7 +320,7 @@ fn test_ilp_solve_dyn_bool() { ObjectiveSense::Maximize, ); let result = solver.solve_dyn(&ilp as &dyn std::any::Any); - assert!(result.is_some()); + assert!(result.is_ok()); } #[test] @@ -328,63 +333,13 @@ fn test_ilp_solve_dyn_i32() { ObjectiveSense::Maximize, ); let result = solver.solve_dyn(&ilp as &dyn std::any::Any); - assert!(result.is_some()); + assert!(result.is_ok()); } #[test] -fn test_ilp_solve_dyn_unknown_type_returns_none() { +fn test_ilp_solve_dyn_unknown_type_returns_unsupported_problem_type() { let solver = ILPSolver::new(); let not_ilp: i32 = 42; let result = solver.solve_dyn(¬_ilp as &dyn std::any::Any); - assert!(result.is_none()); -} - -#[test] -fn test_ilp_supports_direct_dyn() { - let solver = ILPSolver::new(); - let ilp_bool = ILP::::empty(); - let ilp_i32 = ILP::::new(1, vec![], vec![], ObjectiveSense::Maximize); - let not_ilp: i32 = 42; - - assert!(solver.supports_direct_dyn(&ilp_bool as &dyn std::any::Any)); - assert!(solver.supports_direct_dyn(&ilp_i32 as &dyn std::any::Any)); - assert!(!solver.supports_direct_dyn(¬_ilp as &dyn std::any::Any)); -} - -#[test] -fn test_solve_via_reduction_error_display() { - use crate::solvers::ilp::SolveViaReductionError; - - let err = SolveViaReductionError::WitnessPathRequired { - name: "Foo".to_string(), - }; - assert!(err.to_string().contains("witness-capable")); - assert!(err.to_string().contains("Foo")); - - let err = SolveViaReductionError::NoReductionPath { - name: "Bar".to_string(), - }; - assert!(err.to_string().contains("No reduction path")); - assert!(err.to_string().contains("Bar")); - - let err = SolveViaReductionError::NoSolution { - name: "Baz".to_string(), - }; - assert!(err.to_string().contains("no solution")); - assert!(err.to_string().contains("Baz")); - - // std::error::Error is implemented - let _: &dyn std::error::Error = &err; -} - -#[test] -fn test_solve_via_reduction_returns_none_for_no_path() { - let solver = ILPSolver::new(); - let not_ilp: i32 = 42; - let result = solver.solve_via_reduction( - "NonexistentProblem", - &std::collections::BTreeMap::new(), - ¬_ilp as &dyn std::any::Any, - ); - assert!(result.is_none()); + assert_eq!(result, Err(ILPSolveError::UnsupportedProblemType)); } diff --git a/src/unit_tests/solvers/customized/solver.rs b/src/unit_tests/solvers/native/solver.rs similarity index 76% rename from src/unit_tests/solvers/customized/solver.rs rename to src/unit_tests/solvers/native/solver.rs index 09127b0d5..dabfd8531 100644 --- a/src/unit_tests/solvers/customized/solver.rs +++ b/src/unit_tests/solvers/native/solver.rs @@ -1,9 +1,33 @@ use crate::config::DimsIterator; use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; -use crate::solvers::CustomizedSolver; +use crate::solvers::registry::solver_capability_registry; +use crate::solvers::ExactProblemKey; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; +struct NativeTestSolver; + +impl NativeTestSolver { + fn new() -> Self { + Self + } + + fn solve_dyn(&self, problem: &P) -> Option> { + let key = ExactProblemKey::new( + P::NAME, + P::variant() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ); + solver_capability_registry() + .unwrap() + .lookup(&key) + .native + .and_then(|registration| (registration.solve_fn)(problem)) + } +} + fn all_simple_graphs(num_vertices: usize) -> impl Iterator { let candidate_edges: Vec<(usize, usize)> = (0..num_vertices) .flat_map(|u| ((u + 1)..num_vertices).map(move |v| (u, v))) @@ -37,22 +61,22 @@ fn exact_rooted_tree_arrangement_min_stretch(graph: &SimpleGraph) -> Option Vec<(&'static str, &'static str)> { + Vec::new() +} + +fn no_solution(_: &dyn std::any::Any) -> Option> { + None +} + +static NATIVE_A: NativeSolverRegistration = NativeSolverRegistration { + source_name: "Source", + source_variant_fn: source_variant, + implementation: "native-a", + solve_fn: no_solution, +}; +static NATIVE_B: NativeSolverRegistration = NativeSolverRegistration { + source_name: "Source", + source_variant_fn: source_variant, + implementation: "native-b", + solve_fn: no_solution, +}; + +#[test] +fn solver_capability_registry_constructs_without_graph_search() { + solver_capability_registry().expect("production solver registrations must be valid"); +} + +#[test] +fn exact_problem_key_has_canonical_label() { + let key = ExactProblemKey::new( + "MaximumIndependentSet", + BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]), + ); + assert_eq!(key.label(), "MaximumIndependentSet"); +} + +#[test] +fn solver_capability_registry_duplicate_ilp_registration_is_rejected_independent_of_order() { + let variants = BTreeSet::from([ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + )]); + for pipelines in [ + [&DIRECT_BOOL_A, &DIRECT_BOOL_B], + [&DIRECT_BOOL_B, &DIRECT_BOOL_A], + ] { + let error = build_registry(&variants, std::iter::empty(), pipelines, &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::DuplicateIlp(_))); + } +} + +#[test] +fn solver_capability_registry_duplicate_native_registration_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new("Source", BTreeMap::new())]); + let error = + build_registry(&variants, [&NATIVE_A, &NATIVE_B], std::iter::empty(), &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::DuplicateNative(_))); +} + +#[test] +fn solver_capability_registry_unknown_native_variant_is_rejected() { + let error = build_registry(&BTreeSet::new(), [&NATIVE_A], std::iter::empty(), &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnknownVariant(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_unknown_pipeline_variant_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + )]); + let error = build_registry(&variants, std::iter::empty(), [&MISSING_EDGE], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnknownVariant(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_empty_pipeline_is_rejected() { + let error = + build_registry(&BTreeSet::new(), std::iter::empty(), [&EMPTY_PIPELINE], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::EmptyPipeline)); +} + +#[test] +fn solver_capability_registry_unsupported_pipeline_target_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new("Source", BTreeMap::new())]); + let error = + build_registry(&variants, std::iter::empty(), [&UNSUPPORTED_TARGET], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnsupportedTarget(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_pipeline_with_missing_exact_edge_is_rejected() { + let variants = BTreeSet::from([ + ExactProblemKey::new("Source", BTreeMap::new()), + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + ), + ]); + let error = build_registry(&variants, std::iter::empty(), [&MISSING_EDGE], &[]).unwrap_err(); + assert!(matches!( + error, + RegistryBuildError::InvalidEdge { matches: 0, .. } + )); +} + +#[test] +fn solver_capability_registry_pipeline_must_stop_at_first_supported_ilp_node() { + let variants = BTreeSet::from([ + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + ), + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "i32".to_string())]), + ), + ]); + let error = + build_registry(&variants, std::iter::empty(), [&CONTINUES_AFTER_ILP], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::ContinuesAfterIlp(_))); +} + +#[test] +fn solver_capability_registry_production_registry_has_expected_exact_capability_counts() { + let registry = solver_capability_registry().unwrap(); + assert_eq!(registry.native_entries().count(), 7); + #[cfg(feature = "ilp-solver")] + assert_eq!(registry.ilp_entries().count(), 151); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn solver_capability_registry_exposes_representative_capability_classes() { + let key = |name: &str, variant: &[(&str, &str)]| { + ExactProblemKey::new( + name, + variant + .iter() + .map(|&(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + }; + + let native_only = solver_capabilities(&key("TimetableDesign", &[])).unwrap(); + assert_eq!( + native_only.native.unwrap().implementation, + "timetable-required-assignments" + ); + assert!(native_only.ilp.is_none()); + + let direct_ilp = solver_capabilities(&key( + "MaximumClique", + &[("graph", "SimpleGraph"), ("weight", "i32")], + )) + .unwrap(); + assert!(direct_ilp.native.is_none()); + assert_eq!( + direct_ilp.ilp.unwrap().path_labels(), + ["MaximumClique", "ILP"] + ); + + let multihop_ilp = solver_capabilities(&key( + "MaximumIndependentSet", + &[("graph", "SimpleGraph"), ("weight", "One")], + )) + .unwrap(); + assert!(multihop_ilp.ilp.unwrap().path_labels().len() > 2); + + let both = + solver_capabilities(&key("RootedTreeArrangement", &[("graph", "SimpleGraph")])).unwrap(); + assert!(both.native.is_some()); + assert!(both.ilp.is_some()); + + let brute_force_only = solver_capabilities(&key( + "MaxCut", + &[("graph", "SimpleGraph"), ("weight", "i32")], + )) + .unwrap(); + assert!(brute_force_only.native.is_none()); + assert!(brute_force_only.ilp.is_none()); + + let ilp_itself = solver_capabilities(&key("ILP", &[("variable", "bool")])).unwrap(); + assert_eq!(ilp_itself.ilp.unwrap().path_labels(), ["ILP"]); +} + +#[test] +fn solver_capability_registry_does_not_leak_across_exact_variants() { + let registry = solver_capability_registry().unwrap(); + let key = ExactProblemKey::new( + "MinimumCardinalityKey", + BTreeMap::from([("unexpected".to_string(), "variant".to_string())]), + ); + let capabilities = registry.lookup(&key); + assert!(capabilities.native.is_none()); + assert!(capabilities.ilp.is_none()); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn solver_capability_registry_ignores_unrelated_reduction_edges() { + let source = ExactProblemKey::new( + "MaximumIndependentSet", + BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]), + ); + let registration = inventory::iter:: + .into_iter() + .find(|registration| { + registration.path.first().map(ExactProblemKey::from_static) == Some(source.clone()) + }) + .expect("production MIS pipeline must be registered"); + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let all_reductions = reduction_entries(); + let required_reductions = all_reductions + .iter() + .copied() + .filter(|entry| { + path.windows(2) + .any(|pair| edge_key(entry, true) == pair[0] && edge_key(entry, false) == pair[1]) + }) + .collect::>(); + let unrelated = all_reductions + .iter() + .copied() + .find(|entry| { + !required_reductions + .iter() + .any(|required| std::ptr::eq(*required, *entry)) + }) + .expect("catalog must contain an unrelated reduction edge"); + let mut with_unrelated = required_reductions.clone(); + with_unrelated.push(unrelated); + + let variants = registered_variant_keys(); + let minimal = build_registry( + &variants, + std::iter::empty(), + [registration], + &required_reductions, + ) + .unwrap(); + let expanded = build_registry( + &variants, + std::iter::empty(), + [registration], + &with_unrelated, + ) + .unwrap(); + let minimal_pipeline = minimal.lookup(&source).ilp.unwrap(); + let expanded_pipeline = expanded.lookup(&source).ilp.unwrap(); + + assert_eq!(minimal_pipeline.path(), expanded_pipeline.path()); + assert_eq!( + minimal_pipeline + .reducers + .iter() + .map(|reducer| *reducer as usize) + .collect::>(), + expanded_pipeline + .reducers + .iter() + .map(|reducer| *reducer as usize) + .collect::>() + ); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn solver_capability_registry_ambiguous_exact_edge_is_rejected() { + let registration = inventory::iter:: + .into_iter() + .find(|registration| registration.path.len() == 2) + .expect("production catalog must contain a direct ILP pipeline"); + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let reduction = reduction_entries() + .into_iter() + .find(|entry| { + entry.capabilities.witness + && entry.reduce_fn.is_some() + && edge_key(entry, true) == path[0] + && edge_key(entry, false) == path[1] + }) + .expect("direct pipeline must have one witness reduction"); + let error = build_registry( + ®istered_variant_keys(), + std::iter::empty(), + [registration], + &[reduction, reduction], + ) + .unwrap_err(); + + assert!(matches!( + error, + RegistryBuildError::InvalidEdge { matches: 2, .. } + )); +} diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs new file mode 100644 index 000000000..ce39fcd83 --- /dev/null +++ b/src/unit_tests/solvers/resolver.rs @@ -0,0 +1,244 @@ +#[cfg(feature = "ilp-solver")] +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; +use crate::registry::load_dyn; +use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; +use crate::traits::Problem; +use std::collections::BTreeMap; + +#[test] +fn deterministic_solver_dispatch_native_registration_wins_default_dispatch() { + use crate::models::set::MinimumCardinalityKey; + + let problem = MinimumCardinalityKey::new(3, vec![(vec![0], vec![1, 2])]); + let loaded = crate::registry::load_dyn( + MinimumCardinalityKey::NAME, + &BTreeMap::new(), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!( + result.solver, + SolverExecution::Native { + implementation: "fd-minimum-cardinality-key" + } + ); +} + +#[test] +fn deterministic_solver_dispatch_unregistered_ilp_override_is_a_capability_error_without_fallback() +{ + use crate::models::graph::MaxCut; + use crate::topology::SimpleGraph; + + // MaxCut has a discoverable graph route toward ILP, but that route is + // partial for valid negative-weight instances and is intentionally not a + // registered solver pipeline. + let problem = MaxCut::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32]); + let loaded = crate::registry::load_dyn( + MaxCut::::NAME, + &BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let default = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!(default.solver, SolverExecution::BruteForce); + let error = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::MissingIlpCapability(_) + )); +} + +#[test] +fn deterministic_solver_dispatch_native_failure_does_not_fall_back() { + use crate::models::misc::AdditionalKey; + + // {0} is the only candidate key and it is already known, so the registered + // native solver has no witness. Brute force can still report the aggregate + // infeasibility result, which lets this test distinguish fallback from error. + let problem = AdditionalKey::new(3, vec![(vec![0], vec![1, 2])], vec![0, 1, 2], vec![vec![0]]); + let loaded = load_dyn( + AdditionalKey::NAME, + &BTreeMap::new(), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::NativeNoSolution { .. } + )); + let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); + assert_eq!(brute_force.solver, SolverExecution::BruteForce); + assert!(brute_force.config.is_none()); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_direct_ilp_uses_registered_one_node_pipeline() { + let problem = ILP::::new(0, vec![], vec![], ObjectiveSense::Minimize); + let loaded = load_dyn( + ILP::::NAME, + &BTreeMap::from([("variable".to_string(), "bool".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!( + result.solver, + SolverExecution::Ilp { + reduction_path: vec!["ILP".to_string()] + } + ); + assert_eq!(result.config, Some(vec![])); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_ilp_failure_does_not_fall_back() { + let problem = ILP::::new( + 0, + vec![LinearConstraint::le(vec![], -1.0)], + vec![], + ObjectiveSense::Minimize, + ); + let loaded = load_dyn( + ILP::::NAME, + &BTreeMap::from([("variable".to_string(), "bool".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::IlpSolve { + source: crate::solvers::ILPSolveError::Infeasible, + .. + } + )); + let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); + assert_eq!(brute_force.solver, SolverExecution::BruteForce); + assert!(brute_force.config.is_none()); +} + +#[test] +fn deterministic_solver_execution_has_stable_tagged_json_contract() { + assert_eq!( + serde_json::to_value(SolverExecution::Native { + implementation: "native-id" + }) + .unwrap(), + serde_json::json!({"kind": "native", "implementation": "native-id"}) + ); + assert_eq!( + serde_json::to_value(SolverExecution::Ilp { + reduction_path: vec!["Source".to_string(), "ILP".to_string()] + }) + .unwrap(), + serde_json::json!({ + "kind": "ilp", + "reduction_path": ["Source", "ILP"] + }) + ); + assert_eq!( + serde_json::to_value(SolverExecution::BruteForce).unwrap(), + serde_json::json!({"kind": "brute-force"}) + ); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { + use crate::models::graph::MaximumIndependentSet; + use crate::topology::SimpleGraph; + + let problem = MaximumIndependentSet::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![crate::types::One; 3], + ); + let variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]); + let loaded = load_dyn( + MaximumIndependentSet::::NAME, + &variant, + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let first = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + let second = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert_eq!(first, second); + let SolverExecution::Ilp { reduction_path } = first.solver else { + panic!("expected ILP execution metadata"); + }; + assert_eq!( + reduction_path, + vec![ + "MaximumIndependentSet", + "MaximumIndependentSet", + "MaximumSetPacking", + "ILP", + ] + ); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_native_default_allows_explicit_ilp_override() { + use crate::models::graph::RootedTreeArrangement; + use crate::topology::SimpleGraph; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3); + let loaded = load_dyn( + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let default = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert!(matches!(default.solver, SolverExecution::Native { .. })); + + let explicit_ilp = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert!(matches!(explicit_ilp.solver, SolverExecution::Ilp { .. })); + assert_eq!(default.evaluation, explicit_ilp.evaluation); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_repeats_each_available_solver_class() { + use crate::models::graph::RootedTreeArrangement; + use crate::topology::SimpleGraph; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3); + let loaded = load_dyn( + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let mut evaluations = Vec::new(); + for request in [ + SolverRequest::Default, + SolverRequest::Ilp, + SolverRequest::BruteForce, + ] { + let first = solve_deterministically(&loaded, request).unwrap(); + let second = solve_deterministically(&loaded, request).unwrap(); + assert_eq!(first, second, "{request:?} changed its witness"); + evaluations.push(first.evaluation); + } + assert!(evaluations.windows(2).all(|pair| pair[0] == pair[1])); +} diff --git a/src/unit_tests/unitdiskmapping_algorithms/common.rs b/src/unit_tests/unitdiskmapping_algorithms/common.rs index 2a6cd4f5a..ca3c6c5d7 100644 --- a/src/unit_tests/unitdiskmapping_algorithms/common.rs +++ b/src/unit_tests/unitdiskmapping_algorithms/common.rs @@ -40,7 +40,7 @@ pub fn solve_mis(num_vertices: usize, edges: &[(usize, usize)]) -> usize { let weights = vec![1; num_vertices]; let ilp = build_mis_ilp(num_vertices, edges, &weights); let solver = ILPSolver::new(); - if let Some(solution) = solver.solve(&ilp) { + if let Ok(solution) = solver.solve(&ilp) { solution.iter().filter(|&&x| x > 0).count() } else { 0 @@ -52,7 +52,7 @@ pub fn solve_mis_config(num_vertices: usize, edges: &[(usize, usize)]) -> Vec 0 { 1 } else { 0 }) @@ -88,7 +88,7 @@ pub fn solve_weighted_grid_mis(result: &MappingResult) -> usize { pub fn solve_weighted_mis(num_vertices: usize, edges: &[(usize, usize)], weights: &[i32]) -> i32 { let ilp = build_mis_ilp(num_vertices, edges, weights); let solver = ILPSolver::new(); - if let Some(solution) = solver.solve(&ilp) { + if let Ok(solution) = solver.solve(&ilp) { solution .iter() .zip(weights.iter()) @@ -109,7 +109,7 @@ pub fn solve_weighted_mis_config( let ilp = build_mis_ilp(num_vertices, edges, weights); let solver = ILPSolver::new(); - if let Some(solution) = solver.solve(&ilp) { + if let Ok(solution) = solver.solve(&ilp) { solution .iter() .map(|&x| if x > 0 { 1 } else { 0 }) diff --git a/src/unit_tests/unitdiskmapping_algorithms/weighted.rs b/src/unit_tests/unitdiskmapping_algorithms/weighted.rs index 62ec282d4..d1ee880e6 100644 --- a/src/unit_tests/unitdiskmapping_algorithms/weighted.rs +++ b/src/unit_tests/unitdiskmapping_algorithms/weighted.rs @@ -715,7 +715,7 @@ fn test_weighted_map_config_back_standard_graphs() { let grid_config: Vec = solver .solve(&ilp) .map(|sol| sol.iter().map(|&x| if x > 0 { 1 } else { 0 }).collect()) - .unwrap_or_else(|| vec![0; num_grid]); + .unwrap_or_else(|_| vec![0; num_grid]); // Use triangular-specific trace_centers (not the KSG version) // Build position to node index map diff --git a/tests/suites/register_assignment_reductions.rs b/tests/suites/register_assignment_reductions.rs index a124edb00..cecb8bdfb 100644 --- a/tests/suites/register_assignment_reductions.rs +++ b/tests/suites/register_assignment_reductions.rs @@ -106,7 +106,7 @@ fn test_unsatisfiable_ksat_stays_infeasible_through_fra_to_ilp() { assert!( ILPSolver::new() .solve(fra_chain.target_problem::>()) - .is_none(), + .is_err(), "unsatisfiable source instance should yield an infeasible ILP" ); }