From 1c052af0c8f1c3968a0adf1b9bb92d38bcb38135 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 18 Sep 2026 15:54:04 +0800 Subject: [PATCH 01/44] Support completed reduction recovery through witness and value mappings --- .claude/CLAUDE.md | 5 +- problemreductions-cli/src/cli.rs | 24 +- problemreductions-cli/src/commands/extract.rs | 87 +++++- problemreductions-cli/src/commands/reduce.rs | 52 +++- problemreductions-cli/src/commands/solve.rs | 2 +- problemreductions-cli/src/dispatch.rs | 182 +++++++++--- problemreductions-cli/src/main.rs | 6 +- problemreductions-cli/src/mcp/tools.rs | 8 +- problemreductions-cli/src/test_support.rs | 4 + problemreductions-cli/tests/cli_tests.rs | 229 +++++++++++++++ problemreductions-macros/src/lib.rs | 8 + src/models/decision.rs | 61 ++-- src/registry/dyn_problem.rs | 30 +- src/registry/variant.rs | 2 + src/rules/graph.rs | 124 +++++++- ...nimumvertexcover_minimummaximalmatching.rs | 1 + src/rules/mod.rs | 2 + src/rules/registry.rs | 6 + src/rules/satisfiability_naesatisfiability.rs | 27 +- src/rules/subsetsum_integerknapsack.rs | 1 + src/rules/traits.rs | 51 +++- src/solvers/registry.rs | 24 +- src/unit_tests/models/decision.rs | 74 ++++- src/unit_tests/reduction_graph.rs | 9 +- src/unit_tests/rules/graph.rs | 275 +++++++++++++++++- .../hamiltoniancircuit_quadraticassignment.rs | 4 +- src/unit_tests/rules/ksatisfiability_qubo.rs | 9 +- src/unit_tests/rules/registry.rs | 1 + src/unit_tests/rules/traits.rs | 238 ++++++++++++++- src/unit_tests/solvers/registry.rs | 23 +- 30 files changed, 1421 insertions(+), 148 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 438584ded..b313c103c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -165,7 +165,8 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - Problems parameterized by graph type `G` and optionally weight type `W` (problem-dependent) - `BruteForce::solve()` returns `Result, SolveError>`; `None` means exhaustive search proved infeasibility - `BruteForce::find_all_witnesses()` is a reference-testing helper for collecting every optimal or satisfying solution -- `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows +- `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows. Neither requires a rule-category tag. When both are registered, completed-result recovery borrows both mappings from the same constructed reduction. +- `ReductionChain::extract_result()` consumes a completed exact target result; callers must establish optimality or infeasibility. A missing mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value_dyn()` without a representative witness. - Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. - Decode only the reduction's defined mathematical mapping. Reject malformed structure with `ExtractionError`; never panic, truncate, clamp, invent defaults, or add recovery branches. Explicit mathematical alternatives and sentinels are allowed. Test successful decoding and every rejected representation. - CLI-facing dynamic formatting uses aggregate wrapper names directly (for example `Max(2)`, `Min(None)`, `Or(true)`, or `Sum(56)`) @@ -210,7 +211,7 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - Each primitive reduction is determined by the exact `(source_variant, target_variant)` endpoint pair - Reduction edges carry `EdgeCapabilities { witness, aggregate, turing }`; graph search defaults to witness mode, aggregate mode is available through `ReductionMode::Aggregate`, and Turing (multi-query) mode via `ReductionMode::Turing` - `#[reduction]` requires one `transform = exact`, `transform = upper_bound`, or `transform = unavailable` declaration and currently registers witness/config reductions; aggregate-only and Turing edges require manual `ReductionEntry` registration -- `Decision

→ P` is an aggregate-only edge (solve optimization, compare to bound); `P → Decision

` is a Turing edge (binary search over decision bound) +- `Decision

→ P` supports both mappings: compare the exact optimum to the bound, and recover a witness only if it meets the bound. `P → Decision

` is a non-executable Turing edge. ### Extension Points - New models register dynamic load/serialize metadata through `declare_variants!` and, when finite enumeration exists, register it separately through `register_brute_force!`; neither belongs in CLI match arms diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index dd1518a1b..cdebc47a8 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -209,11 +209,13 @@ Examples: Inspect(InspectArgs), /// Solve a problem instance Solve(SolveArgs), - /// Extract a source-space solution from a reduction bundle and a target-space config + /// Recover a source solution, completed result, or aggregate from a reduction bundle #[command(after_help = "\ Examples: pred extract bundle.json --config '[1,0,1,0]' pred extract bundle.json --config '[1,0,1,0]' -o source.json + pred extract bundle.json --result optimum.json + pred extract bundle.json --value '2' cat bundle.json | pred extract - --config '[1,0,1,0]' Use this when an external solver has solved the bundle's target problem @@ -221,6 +223,13 @@ Use this when an external solver has solved the bundle's target problem the corresponding solution in the original source problem space without having to shell back into `pred solve`. +--config recovers a candidate only. --result requires an exact completed result: + {\"status\":\"optimal\",\"solution\":[true,false],\"evaluation\":\"Min(1)\"} + {\"status\":\"infeasible\"} +Evaluation is optional, but must match when supplied. The external solver must +establish optimality or infeasibility; numerical status alone is not a proof. +--value maps an exact aggregate through value-capable edges, without a witness. + Input: a reduction bundle JSON (from `pred reduce`). Use - to read from stdin. --config is the target problem's solution encoded as JSON (e.g. '[1,0,1,0]').")] Extract(ExtractArgs), @@ -331,15 +340,26 @@ pub struct ReduceArgs { /// Explicit reduction route selected from a path-set entry. #[arg(long, required = true)] pub via: PathBuf, + /// Execute value mappings; recover the external aggregate with `pred extract --value`. + #[arg(long)] + pub aggregate: bool, } #[derive(clap::Args)] +#[group(skip)] +#[command(group(clap::ArgGroup::new("recovery_input").required(true).args(["config", "result", "value"])))] pub struct ExtractArgs { /// Reduction bundle JSON (from `pred reduce`). Use - for stdin. pub input: PathBuf, /// Target problem solution encoded as JSON (for example, [1,0,1,0]) #[arg(long)] - pub config: String, + pub config: Option, + /// JSON file containing an exact completed target result (optimal or infeasible). + #[arg(long)] + pub result: Option, + /// Exact target aggregate encoded as JSON; uses only value mappings. + #[arg(long)] + pub value: Option, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index 79736a984..e3780658c 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -1,15 +1,22 @@ +use crate::cli::ExtractArgs; use crate::dispatch::{read_input, BundleReplay, ReductionBundle}; use crate::output::OutputConfig; use anyhow::{Context, Result}; -use std::path::Path; - -/// Extract a source-space configuration from a target-space configuration and a reduction bundle. -/// -/// This lets external solvers (that solved the bundle's target problem on their own) -/// recover a solution in the original source problem space without having to -/// re-solve through `pred solve`. -pub fn extract(input: &Path, config_str: &str, out: &OutputConfig) -> Result<()> { - let content = read_input(input)?; +use problemreductions::rules::ReductionMode; +use problemreductions::solvers::SolveOutcome; + +#[derive(serde::Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum ExternalResult { + Optimal { solution: serde_json::Value }, + Infeasible, +} + +/// Recover a candidate, completed exact result, or aggregate through a bundle. +/// `--result` accepts solve-output metadata, but validates any supplied evaluation. +/// The external solver is responsible for proving optimality or infeasibility. +pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { + let content = read_input(&args.input)?; let json: serde_json::Value = serde_json::from_str(&content).context("Input is not valid JSON")?; @@ -25,11 +32,69 @@ pub fn extract(input: &Path, config_str: &str, out: &OutputConfig) -> Result<()> let bundle: ReductionBundle = serde_json::from_value(json).context("Failed to parse reduction bundle")?; + if let Some(value) = &args.value { + let replay = BundleReplay::prepare(&bundle, ReductionMode::Aggregate)?; + let value = replay.extract_value( + serde_json::from_str(value).context("Target aggregate is not valid JSON")?, + )?; + return out.emit( + || format!("Problem: {}\nAggregate: {value}", replay.source_name), + || Ok(serde_json::json!({"problem": replay.source_name, "aggregate": value})), + ); + } + let replay = BundleReplay::prepare(&bundle, ReductionMode::Witness)?; + if let Some(path) = &args.result { + let json: serde_json::Value = + serde_json::from_str(&read_input(path)?).context("Invalid completed target result")?; + if json + .pointer("/solver/kind") + .and_then(serde_json::Value::as_str) + == Some("ilp") + { + anyhow::bail!("numerical ILP status is not an exact certificate; recover a candidate with --config") + } + let evaluation = json.get("evaluation").cloned(); + let external: ExternalResult = + serde_json::from_value(json).context("Invalid completed target result")?; + let target = match external { + ExternalResult::Optimal { solution } => { + let actual = replay.target.evaluate_dyn(&solution)?; + if evaluation + .as_ref() + .is_some_and(|value| value != &serde_json::json!(actual)) + { + anyhow::bail!("target evaluation does not match the witness") + } + SolveOutcome::Optimal { + solution, + evaluation: actual, + } + } + ExternalResult::Infeasible => { + if evaluation.is_some() { + anyhow::bail!("infeasible results do not have a witness evaluation") + } + SolveOutcome::Infeasible + } + }; + let source = replay.extract_result(&target)?; + return out.emit( + || format!("Problem: {}\nResult: {source:?}", replay.source_name), + || { + let mut json = serde_json::to_value(&source)?; + json["problem"] = serde_json::json!(replay.source_name); + json["intermediate"] = serde_json::to_value(&target)?; + Ok(json) + }, + ); + } + let config_str = args + .config + .as_deref() + .context("provide --config, --result, or --value")?; let target_config: serde_json::Value = serde_json::from_str(config_str).context("Target config is not valid JSON")?; - let replay = BundleReplay::prepare(&bundle)?; - let target_eval = replay.target.evaluate_dyn(&target_config)?; let (source_config, source_eval) = replay.extract(&target_config)?; diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs index 0ff6f61c4..853a9615c 100644 --- a/problemreductions-cli/src/commands/reduce.rs +++ b/problemreductions-cli/src/commands/reduce.rs @@ -4,7 +4,7 @@ use crate::dispatch::{ }; use crate::output::OutputConfig; use anyhow::{Context, Result}; -use problemreductions::rules::{ReductionGraph, ReductionPath, ReductionStep}; +use problemreductions::rules::{ReductionGraph, ReductionMode, ReductionPath, ReductionStep}; use std::collections::BTreeMap; use std::path::Path; @@ -64,6 +64,7 @@ pub(crate) fn parse_path_json(content: &str) -> Result { pub(crate) fn execute_route( problem_json: ProblemJson, reduction_path: ReductionPath, + mode: ReductionMode, ) -> Result { let source = load_problem( &problem_json.problem_type, @@ -87,23 +88,37 @@ pub(crate) fn execute_route( } let graph = ReductionGraph::new(); - let chain = graph - .reduce_along_path(&reduction_path, source.as_any()) - .map_err(|error| anyhow::anyhow!("Reduction path execution failed: {error}"))? - .ok_or_else(|| { - anyhow::anyhow!( - "Reduction bundles require witness-capable paths; this path cannot produce a recoverable witness." - ) - })?; let target_step = reduction_path .steps .last() .expect("route parser requires at least one edge"); - let target_data = serialize_any_problem( - &target_step.name, - &target_step.variant, - chain.target_problem_any(), - )?; + let target_data = match mode { + ReductionMode::Witness => { + let chain = graph + .reduce_along_path(&reduction_path, source.as_any())? + .ok_or_else(|| { + anyhow::anyhow!("Reduction bundles require witness-capable paths") + })?; + serialize_any_problem( + &target_step.name, + &target_step.variant, + chain.target_problem_any(), + )? + } + ReductionMode::Aggregate => { + let chain = graph + .reduce_aggregate_along_path(&reduction_path, source.as_any())? + .ok_or_else(|| { + anyhow::anyhow!("Reduction bundle requires an aggregate-capable path") + })?; + serialize_any_problem( + &target_step.name, + &target_step.variant, + chain.target_problem_any(), + )? + } + ReductionMode::Turing => anyhow::bail!("Turing reductions are not executable"), + }; Ok(ReductionBundle { source: ProblemJsonOutput { @@ -127,13 +142,18 @@ pub(crate) fn execute_route( }) } -pub fn reduce(input: &Path, via: &Path, out: &OutputConfig) -> Result<()> { +pub fn reduce(input: &Path, via: &Path, aggregate: bool, out: &OutputConfig) -> Result<()> { let content = read_input(input)?; let problem_json: ProblemJson = serde_json::from_str(&content)?; let reduction_path = load_path_file(via)?; let route_len = reduction_path.len(); let route_text = reduction_path.to_string(); - let bundle = execute_route(problem_json, reduction_path)?; + let mode = if aggregate { + ReductionMode::Aggregate + } else { + ReductionMode::Witness + }; + let bundle = execute_route(problem_json, reduction_path, mode)?; out.emit( || { diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index ce3c471de..625fd66d3 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -136,7 +136,7 @@ fn solve_problem( /// Solve a reduction bundle: solve the target problem, then map the solution back. fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputConfig) -> Result<()> { - let replay = BundleReplay::prepare(&bundle)?; + let replay = BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Witness)?; let result = replay.solve(request).map_err(add_solver_hint)?; let emitted = out.emit( diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index c48f99f52..83697feac 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -197,7 +197,12 @@ pub struct BundleReplay { pub(crate) source_name: String, pub(crate) target: LoadedProblem, pub(crate) target_name: String, - pub(crate) chain: problemreductions::rules::ReductionChain, + chain: BundleChain, +} + +enum BundleChain { + Witness(problemreductions::rules::ReductionChain), + Aggregate(problemreductions::rules::AggregateReductionChain), } impl BundleReplay { @@ -212,7 +217,10 @@ impl BundleReplay { /// `reduce_along_path` actually produced are rejected) /// /// Returns an error (not a panic) for malformed bundles or paths without witness extraction. - pub fn prepare(bundle: &ReductionBundle) -> Result { + pub fn prepare( + bundle: &ReductionBundle, + mode: problemreductions::rules::ReductionMode, + ) -> Result { if bundle.path.len() < 2 { anyhow::bail!( "Malformed bundle: `path` must contain at least two steps (source and target), got {}", @@ -262,19 +270,35 @@ impl BundleReplay { }; let graph = ReductionGraph::new(); - let chain = graph - .reduce_along_path(&reduction_path, source.as_any()) - .map_err(|error| anyhow::anyhow!("Bundle reduction replay failed: {error}"))? - .ok_or_else(|| anyhow::anyhow!( - "Bundle requires a witness-capable reduction path; this bundle cannot map a target solution back to the source." - ))?; + let chain = match mode { + problemreductions::rules::ReductionMode::Witness => BundleChain::Witness( + graph + .reduce_along_path(&reduction_path, source.as_any())? + .ok_or_else(|| { + anyhow::anyhow!("Bundle requires a witness-capable reduction path") + })?, + ), + problemreductions::rules::ReductionMode::Aggregate => BundleChain::Aggregate( + graph + .reduce_aggregate_along_path(&reduction_path, source.as_any())? + .ok_or_else(|| { + anyhow::anyhow!("Bundle requires an aggregate-capable reduction path") + })?, + ), + problemreductions::rules::ReductionMode::Turing => { + anyhow::bail!("Turing reductions are not executable") + } + }; // Coherence check: `bundle.target.data` must equal what replaying // `source` along `path` actually produces. Without this, a caller // could solve/validate against the bundle's stated target but then // extract through a completely different chain target. - let replayed_target_data = - serialize_any_problem(&last.name, &last.variant, chain.target_problem_any())?; + let target_any = match &chain { + BundleChain::Witness(chain) => chain.target_problem_any(), + BundleChain::Aggregate(chain) => chain.target_problem_any(), + }; + let replayed_target_data = serialize_any_problem(&last.name, &last.variant, target_any)?; if replayed_target_data != bundle.target.data { anyhow::bail!( "Malformed bundle: `target.data` does not match the result of replaying \ @@ -297,7 +321,10 @@ impl BundleReplay { &self, target_config: &serde_json::Value, ) -> Result<(serde_json::Value, String)> { - let source_config = self.chain.extract_solution_json(target_config.clone())?; + let BundleChain::Witness(chain) = &self.chain else { + anyhow::bail!("value-only reductions do not recover witnesses") + }; + let source_config = chain.extract_solution_json(target_config.clone())?; let source_eval = self.source.evaluate_witness_dyn(&source_config)?.ok_or_else(|| { problemreductions::rules::ExtractionError::invalid(format!( "extracted solution is infeasible for {}; the reduction did not establish a source solution", @@ -307,29 +334,42 @@ impl BundleReplay { Ok((source_config, source_eval)) } + pub fn extract_value(&self, value: serde_json::Value) -> Result { + let BundleChain::Aggregate(chain) = &self.chain else { + anyhow::bail!("value recovery requires an aggregate-capable path") + }; + Ok(chain.extract_value_dyn(value)?) + } + + pub fn extract_result(&self, result: &SolveOutcome) -> Result { + let BundleChain::Witness(chain) = &self.chain else { + anyhow::bail!("value-only reductions require an aggregate value") + }; + Ok(chain.extract_result(&*self.source, result)?) + } + /// Solve the target and map the result back to the source problem. /// pub(crate) fn solve(&self, request: SolverRequest) -> Result { let target_result = self.target.solve(request)?; let solver = target_result.solver; - let (source_outcome, target_outcome) = match target_result.outcome { - SolveOutcome::Optimal { - solution: target_solution, - evaluation: target_evaluation, - } => { - let (source_solution, source_evaluation) = self.extract(&target_solution)?; - ( - SolveOutcome::Optimal { - solution: source_solution, - evaluation: source_evaluation, - }, - SolveOutcome::Optimal { - solution: target_solution, - evaluation: target_evaluation, - }, - ) + let target_outcome = target_result.outcome; + let source_outcome = match (&solver, &target_outcome) { + // A numerical optimum does not establish an exact negative threshold. + ( + problemreductions::solvers::SolverExecution::Ilp { .. }, + SolveOutcome::Optimal { solution, .. }, + ) => { + let (solution, evaluation) = self.extract(solution)?; + SolveOutcome::Optimal { + solution, + evaluation, + } + } + (problemreductions::solvers::SolverExecution::Ilp { .. }, SolveOutcome::Infeasible) => { + anyhow::bail!("numerical target infeasibility does not certify the source result") } - SolveOutcome::Infeasible => (SolveOutcome::Infeasible, SolveOutcome::Infeasible), + _ => self.extract_result(&target_outcome)?, }; Ok(BundleSolveResult { @@ -427,6 +467,39 @@ mod tests { use problemreductions::topology::SimpleGraph; use serde_json::json; + #[test] + fn aggregate_only_bundle_executes_and_recovers_without_witnesses() { + use problemreductions::rules::{ReductionMode, ReductionPath, ReductionStep}; + let bundle = crate::test_support::aggregate_bundle(); + let path = ReductionPath { + steps: bundle + .path + .iter() + .map(|step| ReductionStep { + name: step.name.clone(), + variant: step.variant.clone(), + }) + .collect(), + }; + let source = ProblemJson { + problem_type: bundle.source.problem_type.clone(), + variant: bundle.source.variant.clone(), + data: bundle.source.data.clone(), + }; + let executed = + crate::commands::reduce::execute_route(source, path, ReductionMode::Aggregate).unwrap(); + assert_eq!(executed.target.data, serde_json::json!({"base":14})); + let replay = BundleReplay::prepare(&executed, ReductionMode::Aggregate).unwrap(); + assert_eq!( + replay.extract_value(serde_json::json!(12)).unwrap(), + serde_json::json!(12) + ); + assert!(replay.extract_value(serde_json::json!(true)).is_err()); + assert!(replay.extract(&serde_json::json!([true])).is_err()); + assert!(replay.extract_result(&SolveOutcome::Infeasible).is_err()); + assert!(BundleReplay::prepare(&executed, ReductionMode::Turing).is_err()); + } + #[test] fn bundle_rejects_infeasible_extracted_witness() { for (clauses, feasible) in [ @@ -453,8 +526,42 @@ mod tests { "to":{"name":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}} }]}"#, ).unwrap(); - let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); - let replay = BundleReplay::prepare(&bundle).unwrap(); + let bundle = crate::commands::reduce::execute_route( + source, + route, + problemreductions::rules::ReductionMode::Witness, + ) + .unwrap(); + let replay = + BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Witness) + .unwrap(); + assert!(replay.extract_value(serde_json::json!(1)).is_err()); + assert!(BundleReplay::prepare( + &bundle, + problemreductions::rules::ReductionMode::Aggregate + ) + .is_err()); + for mode in [ + problemreductions::rules::ReductionMode::Aggregate, + problemreductions::rules::ReductionMode::Turing, + ] { + let source = ProblemJson { + problem_type: bundle.source.problem_type.clone(), + variant: bundle.source.variant.clone(), + data: bundle.source.data.clone(), + }; + let route = problemreductions::rules::ReductionPath { + steps: bundle + .path + .iter() + .map(|step| problemreductions::rules::ReductionStep { + name: step.name.clone(), + variant: step.variant.clone(), + }) + .collect(), + }; + assert!(crate::commands::reduce::execute_route(source, route, mode).is_err()); + } let result = replay.solve(SolverRequest::BruteForce); if feasible { assert!(matches!(result.unwrap().source_outcome, @@ -493,17 +600,26 @@ mod tests { }]}"#, ) .unwrap(); - let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); + let bundle = crate::commands::reduce::execute_route( + source, + route, + problemreductions::rules::ReductionMode::Witness, + ) + .unwrap(); let encoded = serde_json::to_vec(&bundle).unwrap(); let mut restored: ReductionBundle = serde_json::from_slice(&encoded).unwrap(); assert_eq!(restored.source.data, bundle.source.data); assert_eq!(restored.target.data, bundle.target.data); - BundleReplay::prepare(&restored).expect("an unchanged JSON bundle must replay exactly"); + BundleReplay::prepare(&restored, problemreductions::rules::ReductionMode::Witness) + .expect("an unchanged JSON bundle must replay exactly"); // A one-ULP change remains tampering; replay must not use a float tolerance. let coefficient = restored.target.data["objective"][0][1].as_f64().unwrap(); restored.target.data["objective"][0][1] = json!(f64::from_bits(coefficient.to_bits() + 1)); - let error = BundleReplay::prepare(&restored).err().unwrap(); + let error = + BundleReplay::prepare(&restored, problemreductions::rules::ReductionMode::Witness) + .err() + .unwrap(); assert!(error .to_string() .contains("does not match the result of replaying")); diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 9b0b23c1c..c6aa069cc 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -78,9 +78,11 @@ fn main() -> anyhow::Result<()> { Commands::Solve(args) => { commands::solve::solve(&args.input, args.solver.as_deref(), args.timeout, &out) } - Commands::Reduce(args) => commands::reduce::reduce(&args.input, &args.via, &out), + Commands::Reduce(args) => { + commands::reduce::reduce(&args.input, &args.via, args.aggregate, &out) + } Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out), - Commands::Extract(args) => commands::extract::extract(&args.input, &args.config, &out), + Commands::Extract(args) => commands::extract::extract(&args, &out), #[cfg(feature = "mcp")] Commands::Mcp => mcp::run(), Commands::Completions { shell } => { diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 0c3da5a08..b61b97b5b 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -426,7 +426,11 @@ impl McpServer { pub fn reduce_inner(&self, problem_json: &str, path_json: &str) -> anyhow::Result { let pj: ProblemJson = serde_json::from_str(problem_json)?; let reduction_path = crate::commands::reduce::parse_path_json(path_json)?; - let bundle = crate::commands::reduce::execute_route(pj, reduction_path)?; + let bundle = crate::commands::reduce::execute_route( + pj, + reduction_path, + problemreductions::rules::ReductionMode::Witness, + )?; Ok(serde_json::to_string_pretty(&bundle)?) } @@ -697,7 +701,7 @@ fn solve_problem_inner( /// Solve a reduction bundle: solve the target, then map the solution back. fn solve_bundle_inner(bundle: ReductionBundle, request: SolverRequest) -> anyhow::Result { - let replay = BundleReplay::prepare(&bundle)?; + let replay = BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Witness)?; Ok(serde_json::to_string_pretty( &replay.solve(request)?.to_json(), )?) diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index ba3d7e15d..f7087b2b0 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -316,6 +316,7 @@ problemreductions::inventory::submit! { let problem: AggregateValueSource = serde_json::from_value(data)?; Ok(Box::new(problem)) }, + borrow_fn: |any| any.downcast_ref::().map(|p| p as &dyn problemreductions::registry::DynProblem), serialize_fn: |any| { let problem = any.downcast_ref::()?; Some(serde_json::to_value(problem).expect("serialize AggregateValueSource failed")) @@ -364,6 +365,7 @@ problemreductions::inventory::submit! { let problem: AggregateValueTarget = serde_json::from_value(data)?; Ok(Box::new(problem)) }, + borrow_fn: |any| any.downcast_ref::().map(|p| p as &dyn problemreductions::registry::DynProblem), serialize_fn: |any| { let problem = any.downcast_ref::()?; Some(serde_json::to_value(problem).expect("serialize AggregateValueTarget failed")) @@ -403,6 +405,7 @@ problemreductions::inventory::submit! { }, module_path: module_path!(), reduce_fn: None, + aggregate_view_fn: None, reduce_aggregate_fn: Some(|any: &dyn Any| { let source = any .downcast_ref::() @@ -443,6 +446,7 @@ problemreductions::inventory::submit! { }, module_path: module_path!(), reduce_fn: None, + aggregate_view_fn: None, reduce_aggregate_fn: Some(|any: &dyn Any| { let _source = any .downcast_ref::() diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 0739162e0..0cd79b5b4 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -9557,6 +9557,235 @@ fn extract_test_solve_bundle(bundle_file: &std::path::Path) -> (String, String) (target_solution, source_eval) } +#[test] +fn test_completed_decision_recovery_and_aggregate_cli() { + use problemreductions::models::{graph::MinimumVertexCover, Decision}; + use problemreductions::topology::SimpleGraph; + use serde_json::json; + let dir = std::env::temp_dir().join(format!("pred-completed-recovery-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("source.json"); + let route = dir.join("route.json"); + let bundle = dir.join("bundle.json"); + let result = dir.join("result.json"); + let variant = json!({"graph":"SimpleGraph", "weight":"i64"}); + std::fs::write( + &route, + json!({"path":[{ + "from":{"name":"DecisionMinimumVertexCover","variant":variant}, + "to":{"name":"MinimumVertexCover","variant":variant} + }]}) + .to_string(), + ) + .unwrap(); + for bound in [1, 2] { + let problem = Decision::new( + MinimumVertexCover::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![1i64; 3], + ), + bound, + ); + std::fs::write( + &input, + json!({"type":"DecisionMinimumVertexCover", "variant":variant,"data":problem}) + .to_string(), + ) + .unwrap(); + let reduced = pred() + .args([ + "reduce", + input.to_str().unwrap(), + "--via", + route.to_str().unwrap(), + "--aggregate", + "-o", + bundle.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + reduced.status.success(), + "{}", + String::from_utf8_lossy(&reduced.stderr) + ); + let solved = pred() + .args([ + "solve", + bundle.to_str().unwrap(), + "--solver", + "brute-force", + "--json", + ]) + .output() + .unwrap(); + assert!( + solved.status.success(), + "{}", + String::from_utf8_lossy(&solved.stderr) + ); + let solved: serde_json::Value = serde_json::from_slice(&solved.stdout).unwrap(); + let expected = if bound == 1 { "infeasible" } else { "optimal" }; + assert_eq!(solved["status"], expected); + + let numerical = pred() + .args([ + "solve", + bundle.to_str().unwrap(), + "--solver", + "ilp", + "--json", + ]) + .output() + .unwrap(); + assert_eq!( + numerical.status.success(), + bound == 2, + "{}", + String::from_utf8_lossy(&numerical.stderr) + ); + + for evaluation in [None, Some("Min(2)")] { + let mut external = json!({"status":"optimal","solution":[true,true,false],"problem":"MinimumVertexCover","solver":{"kind":"brute-force"}}); + if let Some(evaluation) = evaluation { + external["evaluation"] = json!(evaluation); + } + std::fs::write(&result, external.to_string()).unwrap(); + let extracted = pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--result", + result.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + extracted.status.success(), + "{}", + String::from_utf8_lossy(&extracted.stderr) + ); + let recovered: serde_json::Value = serde_json::from_slice(&extracted.stdout).unwrap(); + assert_eq!(recovered["status"], expected); + } + let value = pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--value", + "2", + "--json", + ]) + .output() + .unwrap(); + assert!( + value.status.success(), + "{}", + String::from_utf8_lossy(&value.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&value.stdout).unwrap(); + assert_eq!(value["aggregate"], json!(bound == 2)); + let candidate = pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--config", + "[true,true,false]", + "--json", + ]) + .output() + .unwrap(); + assert_eq!(candidate.status.success(), bound == 2); + } + for invalid in [ + json!({"status":"optimal", "solution":[true,true,false], "evaluation":"Min(99)"}), + json!({"status":"optimal", "solution":[true,true,false], "evaluation":99}), + json!({"status":"optimal", "solution":[true,true,false], "evaluation":null}), + json!({"status":"optimal", "solution":[false,false,false]}), + json!({"status":"timeout"}), + json!({"status":"infeasible", "evaluation":"Min(2)"}), + json!({"status":"optimal", "solution":[true,true,false], "solver":{"kind":"ilp"}}), + ] { + std::fs::write(&result, invalid.to_string()).unwrap(); + let output = pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--result", + result.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + } + for options in [ + vec![], + vec!["--value", "2", "--config", "[true,true,false]"], + vec!["--value", "true"], + ] { + let output = pred() + .args(["extract", bundle.to_str().unwrap()]) + .args(options) + .output() + .unwrap(); + assert!(!output.status.success()); + } + let sat = problemreductions::models::formula::Satisfiability::new( + 1, + vec![ + problemreductions::models::formula::CNFClause::new(vec![1]), + problemreductions::models::formula::CNFClause::new(vec![-1]), + ], + ); + std::fs::write( + &input, + json!({"type":"Satisfiability", "data":sat}).to_string(), + ) + .unwrap(); + std::fs::write( + &route, + json!({"path":[{ + "from":{"name":"Satisfiability","variant":{}}, + "to":{"name":"NAESatisfiability","variant":{}} + }]}) + .to_string(), + ) + .unwrap(); + let reduced = pred() + .args([ + "reduce", + input.to_str().unwrap(), + "--via", + route.to_str().unwrap(), + "-o", + bundle.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(reduced.status.success()); + std::fs::write(&result, json!({"status":"infeasible"}).to_string()).unwrap(); + let recovered = pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--result", + result.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + recovered.status.success(), + "{}", + String::from_utf8_lossy(&recovered.stderr) + ); + let recovered: serde_json::Value = serde_json::from_slice(&recovered.stdout).unwrap(); + assert_eq!(recovered["status"], "infeasible"); + assert!(recovered.get("solution").is_none()); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn test_extract_roundtrip_mis_to_qubo() { let problem_file = std::env::temp_dir().join("pred_test_extract_in.json"); diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 4bb5e88c0..5d7126796 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -531,6 +531,12 @@ fn generate_reduction_entry( quote! { None } }; + let aggregate_view_fn = if attrs.aggregate { + quote! { Some(crate::rules::aggregate_view::<<#source_type as crate::rules::ReduceTo<#target_type>>::Result>) } + } else { + quote! { None } + }; + // Collect generic parameter info from the impl block let type_generics = collect_type_generic_names(&impl_block.generics); @@ -580,6 +586,7 @@ fn generate_reduction_entry( Ok(Box::new(result)) }), reduce_aggregate_fn: #reduce_aggregate_fn, + aggregate_view_fn: #aggregate_view_fn, turing: false, } } @@ -940,6 +947,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result()?; Some(serde_json::to_value(p).expect("serialize failed")) }, + borrow_fn: |any| any.downcast_ref::<#ty>().map(|p| p as &dyn crate::registry::DynProblem), }; output.extend(quote! { diff --git a/src/models/decision.rs b/src/models/decision.rs index 1acb637ca..ce1f9849d 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -109,6 +109,9 @@ macro_rules! register_decision_variant { <$crate::models::decision::Decision<$inner> as $crate::rules::ReduceToAggregate<$inner>>::reduce_to_aggregate(source)?; Ok(Box::new(result)) }), + aggregate_view_fn: Some($crate::rules::aggregate_view::< + $crate::models::decision::DecisionToOptimizationResult<$inner> + >), turing: false, } } @@ -131,6 +134,7 @@ macro_rules! register_decision_variant { module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: true, } } @@ -322,7 +326,33 @@ where } } -/// Aggregate reduction result for `Decision

-> P`. +/// Witness and aggregate reduction result for `Decision

-> P`. +/// +/// An optimum value decides the bound; a witness is recovered only when its +/// value meets the bound. Both mappings use the same constructed target. +/// +/// ``` +/// use problemreductions::models::{Decision, MinimumVertexCover}; +/// use problemreductions::rules::{AggregateReductionResult, ReduceTo, ReductionResult}; +/// use problemreductions::solvers::BruteForce; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::types::Or; +/// +/// let cover = MinimumVertexCover::new( +/// SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), +/// vec![1_i64; 3], +/// ); +/// let source = Decision::new(cover, 1); +/// let reduction = ReduceTo::>::reduce_to(&source)?; +/// let (optimum, witnesses) = BruteForce::new() +/// .solve_with_witnesses(ReductionResult::target_problem(&reduction))?; +/// let answer = reduction.extract_value(optimum); +/// assert_eq!(answer, Or(false)); // Minimum cover size is 2, above the bound. +/// if answer.0 { +/// let source_witness = reduction.extract_solution(&witnesses[0])?; +/// } +/// # Ok::<(), Box>(()) +/// ``` #[derive(Debug, Clone)] pub struct DecisionToOptimizationResult

where @@ -368,21 +398,7 @@ where } } -/// Witness reduction result for `Decision

-> P`. -/// -/// The configuration spaces are identical — a config that is optimal for -/// `P` and meets the bound is a valid `Decision

` witness. The -/// `extract_solution` is the identity function. -#[derive(Debug, Clone)] -pub struct DecisionToOptimizationWitnessResult

-where - P: Problem, - P::Value: OptimizationValue, -{ - target: P, -} - -impl

ReductionResult for DecisionToOptimizationWitnessResult

+impl

ReductionResult for DecisionToOptimizationResult

where P: DecisionProblemMeta + 'static, P::Solution: Clone, @@ -399,7 +415,12 @@ where &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::validate_target_solution(self.target_problem(), target_solution)?; + let value = crate::rules::validate_target_solution(&self.target, target_solution)?; + if !self.extract_value(value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not meet the decision bound", + )); + } Ok(target_solution.clone()) } @@ -411,12 +432,10 @@ where P::Solution: Clone, P::Value: OptimizationValue + Serialize + DeserializeOwned, { - type Result = DecisionToOptimizationWitnessResult

; + type Result = DecisionToOptimizationResult

; fn reduce_to(&self) -> Result { - Ok(DecisionToOptimizationWitnessResult { - target: self.inner.clone(), - }) + self.reduce_to_aggregate() } } diff --git a/src/registry/dyn_problem.rs b/src/registry/dyn_problem.rs index e5938bdc4..e607d5c32 100644 --- a/src/registry/dyn_problem.rs +++ b/src/registry/dyn_problem.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use std::fmt; use crate::traits::{EvaluationError, Problem}; -use crate::types::SolutionAggregate; +use crate::types::{Aggregate, SolutionAggregate}; /// Format a metric for CLI- and registry-facing dynamic dispatch. /// @@ -21,6 +21,13 @@ where /// /// Implemented for serializable problems whose values support solution witnesses. pub trait DynProblem: Any { + /// Aggregate for an exhausted problem with no feasible witnesses. + fn empty_aggregate_json(&self) -> Result; + /// Whether a completed aggregate admits a representative witness. + fn aggregate_witness_evaluation( + &self, + value: &Value, + ) -> Result, EvaluationError>; /// Evaluate a configuration and return the CLI-facing metric string. fn evaluate_dyn(&self, solution: &Value) -> Result; /// Evaluate a candidate witness, returning `None` when it is infeasible. @@ -48,6 +55,23 @@ where T::Solution: serde::de::DeserializeOwned, T::Value: SolutionAggregate + fmt::Display + Serialize, { + fn empty_aggregate_json(&self) -> Result { + serde_json::to_value(T::Value::identity()).map_err(|error| { + EvaluationError::InvalidConfiguration(format!( + "cannot serialize aggregate identity: {error}" + )) + }) + } + + fn aggregate_witness_evaluation( + &self, + value: &Value, + ) -> Result, EvaluationError> { + let value: T::Value = serde::Deserialize::deserialize(value).map_err(|error| { + EvaluationError::InvalidConfiguration(format!("invalid aggregate JSON: {error}")) + })?; + Ok(T::Value::contributes_to_solution(&value, &value).then(|| format_metric(&value))) + } fn evaluate_dyn(&self, solution: &Value) -> Result { let solution = serde::Deserialize::deserialize(solution).map_err(|error| { EvaluationError::InvalidConfiguration(format!("invalid solution JSON: {error}")) @@ -59,7 +83,9 @@ where let solution = serde::Deserialize::deserialize(solution).map_err(|error| { EvaluationError::InvalidConfiguration(format!("invalid solution JSON: {error}")) })?; - Ok(serde_json::to_value(self.evaluate(&solution)?).expect("serialize metric failed")) + serde_json::to_value(self.evaluate(&solution)?).map_err(|error| { + EvaluationError::InvalidConfiguration(format!("cannot serialize evaluation: {error}")) + }) } fn evaluate_witness_dyn(&self, solution: &Value) -> Result, EvaluationError> { diff --git a/src/registry/variant.rs b/src/registry/variant.rs index 08f464b00..18a9df76d 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -254,6 +254,8 @@ pub struct VariantEntry { pub factory: fn(serde_json::Value) -> Result, serde_json::Error>, /// Serialize: downcast `&dyn Any` and serialize to JSON. pub serialize_fn: fn(&dyn Any) -> Option, + /// Borrow a registered concrete instance without serializing or cloning it. + pub borrow_fn: fn(&dyn Any) -> Option<&dyn DynProblem>, } impl VariantEntry { diff --git a/src/rules/graph.rs b/src/rules/graph.rs index f89ffbb49..658a52bc3 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -44,6 +44,7 @@ pub(crate) struct ReductionEdgeData { pub parameter_contract: Result, pub reduce_fn: Option, pub reduce_aggregate_fn: Option, + pub aggregate_view_fn: Option, pub turing: bool, } @@ -455,6 +456,7 @@ impl ReductionGraph { parameter_contract, reduce_fn: entry.reduce_fn, reduce_aggregate_fn: entry.reduce_aggregate_fn, + aggregate_view_fn: entry.aggregate_view_fn, turing: entry.turing, }, ); @@ -1535,9 +1537,116 @@ pub struct MatchedEntry { /// solution extraction back to the source problem space. pub struct ReductionChain { steps: Vec>, + aggregate_views: Vec>, + path: ReductionPath, } impl ReductionChain { + fn problem_at<'a>( + &'a self, + index: usize, + source: &'a dyn crate::registry::DynProblem, + ) -> crate::rules::ExtractionResult<&'a dyn crate::registry::DynProblem> { + if index == 0 { + return Ok(source); + } + let node = &self.path.steps[index]; + crate::registry::find_variant_entry(&node.name, &node.variant) + .and_then(|entry| (entry.borrow_fn)(self.steps[index - 1].target_problem_any())) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "cannot borrow executed problem {}", + node.name + )) + }) + } + + /// Recover a completed exact result through the executed witness/value mappings. + /// + /// The caller must establish target optimality or infeasibility; witness + /// evaluation alone cannot establish either. Numerical backend status must + /// not be passed here as an exact certificate. `source` must be the instance + /// used to execute this chain. Value-only problems use `AggregateReductionChain`. + /// A missing mapping is an error, not evidence of source infeasibility. + pub fn extract_result( + &self, + source: &dyn crate::registry::DynProblem, + target_result: &crate::solvers::SolveOutcome, + ) -> crate::rules::ExtractionResult { + use crate::rules::ExtractionError; + use crate::solvers::SolveOutcome; + if source.problem_name() != self.path.steps[0].name + || source.variant_map() != self.path.steps[0].variant + { + return Err(ExtractionError::invalid( + "source does not match the executed path", + )); + } + let target = self.problem_at(self.steps.len(), source)?; + let (mut witness, mut value) = match target_result { + SolveOutcome::Optimal { + solution, + evaluation, + } => { + let value = target.evaluate_json(solution)?; + let actual = target + .aggregate_witness_evaluation(&value)? + .ok_or_else(|| ExtractionError::invalid("target witness is infeasible"))?; + if evaluation != &actual { + return Err(ExtractionError::invalid( + "target evaluation does not match the witness", + )); + } + (Some(solution.clone()), value) + } + SolveOutcome::Infeasible => (None, target.empty_aggregate_json()?), + }; + let mut evaluation = None; + for index in (0..self.steps.len()).rev() { + let step = self.steps[index].as_ref(); + let input = self.problem_at(index, source)?; + let mapped = self.aggregate_views[index] + .map(|view| view(step)?.extract_value_dyn(value.clone())) + .transpose()?; + if let Some(mapped_value) = &mapped { + if input.aggregate_witness_evaluation(mapped_value)?.is_none() { + witness = None; + value = mapped_value.clone(); + evaluation = None; + continue; + } + } + let target_witness = witness.take().ok_or_else(|| { + ExtractionError::invalid(format!( + "{} -> {} cannot recover a source witness from this value-only result", + self.path.steps[index].name, + self.path.steps[index + 1].name, + )) + })?; + let typed = step.target_solution_from_json(target_witness)?; + let recovered = step.extract_solution_dyn(typed.as_ref())?; + let solution = step.source_solution_json(recovered.as_ref())?; + value = input.evaluate_json(&solution)?; + evaluation = Some( + input + .aggregate_witness_evaluation(&value)? + .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?, + ); + if mapped.is_some_and(|mapped| mapped != value) { + return Err(ExtractionError::invalid( + "extracted witness does not realize the mapped aggregate", + )); + } + witness = Some(solution); + } + Ok(match witness { + Some(solution) => SolveOutcome::Optimal { + solution, + evaluation: evaluation.expect("a recovered witness has an evaluation"), + }, + None => SolveOutcome::Infeasible, + }) + } /// Get the final target problem as a type-erased reference. pub fn target_problem_any(&self) -> &dyn Any { self.steps @@ -1611,11 +1720,14 @@ impl AggregateReductionChain { } /// Extract an aggregate value from target space back to source space. - pub fn extract_value_dyn(&self, target_value: serde_json::Value) -> serde_json::Value { + pub fn extract_value_dyn( + &self, + target_value: serde_json::Value, + ) -> crate::rules::ExtractionResult { self.steps .iter() .rev() - .fold(target_value, |value, step| step.extract_value_dyn(value)) + .try_fold(target_value, |value, step| step.extract_value_dyn(value)) } } @@ -1661,6 +1773,7 @@ impl ReductionGraph { } // Collect edge reduce_fns let mut edge_fns = Vec::new(); + let mut aggregate_views = Vec::new(); for window in path.steps.windows(2) { let Some(src) = self.lookup_node(&window[0].name, &window[0].variant) else { return Ok(None); @@ -1678,6 +1791,7 @@ impl ReductionGraph { return Ok(None); }; edge_fns.push(reduce); + aggregate_views.push(self.graph[edge_idx].aggregate_view_fn); } // Execute the chain let mut steps: Vec> = Vec::new(); @@ -1690,7 +1804,11 @@ impl ReductionGraph { }; steps.push(step); } - Ok(Some(ReductionChain { steps })) + Ok(Some(ReductionChain { + steps, + aggregate_views, + path: path.clone(), + })) } /// Execute an aggregate-value reduction path on a source problem instance. diff --git a/src/rules/minimumvertexcover_minimummaximalmatching.rs b/src/rules/minimumvertexcover_minimummaximalmatching.rs index 06819a13b..9338eae91 100644 --- a/src/rules/minimumvertexcover_minimummaximalmatching.rs +++ b/src/rules/minimumvertexcover_minimummaximalmatching.rs @@ -31,6 +31,7 @@ inventory::submit! { module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, } } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 2b7d66b7c..7c31b26ca 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -6,6 +6,8 @@ pub use registry::{ EdgeCapabilities, ParameterContractError, ReductionEntry, ReductionParameterContract, ReductionParameterDeclarations, UnavailableParameterField, }; +#[doc(hidden)] +pub use traits::aggregate_view; pub(crate) mod bicliquecover_bmf; pub(crate) mod bmf_bicliquecover; diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 80a10beb6..3289b9e91 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -140,6 +140,10 @@ pub type ReduceFn = pub type AggregateReduceFn = fn(&dyn Any) -> Result, crate::rules::ReductionError>; +/// Value mapping borrowed from an already constructed witness reduction. +pub type AggregateViewFn = + fn(&dyn DynReductionResult) -> crate::rules::ExtractionResult<&dyn DynAggregateReductionResult>; + /// Execution capabilities carried by a reduction edge. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct EdgeCapabilities { @@ -189,6 +193,8 @@ pub struct ReductionEntry { /// `ReduceToAggregate::reduce_to_aggregate()`, and returns either a boxed /// `DynAggregateReductionResult` or the edge's `ReductionError`. pub reduce_aggregate_fn: Option, + /// Shares the witness construction when both mappings are available. + pub aggregate_view_fn: Option, /// Whether this is a Turing (multi-query) reduction. pub turing: bool, } diff --git a/src/rules/satisfiability_naesatisfiability.rs b/src/rules/satisfiability_naesatisfiability.rs index 0be93eb62..d814c7853 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -33,16 +33,15 @@ impl ReductionResult for ReductionSATToNAESAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target assignment does not satisfy NAE clauses", + )); + } let n = self.source_num_vars; - if target_solution.len() != n + 1 { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} target truth values, got {}", - n + 1, - target_solution.len() - ))); - } let sentinel = target_solution[n]; Ok(target_solution[..n] .iter() @@ -51,7 +50,19 @@ impl ReductionResult for ReductionSATToNAESAT { } } +impl crate::rules::AggregateReductionResult for ReductionSATToNAESAT { + type Source = Satisfiability; + type Target = NAESatisfiability; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( + aggregate = identity, transform = exact { num_vars = "num_vars + 1", num_clauses = "num_clauses", diff --git a/src/rules/subsetsum_integerknapsack.rs b/src/rules/subsetsum_integerknapsack.rs index 1a244f968..b715ca836 100644 --- a/src/rules/subsetsum_integerknapsack.rs +++ b/src/rules/subsetsum_integerknapsack.rs @@ -42,6 +42,7 @@ inventory::submit! { module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, } } diff --git a/src/rules/traits.rs b/src/rules/traits.rs index c33c39754..0d50b78f2 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -333,6 +333,8 @@ impl> AggregateReductionResult /// Implemented automatically for all `ReductionResult` types via blanket impl. /// Used internally by `ReductionChain`. pub trait DynReductionResult { + /// Borrow the executed concrete result, including its optional value mapping. + fn as_any(&self) -> &dyn Any; /// Get the target problem as a type-erased reference. fn target_problem_any(&self) -> &dyn Any; /// Extract a solution from target space to source space. @@ -357,6 +359,9 @@ where ::Solution: 'static, ::Solution: serde::Serialize, { + fn as_any(&self) -> &dyn Any { + self + } fn target_problem_any(&self) -> &dyn Any { self.target_problem() as &dyn Any } @@ -398,19 +403,36 @@ where } } +/// Borrow the value mapping of the same result used for witness extraction. +pub fn aggregate_view( + result: &dyn DynReductionResult, +) -> ExtractionResult<&dyn DynAggregateReductionResult> +where + R: DynAggregateReductionResult + 'static, +{ + result + .as_any() + .downcast_ref::() + .map(|result| result as &dyn DynAggregateReductionResult) + .ok_or_else(|| ExtractionError::invalid("executed reduction type mismatch")) +} + /// Type-erased aggregate reduction result for runtime-discovered paths. pub trait DynAggregateReductionResult { /// Get the target problem as a type-erased reference. fn target_problem_any(&self) -> &dyn Any; /// Extract an aggregate value from target space to source space. - fn extract_value_dyn(&self, target_value: serde_json::Value) -> serde_json::Value; - /// Map the value of a target solution without erasing the source value's type. + fn extract_value_dyn( + &self, + target_value: serde_json::Value, + ) -> ExtractionResult; + /// Map the value of a target solution to a serialized source aggregate. /// The caller must establish that the solution realizes the target aggregate /// before interpreting the result as the source aggregate. fn extract_value_from_solution_dyn( &self, target_solution: &dyn Any, - ) -> ExtractionResult>; + ) -> ExtractionResult; } impl DynAggregateReductionResult for R @@ -424,18 +446,23 @@ where self.target_problem() as &dyn Any } - fn extract_value_dyn(&self, target_value: serde_json::Value) -> serde_json::Value { - let target_value = serde_json::from_value(target_value) - .expect("DynAggregateReductionResult target value deserialize failed"); + fn extract_value_dyn( + &self, + target_value: serde_json::Value, + ) -> ExtractionResult { + let target_value = serde_json::from_value(target_value).map_err(|error| { + ExtractionError::invalid(format!("target aggregate deserialization failed: {error}")) + })?; let source_value = self.extract_value(target_value); - serde_json::to_value(source_value) - .expect("DynAggregateReductionResult source value serialize failed") + serde_json::to_value(source_value).map_err(|error| { + ExtractionError::invalid(format!("source aggregate serialization failed: {error}")) + }) } fn extract_value_from_solution_dyn( &self, target_solution: &dyn Any, - ) -> ExtractionResult> { + ) -> ExtractionResult { let target_solution = target_solution .downcast_ref::<::Solution>() .ok_or_else(|| { @@ -445,10 +472,12 @@ where )) })?; let target_value = self.target_problem().evaluate(target_solution)?; - Ok(Box::new(self.extract_value(target_value))) + serde_json::to_value(self.extract_value(target_value)).map_err(|error| { + ExtractionError::invalid(format!("source aggregate serialization failed: {error}")) + }) } } #[cfg(test)] #[path = "../unit_tests/rules/traits.rs"] -mod tests; +pub(crate) mod tests; diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index c044e3d36..9521d0c8e 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -1,7 +1,7 @@ //! Deterministic solver capabilities for exact problem variants. use crate::registry::VariantEntry; -use crate::rules::registry::{reduction_entries, AggregateReduceFn, ReduceFn, ReductionEntry}; +use crate::rules::registry::{reduction_entries, AggregateViewFn, ReduceFn, ReductionEntry}; use crate::rules::DynReductionResult; use serde::Serialize; use std::any::Any; @@ -103,7 +103,7 @@ inventory::collect!(CustomizedSolverRegistration); #[derive(Debug)] pub(crate) struct CompiledIlpPipeline { path: Vec, - reducers: Vec<(ReduceFn, Option)>, + reducers: Vec<(ReduceFn, Option)>, } impl CompiledIlpPipeline { @@ -144,17 +144,29 @@ impl CompiledIlpPipeline { let solution = solver.solve_dyn(target)?; let mut source_solution: Box = Box::new(solution); for (index, step) in reductions.iter().enumerate().rev() { - if let Some(reduce) = self.reducers[index].1 { + if let Some(view) = self.reducers[index].1 { let input = if index == 0 { source } else { reductions[index - 1].target_problem_any() }; - let aggregate = reduce(input)?; + let aggregate = view(step.as_ref())?; // A numerical target optimum can establish YES through a source witness, // but a missed threshold alone cannot establish NO. let value = aggregate.extract_value_from_solution_dyn(source_solution.as_ref())?; - if value.downcast_ref::() == Some(&crate::types::Or(false)) { + let source = crate::registry::find_variant_entry( + &self.path[index].name, + &self.path[index].variant, + ) + .and_then(|entry| (entry.borrow_fn)(input)) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid("pipeline source type mismatch") + })?; + if source + .aggregate_witness_evaluation(&value) + .map_err(crate::rules::ExtractionError::from)? + .is_none() + { return Err(super::ILPSolveError::UnresolvedDecision( self.path[index].label(), )); @@ -415,7 +427,7 @@ fn build_registry( matches[0] .reduce_fn .expect("indexed only entries with reduce_fn"), - matches[0].reduce_aggregate_fn, + matches[0].aggregate_view_fn, )); } diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs index 9bce00a5b..dd3562f5f 100644 --- a/src/unit_tests/models/decision.rs +++ b/src/unit_tests/models/decision.rs @@ -196,6 +196,72 @@ fn test_decision_reduce_to_aggregate_infeasible_bound() { } } +#[test] +fn decision_reduction_recovers_answers_and_all_tied_witnesses() { + use crate::rules::{AggregateReductionResult, ReduceTo, ReductionResult}; + use crate::types::Min; + + for bound in [1, 2] { + let source = Decision::new(triangle_mvc(), bound); + let reduction = + ReduceTo::>::reduce_to(&source).unwrap(); + let (optimum, witnesses) = BruteForce::new() + .solve_with_witnesses(ReductionResult::target_problem(&reduction)) + .unwrap(); + assert_eq!(optimum, Min(Some(2))); + assert_eq!(reduction.extract_value(optimum), Or(bound == 2)); + assert_eq!(witnesses.len(), 3); + for witness in witnesses { + let recovered = reduction.extract_solution(&witness); + if bound == 2 { + assert_eq!(source.evaluate(&recovered.unwrap()), Ok(Or(true))); + } else { + assert!(matches!( + recovered, + Err(crate::rules::ExtractionError::InvalidTargetSolution(_)) + )); + } + } + } +} + +#[test] +fn decision_reduction_rejects_invalid_and_insufficient_witnesses() { + use crate::rules::{ReduceTo, ReductionResult}; + + let source = Decision::new(triangle_mvc(), 2); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + for witness in [vec![true, false, false], vec![true, true, true]] { + assert!(matches!( + reduction.extract_solution(&witness), + Err(crate::rules::ExtractionError::InvalidTargetSolution(_)) + )); + } + assert!(matches!( + reduction.extract_solution(&vec![true]), + Err(crate::rules::ExtractionError::Evaluation( + crate::traits::EvaluationError::InvalidConfiguration(_) + )) + )); + + let source = Decision::new( + MaximumIndependentSet::new(SimpleGraph::path(3), vec![1_i64; 3]), + 2, + ); + let reduction = + ReduceTo::>::reduce_to(&source).unwrap(); + assert_eq!( + reduction + .extract_solution(&vec![true, false, true]) + .unwrap(), + vec![true, false, true] + ); + assert!(matches!( + reduction.extract_solution(&vec![false, true, false]), + Err(crate::rules::ExtractionError::InvalidTargetSolution(_)) + )); +} + #[test] fn test_decision_mds_creation() { let mds = star_mds(); @@ -333,12 +399,8 @@ fn test_decision_mis_unit_dynamic_identity_edges() { )); let aggregate = (edge.reduce_aggregate_fn.unwrap())(&decision).unwrap(); assert_eq!( - *aggregate - .extract_value_from_solution_dyn(&witness) - .unwrap() - .downcast::() - .unwrap(), - Or(true) + aggregate.extract_value_from_solution_dyn(&witness).unwrap(), + serde_json::json!(true) ); assert!(matches!( (edge.reduce_aggregate_fn.unwrap())(decision.inner()), diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 6595c0007..a2b6ec805 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -931,12 +931,8 @@ fn test_decision_minimum_dominating_set_to_minmax_multicenter_has_direct_witness ); let aggregate = (edge.reduce_aggregate_fn.unwrap())(&source).unwrap(); assert_eq!( - *aggregate - .extract_value_from_solution_dyn(&witness) - .unwrap() - .downcast::() - .unwrap(), - Or(expected) + aggregate.extract_value_from_solution_dyn(&witness).unwrap(), + serde_json::json!(expected) ); } } @@ -1087,6 +1083,7 @@ fn test_find_paths_bounded_returns_shortest_when_truncated() { ), reduce_fn: Some(reduce), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, } } diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index df044287b..8da900d22 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -51,6 +51,7 @@ fn symbolic_size_edge(fields: &[(&'static str, &str)], turing: bool) -> Reductio ), reduce_fn: Some(|_| panic!("size search must not execute reductions")), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing, } } @@ -67,6 +68,266 @@ fn named_path(names: &[&str]) -> ReductionPath { } } +fn problem_step() -> ReductionStep { + ReductionStep { + name: P::NAME.into(), + variant: ReductionGraph::variant_to_map(&P::variant()), + } +} + +#[test] +fn completed_decision_recovery_shares_construction_and_handles_both_answers() { + use crate::models::Decision; + use crate::solvers::{BruteForce, SolveOutcome}; + type Cover = MinimumVertexCover; + let graph = ReductionGraph::new(); + let path = ReductionPath { + steps: vec![problem_step::>(), problem_step::()], + }; + for bound in [1, 2] { + let source = Decision::new( + Cover::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![1; 3], + ), + bound, + ); + let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + let step = chain.steps[0].as_ref(); + assert!( + crate::rules::aggregate_view::>( + step + ) + .is_err() + ); + let aggregate = chain.aggregate_views[0].unwrap()(step).unwrap(); + assert!(std::ptr::eq( + step.target_problem_any().downcast_ref::().unwrap(), + aggregate + .target_problem_any() + .downcast_ref::() + .unwrap(), + )); + let target = chain.target_problem::(); + for solution in BruteForce::new().find_all_witnesses(target).unwrap() { + let result = SolveOutcome::Optimal { + evaluation: target.evaluate(&solution).unwrap().to_string(), + solution: json!(solution), + }; + let recovered = chain.extract_result(&source, &result).unwrap(); + if bound == 1 { + assert_eq!(recovered, SolveOutcome::Infeasible); + } else { + assert!( + matches!(recovered, SolveOutcome::Optimal { evaluation, .. } if evaluation == "Or(true)") + ); + } + } + } +} + +#[test] +fn completed_recovery_propagates_value_only_results_through_multiple_mappings() { + use crate::models::formula::CNFClause; + use crate::solvers::{BruteForce, SolveOutcome}; + let graph = ReductionGraph::new(); + for unsatisfiable in [false, true] { + let clauses = if unsatisfiable { + vec![vec![1], vec![-1]] + } else { + vec![vec![1]] + }; + let source = Satisfiability::new(1, clauses.into_iter().map(CNFClause::new).collect()); + let decision_path = ReductionPath { + steps: vec![ + problem_step::(), + problem_step::(), + ], + }; + let decision_chain = graph + .reduce_along_path(&decision_path, &source) + .unwrap() + .unwrap(); + let nae = decision_chain.target_problem::(); + assert!(decision_chain + .extract_solution_json(json!([false, false])) + .is_err()); + let values = graph + .reduce_aggregate_along_path(&decision_path, &source) + .unwrap() + .unwrap(); + assert_eq!( + values.target_problem::().num_vars(), + nae.num_vars() + ); + let target_result = match BruteForce::new().solve(nae).unwrap() { + Some(solution) => SolveOutcome::Optimal { + evaluation: nae.evaluate(&solution).unwrap().to_string(), + solution: json!(solution), + }, + None => SolveOutcome::Infeasible, + }; + let expected = decision_chain + .extract_result(&source, &target_result) + .unwrap(); + assert_eq!(matches!(expected, SolveOutcome::Infeasible), unsatisfiable); + + let path = ReductionPath { + steps: vec![ + problem_step::(), + problem_step::(), + problem_step::>(), + ], + }; + let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + let target = chain.target_problem::>(); + for solution in BruteForce::new().find_all_witnesses(target).unwrap() { + let result = SolveOutcome::Optimal { + evaluation: target.evaluate(&solution).unwrap().to_string(), + solution: json!(solution), + }; + let recovered = chain.extract_result(&source, &result).unwrap(); + assert_eq!(matches!(recovered, SolveOutcome::Infeasible), unsatisfiable); + if let SolveOutcome::Optimal { solution, .. } = recovered { + assert_eq!( + source + .evaluate(&serde_json::from_value(solution).unwrap()) + .unwrap(), + crate::types::Or(true) + ); + } + } + } +} + +#[test] +fn completed_optimization_recovery_validates_results_and_requires_value_mappings_for_absence() { + use crate::solvers::{BruteForce, SolveOutcome}; + type Independent = MaximumIndependentSet; + type Cover = MinimumVertexCover; + let source = Independent::new(SimpleGraph::path(3), vec![1; 3]); + let path = ReductionPath { + steps: vec![problem_step::(), problem_step::()], + }; + let chain = ReductionGraph::new() + .reduce_along_path(&path, &source) + .unwrap() + .unwrap(); + let target = chain.target_problem::(); + let solution = BruteForce::new().solve(target).unwrap().unwrap(); + let result = SolveOutcome::Optimal { + evaluation: target.evaluate(&solution).unwrap().to_string(), + solution: json!(solution), + }; + assert!( + matches!(chain.extract_result(&source, &result).unwrap(), SolveOutcome::Optimal { evaluation, .. } if evaluation == "Max(2)") + ); + assert!(chain + .extract_result(&source, &SolveOutcome::Infeasible) + .is_err()); + assert!(chain.extract_result(target, &result).is_err()); + for (solution, evaluation) in [ + (json!([true]), "Min(1)"), + (json!([false, false, false]), "Min(None)"), + (json!([false, true, false]), "Min(999)"), + ] { + assert!(chain + .extract_result( + &source, + &SolveOutcome::Optimal { + solution, + evaluation: evaluation.into() + } + ) + .is_err()); + } +} + +#[test] +fn completed_recovery_rejects_inconsistent_value_and_witness_mappings() { + use crate::rules::VariantReductionResult; + use crate::solvers::SolveOutcome; + type Cover = MinimumVertexCover; + let source = Cover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1; 2]); + let target = Cover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![2; 2]); + let chain = ReductionChain { + steps: vec![Box::new(VariantReductionResult::::new( + target, + ))], + aggregate_views: vec![Some( + crate::rules::aggregate_view::>, + )], + path: ReductionPath { + steps: vec![problem_step::(), problem_step::()], + }, + }; + let error = chain + .extract_result( + &source, + &SolveOutcome::Optimal { + solution: json!([true, false]), + evaluation: "Min(2)".into(), + }, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("does not realize the mapped aggregate")); + let problem: &dyn crate::registry::DynProblem = &source; + assert!(problem.aggregate_witness_evaluation(&json!(true)).is_err()); +} + +#[test] +fn counting_and_universal_values_compose_without_witness_recovery() { + use crate::rules::traits::tests::{ + CountingOrCircuit, CountingTseitinFormula, UniversalFormula, + }; + use crate::rules::{ReduceToAggregate, VariantReductionResult}; + use crate::solvers::BruteForce; + let source = CountingOrCircuit; + let first = source.reduce_to_aggregate().unwrap(); + let second = VariantReductionResult::::new( + first.target_problem().clone(), + ); + let chain = AggregateReductionChain { + steps: vec![Box::new(first), Box::new(second)], + }; + let count = BruteForce::new() + .solve_cartesian(chain.target_problem::(), |bits| { + bits + }) + .unwrap(); + assert_eq!(count, Sum(3)); + assert_eq!( + chain + .extract_value_dyn(serde_json::to_value(count).unwrap()) + .unwrap(), + json!(3) + ); + + for tautology in [false, true] { + let source = UniversalFormula { + variable: 0, + tautology, + }; + let first = source.reduce_to_aggregate().unwrap(); + let second = first.target_problem().reduce_to_aggregate().unwrap(); + let chain = AggregateReductionChain { + steps: vec![Box::new(first), Box::new(second)], + }; + let value = BruteForce::new() + .solve_cartesian(chain.target_problem::(), |bits| bits) + .unwrap(); + assert_eq!( + chain + .extract_value_dyn(serde_json::to_value(value).unwrap()) + .unwrap(), + json!(tautology) + ); + assert!(chain.extract_value_dyn(json!(123)).is_err()); + } +} + #[derive(Clone)] struct AggregateChainSource; @@ -397,6 +658,7 @@ fn execute_paths_executes_a_shared_prefix_once() { parameter_contract: empty_parameter_contract(), reduce_fn: Some(reduce_fn), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, }; let graph = ReductionGraph::from_test_edges( @@ -471,6 +733,7 @@ fn path_parameter_contract_errors_are_typed_and_isolated() { parameter_contract: empty_parameter_contract(), reduce_fn: Some(|_| panic!("metadata inspection must not execute reductions")), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, }, ); @@ -492,6 +755,7 @@ fn path_parameter_contract_errors_are_typed_and_isolated() { parameter_contract: invalid_contract, reduce_fn: Some(|_| panic!("metadata inspection must not execute reductions")), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, }, ); @@ -646,6 +910,7 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { parameter_contract: empty_parameter_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), + aggregate_view_fn: None, turing: false, }, ); @@ -656,6 +921,7 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { parameter_contract: empty_parameter_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_middle_to_target_aggregate), + aggregate_view_fn: None, turing: false, }, ); @@ -696,7 +962,8 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { chain.target_problem::().dimensions(), vec![1] ); - assert_eq!(chain.extract_value_dyn(json!(7)), json!(12)); + assert_eq!(chain.extract_value_dyn(json!(7)).unwrap(), json!(12)); + assert!(chain.extract_value_dyn(json!("not an aggregate")).is_err()); } #[test] @@ -712,6 +979,7 @@ fn witness_path_search_rejects_aggregate_only_edge() { parameter_contract: empty_parameter_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), + aggregate_view_fn: None, turing: false, }, ); @@ -749,6 +1017,7 @@ fn aggregate_path_search_rejects_witness_only_edge() { parameter_contract: empty_parameter_contract(), reduce_fn: Some(reduce_source_to_middle_witness), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, }, ); @@ -786,6 +1055,7 @@ fn witness_executor_does_not_imply_aggregate_capability() { parameter_contract: empty_parameter_contract(), reduce_fn: Some(reduce_natural_variant_witness), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, }, ); @@ -822,6 +1092,7 @@ fn reduce_aggregate_along_path_rejects_single_step_path() { parameter_contract: empty_parameter_contract(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), + aggregate_view_fn: None, turing: false, }, ); @@ -850,6 +1121,7 @@ fn reduce_aggregate_returns_none_for_witness_only_edge() { parameter_contract: empty_parameter_contract(), reduce_fn: Some(reduce_source_to_middle_witness), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, }, ); @@ -884,6 +1156,7 @@ fn reduce_along_path_preserves_edge_failure() { parameter_contract: empty_parameter_contract(), reduce_fn: Some(fail_source_to_middle_witness), reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, }, ); diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs index 466d811f9..a434e2b90 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -284,7 +284,9 @@ fn test_hamiltoniancircuit_to_quadraticassignment_registered_aggregate_path() { let best = BruteForce::new().solve(target).unwrap().unwrap(); let optimum = target.evaluate(&best).unwrap(); assert_eq!( - chain.extract_value_dyn(serde_json::to_value(optimum).unwrap()), + chain + .extract_value_dyn(serde_json::to_value(optimum).unwrap()) + .unwrap(), serde_json::to_value(Or(expected)).unwrap(), ); assert_eq!( diff --git a/src/unit_tests/rules/ksatisfiability_qubo.rs b/src/unit_tests/rules/ksatisfiability_qubo.rs index 22aad0944..569e16edb 100644 --- a/src/unit_tests/rules/ksatisfiability_qubo.rs +++ b/src/unit_tests/rules/ksatisfiability_qubo.rs @@ -294,7 +294,6 @@ fn test_sat_qubo_checked_numeric_boundaries() { #[test] fn test_sat_qubo_registered_aggregate_threshold() { - use crate::types::Or; macro_rules! check { ($k:ty) => { for (clauses, expected) in [(vec![vec![1]], true), (vec![vec![1], vec![-1]], false)] { @@ -317,12 +316,8 @@ fn test_sat_qubo_registered_aggregate_threshold() { .unwrap(); let aggregate = (edge.reduce_aggregate_fn.unwrap())(&source).unwrap(); assert_eq!( - *aggregate - .extract_value_from_solution_dyn(&witness) - .unwrap() - .downcast::() - .unwrap(), - Or(expected) + aggregate.extract_value_from_solution_dyn(&witness).unwrap(), + serde_json::json!(expected) ); } }; diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index b05d19fa9..7259ae0eb 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -11,6 +11,7 @@ fn entry_with(declarations: fn() -> ReductionParameterDeclarations) -> Reduction module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, } } diff --git a/src/unit_tests/rules/traits.rs b/src/unit_tests/rules/traits.rs index bfc64c583..dde9e5df0 100644 --- a/src/unit_tests/rules/traits.rs +++ b/src/unit_tests/rules/traits.rs @@ -7,6 +7,7 @@ use crate::rules::traits::{ validate_target_solution, AggregateReductionResult, DynAggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionResult, }; +use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Sum; use serde_json::json; @@ -132,7 +133,6 @@ fn aggregate_value_from_solution_keeps_evaluation_errors_distinct_from_false() { use crate::models::graph::MinimumVertexCover; use crate::rules::ExtractionError; use crate::topology::SimpleGraph; - use crate::types::Or; let source = Decision::new( MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i64; 2]), @@ -142,7 +142,7 @@ fn aggregate_value_from_solution_keeps_evaluation_errors_distinct_from_false() { let value = reduction .extract_value_from_solution_dyn(&vec![true, false]) .unwrap(); - assert_eq!(value.downcast_ref::(), Some(&Or(false))); + assert_eq!(value, json!(false)); assert!(matches!( reduction.extract_value_from_solution_dyn(&vec![true]), Err(ExtractionError::Evaluation(_)) @@ -274,5 +274,237 @@ fn test_dyn_aggregate_reduction_result_extracts_value() { .target_problem_any() .downcast_ref::() .is_some()); - assert_eq!(dyn_result.extract_value_dyn(json!(7)), json!(9)); + assert_eq!(dyn_result.extract_value_dyn(json!(7)).unwrap(), json!(9)); + assert!(matches!( + dyn_result.extract_value_dyn(json!("not a count")), + Err(crate::rules::ExtractionError::InvalidTargetSolution(_)) + )); +} + +#[derive(Clone)] +struct UnserializableValue; + +impl serde::Serialize for UnserializableValue { + fn serialize(&self, _: S) -> Result { + Err(serde::ser::Error::custom("aggregate cannot be serialized")) + } +} + +impl Problem for UnserializableValue { + const NAME: &'static str = "UnserializableValue"; + type Solution = (); + type Value = Self; + fn parameter_names() -> &'static [&'static str] { + &[] + } + fn parameters(&self) -> crate::types::ProblemParameters { + Default::default() + } + fn evaluate(&self, _: &()) -> Result { + Ok(self.clone()) + } + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + +struct UnserializableReduction(AggregateTargetProblem); + +impl AggregateReductionResult for UnserializableReduction { + type Source = UnserializableValue; + type Target = AggregateTargetProblem; + fn target_problem(&self) -> &Self::Target { + &self.0 + } + fn extract_value(&self, _: Sum) -> UnserializableValue { + UnserializableValue + } +} + +#[test] +fn dynamic_aggregate_serialization_failure_returns_an_error() { + let reduction = UnserializableReduction(AggregateTargetProblem); + assert!(reduction + .extract_value_from_solution_dyn(&vec![0usize]) + .is_err()); + let error = reduction.extract_value_dyn(json!(0)).unwrap_err(); + assert!(matches!( + error, + crate::rules::ExtractionError::InvalidTargetSolution(_) + )); + assert!(error + .to_string() + .contains("source aggregate serialization failed")); +} + +#[derive(Clone)] +pub(crate) struct CountingOrCircuit; + +#[derive(Clone)] +pub(crate) struct CountingTseitinFormula; + +impl Problem for CountingOrCircuit { + const NAME: &'static str = "CountingOrCircuit"; + type Solution = Vec; + type Value = Sum; + crate::problem_parameters![("num_variables", num_variables)]; + + fn evaluate( + &self, + bits: &Self::Solution, + ) -> Result { + Ok(Sum(u64::from(bits[0] != 0 || bits[1] != 0))) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + +impl crate::solvers::BruteForceProblem for CountingOrCircuit { + fn dimensions(&self) -> Vec { + vec![2; 2] + } +} + +impl Problem for CountingTseitinFormula { + const NAME: &'static str = "CountingTseitinFormula"; + type Solution = Vec; + type Value = Sum; + crate::problem_parameters![("num_variables", num_variables)]; + + fn evaluate( + &self, + bits: &Self::Solution, + ) -> Result { + let (x, y, z) = (bits[0] != 0, bits[1] != 0, bits[2] != 0); + // z <=> (x OR y), with output z asserted. + let clauses = [!x || z, !y || z, x || y || !z, z]; + Ok(Sum(u64::from(clauses.into_iter().all(|clause| clause)))) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + +impl crate::solvers::BruteForceProblem for CountingTseitinFormula { + fn dimensions(&self) -> Vec { + vec![2; 3] + } +} + +pub(crate) struct CountingTseitinReduction(CountingTseitinFormula); + +impl AggregateReductionResult for CountingTseitinReduction { + type Source = CountingOrCircuit; + type Target = CountingTseitinFormula; + fn target_problem(&self) -> &Self::Target { + &self.0 + } + fn extract_value(&self, count: Sum) -> Sum { + count + } +} + +impl ReduceToAggregate for CountingOrCircuit { + type Result = CountingTseitinReduction; + fn reduce_to_aggregate(&self) -> Result { + Ok(CountingTseitinReduction(CountingTseitinFormula)) + } +} + +#[test] +fn counting_reduction_preserves_the_number_of_satisfying_assignments() { + use crate::solvers::BruteForce; + let source = CountingOrCircuit; + let reduction = source.reduce_to_aggregate().unwrap(); + let solver = BruteForce::new(); + let source_count = solver.solve_cartesian(&source, |bits| bits).unwrap(); + let target_count = solver + .solve_cartesian(reduction.target_problem(), |bits| bits) + .unwrap(); + assert_eq!(source_count, Sum(3)); + assert_eq!(target_count, Sum(3)); + assert_eq!(reduction.extract_value(target_count), source_count); + assert_eq!(reduction.extract_value_dyn(json!(3)).unwrap(), json!(3)); +} + +#[derive(Clone)] +pub(crate) struct UniversalFormula { + pub(crate) variable: usize, + pub(crate) tautology: bool, +} + +impl Problem for UniversalFormula { + const NAME: &'static str = "UniversalFormula"; + type Solution = Vec; + type Value = crate::types::And; + crate::problem_parameters![("num_variables", num_variables)]; + + fn evaluate( + &self, + bits: &Self::Solution, + ) -> Result { + // x OR NOT x when tautology is true; otherwise just x. + let x = bits[self.variable] != 0; + Ok(crate::types::And(self.tautology || x)) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + +impl crate::solvers::BruteForceProblem for UniversalFormula { + fn dimensions(&self) -> Vec { + vec![2; 2] + } +} + +pub(crate) struct RenameUniversalVariable(UniversalFormula); + +impl AggregateReductionResult for RenameUniversalVariable { + type Source = UniversalFormula; + type Target = UniversalFormula; + fn target_problem(&self) -> &Self::Target { + &self.0 + } + fn extract_value(&self, value: crate::types::And) -> crate::types::And { + value + } +} + +impl ReduceToAggregate for UniversalFormula { + type Result = RenameUniversalVariable; + fn reduce_to_aggregate(&self) -> Result { + Ok(RenameUniversalVariable(Self { + variable: 1 - self.variable, + tautology: self.tautology, + })) + } +} + +#[test] +fn universal_reduction_preserves_true_and_false_aggregates_without_witnesses() { + use crate::solvers::BruteForce; + for tautology in [false, true] { + let source = UniversalFormula { + variable: 0, + tautology, + }; + let reduction = source.reduce_to_aggregate().unwrap(); + let solver = BruteForce::new(); + let expected = solver.solve_cartesian(&source, |bits| bits).unwrap(); + let target_value = solver + .solve_cartesian(reduction.target_problem(), |bits| bits) + .unwrap(); + assert_eq!(expected, crate::types::And(tautology)); + assert_eq!(reduction.extract_value(target_value), expected); + assert_eq!( + reduction.extract_value_dyn(json!(tautology)).unwrap(), + json!(tautology) + ); + assert!(reduction.extract_value_dyn(json!("not a Boolean")).is_err()); + } } diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 97c55d085..449a91154 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -75,19 +75,19 @@ fn generic_decision_ilp_respects_maximization_bounds() { fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { use crate::models::decision::Decision; use crate::models::graph::MinimumVertexCover; - use crate::rules::{ExtractionError, ReductionResult}; + use crate::rules::{AggregateReductionResult, ExtractionError, ReductionResult}; use crate::solvers::{ILPSolveError, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; type Inner = MinimumVertexCover; - struct BrokenExtractor(Inner); + struct BrokenExtractor(Decision); impl ReductionResult for BrokenExtractor { type Source = Decision; type Target = Inner; fn target_problem(&self) -> &Inner { - &self.0 + self.0.inner() } fn extract_solution(&self, _: &Vec) -> crate::rules::ExtractionResult> { @@ -95,6 +95,20 @@ fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { } } + impl AggregateReductionResult for BrokenExtractor { + type Source = Decision; + type Target = Inner; + + fn target_problem(&self) -> &Inner { + self.0.inner() + } + + fn extract_value(&self, value: ::Value) -> crate::types::Or { + use crate::types::OptimizationValue; + crate::types::Or(OptimizationValue::meets_bound(&value, self.0.bound())) + } + } + let source = ExactProblemKey::new( Decision::::NAME, Decision::::variant() @@ -110,8 +124,9 @@ fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { }; pipeline.reducers[0].0 = |source| { let source = source.downcast_ref::>().unwrap(); - Ok(Box::new(BrokenExtractor(source.inner().clone()))) + Ok(Box::new(BrokenExtractor(source.clone()))) }; + pipeline.reducers[0].1 = Some(crate::rules::aggregate_view::); let inner = Inner::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i64; 2]); assert!(matches!( pipeline.solve(&Decision::new(inner.clone(), 0), &ILPSolver::new()), From 6c16bdabef0af1b053ba9a6eab7f4c3d9abc774b Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 18 Sep 2026 23:46:53 +0800 Subject: [PATCH 02/44] Keep reduction chains outcome-independent and fix reduction correctness --- .claude/CLAUDE.md | 2 +- docs/paper/reductions.typ | 32 +- problemreductions-cli/src/dispatch.rs | 282 +++++++++++++++++- .../graph/length_bounded_disjoint_paths.rs | 90 +++--- src/models/graph/minimum_multiway_cut.rs | 97 +++--- ...onminimumvertexcover_hamiltoniancircuit.rs | 85 +++--- src/rules/graph.rs | 125 ++------ .../ksatisfiability_preemptivescheduling.rs | 86 ++++-- src/rules/minimummultiwaycut_qubo.rs | 33 +- ...fiability_partitionintoperfectmatchings.rs | 112 +++++-- .../graph/length_bounded_disjoint_paths.rs | 20 ++ .../models/graph/minimum_multiway_cut.rs | 16 + ...onminimumvertexcover_hamiltoniancircuit.rs | 59 ++++ src/unit_tests/rules/graph.rs | 189 ++++-------- .../hamiltoniancircuit_quadraticassignment.rs | 2 +- .../ksatisfiability_preemptivescheduling.rs | 127 +++++++- .../rules/minimummultiwaycut_qubo.rs | 38 +++ ...fiability_partitionintoperfectmatchings.rs | 84 +++++- 18 files changed, 1003 insertions(+), 476 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b313c103c..47c94bc60 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -166,7 +166,7 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `BruteForce::solve()` returns `Result, SolveError>`; `None` means exhaustive search proved infeasibility - `BruteForce::find_all_witnesses()` is a reference-testing helper for collecting every optimal or satisfying solution - `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows. Neither requires a rule-category tag. When both are registered, completed-result recovery borrows both mappings from the same constructed reduction. -- `ReductionChain::extract_result()` consumes a completed exact target result; callers must establish optimality or infeasibility. A missing mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value_dyn()` without a representative witness. +- Reduction chains expose solution and aggregate-value mappings, not solver outcomes. CLI execution coordinates those mappings when recovering a completed exact target result; callers must establish optimality or infeasibility. A missing mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. - Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. - Decode only the reduction's defined mathematical mapping. Reject malformed structure with `ExtractionError`; never panic, truncate, clamp, invent defaults, or add recovery branches. Explicit mathematical alternatives and sentinels are allowed. Test successful decoding and every rejected representation. - CLI-facing dynamic formatting uses aggregate wrapper names directly (for example `Max(2)`, `Min(None)`, `Or(true)`, or `Sum(56)`) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 1524f98b8..4b6f4f18d 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -12625,9 +12625,9 @@ where $P$ is a penalty weight large enough that any constraint violation costs m *Step 6 -- Verify a solution.* The QUBO ground state $bold(x) = (#fmt-values(mwc_qubo_sol.target_config))$ decodes to the partition: vertex 0 in component 0, vertices 1--3 in component 1, vertex 4 in component 2. Cut edges: $\{#mwc_qubo_cut_indices.map(i => "(" + str(mwc_qubo_edges.at(i).at(0)) + "," + str(mwc_qubo_edges.at(i).at(1)) + ")").join(", ")\}$ with total weight #mwc_qubo_cut_indices.map(i => str(mwc_qubo_weights.at(i))).join(" + ") $= #mwc_qubo_cut_cost$ #sym.checkmark. ], )[ - The multiway cut problem requires a partition of vertices into $k$ components — one per terminal — minimizing the total weight of edges crossing components. The penalty method (@sec:penalty-method) encodes two constraints as QUBO penalties: (1) each vertex belongs to exactly one component (one-hot), and (2) each terminal is pinned to its own component. The cut-cost Hamiltonian counts edge weight across distinct components. Reference: @Heidari2022. + The multiway cut problem minimizes the weight of deleted edges separating all terminals. Every negative-weight edge is deleted first; the remaining nonnegative-cost problem admits a partition into $k$ groups, one per terminal. One-hot and terminal-pinning penalties encode that partition @Heidari2022. ][ - _Construction._ Given $G = (V, E)$ with $n = |V|$, edge weights $w: E -> RR_(>0)$, and $k$ terminals $T = {t_0, ..., t_(k-1)}$. Introduce $n k$ binary variables $x_(u,t) in {0,1}$ (indexed by $u dot k + t$), where $x_(u,t) = 1$ means vertex $u$ is in terminal $t$'s component. Let $alpha = 1 + sum_(e in E) w(e)$. + _Construction._ Given $G = (V, E)$ with $n = |V|$, edge weights $w: E -> ZZ$, and $k$ terminals $T = {t_0, ..., t_(k-1)}$. Introduce $n k$ binary variables $x_(u,t) in {0,1}$ (indexed by $u dot k + t$), where $x_(u,t) = 1$ means vertex $u$ is in terminal $t$'s component. Set $w^+(e) = max(w(e), 0)$ and $alpha = 1 + sum_(e in E) w^+(e)$. The QUBO Hamiltonian is $H = H_A + H_B$ where: $ H_A = alpha (sum_(u in V) (1 - sum_(t=0)^(k-1) x_(u,t))^2 + sum_(i=0)^(k-1) sum_(s != i) x_(t_i, s)) $ @@ -12636,12 +12636,12 @@ where $P$ is a penalty weight large enough that any constraint violation costs m Terminal pinning adds $alpha$ to the diagonal $Q_(t_i k+s, t_i k+s)$ for $s != i$, canceling the one-hot incentive. The cut-cost Hamiltonian: - $ H_B = sum_((u,v) in E) sum_(s != t) w(u,v) dot x_(u,s) dot x_(v,t) $ - counts the total weight of edges whose endpoints lie in different components. + $ H_B = sum_((u,v) in E) sum_(s != t) w^+(u,v) dot x_(u,s) dot x_(v,t) $ + counts nonnegative weights across different groups. The negative-edge contribution is a constant restored during extraction; the stored QUBO also omits the constant $alpha n$ from $H_A$. - _Correctness._ ($arrow.r.double$) A valid multiway cut with cost $C$ maps to a QUBO solution with $H_A = 0$ (valid partition with correct terminal pinning) and $H_B = C$. ($arrow.l.double$) If $H_A > 0$, the penalty $alpha > sum_e w(e)$ exceeds the entire cut-cost range, so any QUBO minimizer has $H_A = 0$, encoding a valid partition. Among valid partitions, $H_B$ equals the cut cost, and the minimizer achieves the minimum multiway cut. + _Correctness._ Deleting any negative edge strictly improves the objective and cannot reconnect terminals, so every optimum deletes all such edges. In the residual nonnegative-cost problem, any feasible cut yields disconnected terminal components that can be assigned distinct labels; components without terminals can be assigned arbitrarily. Keeping additional edges within each label cannot increase cut cost. Conversely, every terminal-pinned partition gives a feasible cut. Since $H_B >= 0$ for every binary assignment, violating a constraint costs at least $alpha$, while some valid pinned partition costs at most $sum_e w^+(e) < alpha$. Thus every QUBO optimum satisfies the constraints and minimizes the residual cut cost. Restoring every negative edge to the deletion set gives a source optimum. - _Solution extraction._ For each vertex $u$, find terminal position $t$ with $x_(u,t) = 1$. For each edge $(u,v)$, output 1 (cut) if $u$ and $v$ are in different components, 0 otherwise. + _Solution extraction._ Require exactly one label per vertex and the prescribed label at each terminal. Delete an edge iff its weight is negative or its endpoint labels differ. ] #reduction-rule("GraphPartitioning", "QUBO")[ @@ -17326,11 +17326,11 @@ The following table shows concrete target-variable counts for example instances, )[ Garey and Johnson's Theorem 3.4 replaces each source edge by a 12-vertex cover-testing gadget and uses $k$ selector vertices to choose $k$ source vertices whose incident gadget-paths together cover every gadget @garey1979. In the unit-weight decision setting, the constructed graph is Hamiltonian iff the source graph has a vertex cover of size at most $k$. ][ - _Construction._ Let the source be a unit-weight Decision Minimum Vertex Cover instance $(G = (V, E), k)$ with $G$ simple. For each edge $e = {u, v} in E$, create a gadget with vertices $(u, e, i)$ and $(v, e, i)$ for $1 <= i <= 6$. Add the two 6-chains on the $u$-side and $v$-side together with the four cross edges ${(u, e, 3), (v, e, 1)}$, ${(v, e, 3), (u, e, 1)}$, ${(u, e, 6), (v, e, 4)}$, and ${(v, e, 6), (u, e, 4)}$. For every source vertex $v$, order its incident edges as $e_(v[1]), dots, e_(v[deg(v)])$ and connect ${(v, e_(v[i]), 6), (v, e_(v[i+1]), 1)}$ for $1 <= i < deg(v)$, forming one path that contains exactly the gadget copies labeled by $v$. Finally add selector vertices $a_1, dots, a_k$ and join each selector to both endpoints of every non-isolated vertex-path. Thus the theorem branch has $k + 12|E|$ vertices and $14|E| + sum_(v in V^+) (deg(v)-1) + 2k|V^+|$ edges, where $V^+ = {v in V : deg(v) > 0}$. + _Construction._ Let the source be a unit-weight Decision Minimum Vertex Cover instance $(G = (V, E), k)$ with $G$ loopless. For inputs with loops, first select every looped vertex, remove its incident edges, and subtract the number selected from $k$; apply the construction to that residual graph. A negative residual budget gives a fixed NO instance; a budget covering all residual non-isolated vertices gives a fixed YES instance. For each edge $e = {u, v} in E$, create a gadget with vertices $(u, e, i)$ and $(v, e, i)$ for $1 <= i <= 6$. Add the two 6-chains on the $u$-side and $v$-side together with the four cross edges ${(u, e, 3), (v, e, 1)}$, ${(v, e, 3), (u, e, 1)}$, ${(u, e, 6), (v, e, 4)}$, and ${(v, e, 6), (u, e, 4)}$. For every source vertex $v$, order its incident edges as $e_(v[1]), dots, e_(v[deg(v)])$ and connect ${(v, e_(v[i]), 6), (v, e_(v[i+1]), 1)}$ for $1 <= i < deg(v)$, forming one path that contains exactly the gadget copies labeled by $v$. Finally add selector vertices $a_1, dots, a_k$ and join each selector to both endpoints of every non-isolated vertex-path. Thus the theorem branch has $k + 12|E|$ vertices and $14|E| + sum_(v in V^+) (deg(v)-1) + 2k|V^+|$ edges, where $V^+ = {v in V : deg(v) > 0}$. _Correctness._ ($arrow.r.double$) Suppose $C subset.eq V$ is a vertex cover with $|C| <= k$. Because all weights are 1, we may pad $C$ with arbitrary additional non-isolated vertices until it has exactly $k$ elements, say $v_1, dots, v_k$. For every edge gadget $e = {u, v}$, traverse it in one of the three gadget modes from @garey1979: if only $u in C$, follow the unique Hamiltonian path from $(u, e, 1)$ to $(u, e, 6)$ through all 12 gadget vertices; if only $v in C$, use the symmetric path from $(v, e, 1)$ to $(v, e, 6)$ through all 12 vertices; if both endpoints lie in $C$, use the two disjoint side paths from $(u, e, 1)$ to $(u, e, 6)$ and from $(v, e, 1)$ to $(v, e, 6)$. Chaining these gadget traversals along the paths for $v_1, dots, v_k$ and connecting consecutive paths through the selectors yields a Hamiltonian circuit of the target graph. ($arrow.l.double$) Suppose the target graph has a Hamiltonian circuit. Each selector has degree two inside the circuit and therefore cuts the circuit into $k$ selector-to-selector segments. Inside any edge gadget, the circuit can appear only in the three modes above, so each segment must stay on the path corresponding to one source vertex. Mark a source vertex $v$ selected exactly when both endpoints of its path are adjacent to selectors in the Hamiltonian circuit. This selects exactly $k$ source vertices. Every edge gadget must be completely visited, and that is possible only if at least one of its endpoint paths is selected, so every source edge has a selected endpoint. Hence the extracted set is a vertex cover of size at most $k$. - _Solution extraction._ Given a Hamiltonian circuit witness, inspect the two endpoints of each source vertex-path. Set $x_v = 1$ iff both path endpoints are adjacent to selector vertices in the cycle; otherwise set $x_v = 0$. The resulting indicator vector is a valid source-side vertex cover. + _Solution extraction._ Given a Hamiltonian circuit witness, inspect the two endpoints of each source vertex-path. Set $x_v = 1$ iff both path endpoints are adjacent to selector vertices in the cycle; otherwise set $x_v = 0$. Restore every vertex forced by a source loop. The resulting indicator vector is a valid source-side vertex cover. ] #let ksat_mvc = load-example("KSatisfiability", "MinimumVertexCover") @@ -17744,7 +17744,7 @@ The following table shows concrete target-variable counts for example instances, [ *Step 1 -- Source instance.* The formula is $phi = (x_1 or x_2 or x_3)$ with satisfying assignment $(x_1, x_2, x_3) = (#fmt-values(ksat_ps_sol.source_config))$. - *Step 2 -- Build Ullman's unit-task gadgets.* For $n = #n$, the reduction creates $2 n (n + 1) = #(2 * n * (n + 1))$ chain jobs $x_(i,j), overline(x)_(i,j)$, $2n = #(2 * n)$ forcing jobs $y_i, overline(y)_i$, and $7m = #(7 * m)$ clause jobs $D_(r,s)$. The slot capacities are $(#(n), #(2 * n + 1), #(2 * n + 2), #(2 * n + 2), #(m + n + 1), #(6 * m)) = (3, 7, 8, 8, 5, 6)$. We realize these capacities with $p = max(2n + 2, 6m) = #p$ processors and $F = #filler-jobs$ filler jobs, giving $#num-jobs$ total unit jobs. In this example the filler counts are $(5, 1, 0, 0, 3, 2)$. + *Step 2 -- Build Ullman's unit-task gadgets.* For $n = #n$, the reduction creates $2 n (n + 1) = #(2 * n * (n + 1))$ chain jobs $x_(i,j), overline(x)_(i,j)$, $2n = #(2 * n)$ forcing jobs $y_i, overline(y)_i$, and $7m = #(7 * m)$ clause jobs $D_(r,s)$. The slot capacities are $(#(n), #(2 * n + 1), #(2 * n + 2), #(2 * n + 2), #(m + n + 1), #(6 * m)) = (3, 7, 8, 8, 5, 6)$. We realize these capacities with $p = 1 + max_t c_t = #p$ processors and $F = #filler-jobs$ filler jobs, giving $#num-jobs$ total unit jobs. In this example the filler counts are $(#fmt-values((3, 7, 8, 8, 5, 6).map(c => p - c)))$. *Step 3 -- Verify a schedule.* The witness schedule has exactly $p = #p$ jobs in each of the $T = #t$ slots: $(#fmt-values(slot-counts))$. The positive chain starters $x_(1,0), x_(2,0), x_(3,0)$ are jobs $0, 8, 16$, placed at slots $(#sigma.at(0), #sigma.at(8), #sigma.at(16)) = (1, 1, 0)$, so extraction reads $(0, 0, 1)$ back from slot 0. The clause-pattern jobs are indices $30, dots, 36$; their slots are $(#fmt-values(clause-slots))$, so exactly one clause job is promoted to slot $n + 1 = 4$ and the remaining six sit at slot $n + 2 = 5$. @@ -17755,14 +17755,16 @@ The following table shows concrete target-variable counts for example instances, )[ Ullman's reduction first builds a variable-capacity unit-task scheduling instance for 3-SAT, then pads each time slot with chained filler jobs so a fixed number of processors simulates the desired capacity profile. Because every task has length $1$, preemption is irrelevant: the resulting instance is already a valid preemptive scheduling instance whose optimal makespan is at most $T = n + 3$ iff the formula is satisfiable @ullman1975 @garey1979. ][ + Short nonempty clauses are padded by repeating literals. An empty conjunction maps to one unit task with threshold 1; a formula containing an empty clause maps to the same task with threshold 0. The following construction handles the remaining instances. + _Construction._ Let $phi$ be a 3-CNF formula with variables $x_1, dots, x_n$ and clauses $C_1, dots, C_m$. Create unit jobs $x_(i,j)$ and $overline(x)_(i,j)$ for $1 <= i <= n$ and $0 <= j <= n$, plus forcing jobs $y_i, overline(y)_i$, and clause jobs $D_(r,s)$ for $1 <= r <= m$, $1 <= s <= 7$. Add chain precedences $x_(i,j) prec x_(i,j+1)$ and $overline(x)_(i,j) prec overline(x)_(i,j+1)$, and branching precedences $x_(i,i-1) prec y_i$, $overline(x)_(i,i-1) prec overline(y)_i$. Set $T = n + 3$ and slot capacities $c_0 = n$, $c_1 = 2n + 1$, $c_t = 2n + 2$ for $2 <= t <= n$, $c_(n+1) = m + n + 1$, and $c_(n+2) = 6m$. For each clause $C_r = (ell_1 or ell_2 or ell_3)$ and each nonzero bit pattern $b in {1, dots, 7}$, create clause job $D_(r,b)$. Its predecessors are the three chain endpoints chosen according to the bits of $b$: for literal position $k$, use the endpoint of $ell_k$ when bit $k$ is 1 and of $not ell_k$ when bit $k$ is 0. This makes exactly one clause job per clause ready one slot earlier when the clause is satisfied. - To convert the variable-capacity instance to fixed processors, let $p = max(2n + 2, 6m)$. For every slot $t$, add $p - c_t$ filler jobs and impose complete-bipartite precedences from every filler at slot $t$ to every filler at slot $t+1$. Keep every task length equal to $1$ and use $p$ processors. The total work is exactly $p T$, so any schedule of makespan at most $T$ must saturate every slot and therefore realizes the intended capacities. + To convert the variable-capacity instance to fixed processors, let $p = 1 + max_t c_t$. For every slot $t$, add $p - c_t$ filler jobs and impose complete-bipartite precedences from every filler at slot $t$ to every filler at slot $t+1$. Keep every task length equal to $1$ and use $p$ processors. Every filler layer is nonempty, so a chain through all $T$ layers pins layer $t$ to slot $t$. The total work is exactly $p T$, so any schedule of makespan at most $T$ must saturate every slot and therefore realizes the intended capacities. _Correctness._ ($arrow.r.double$) Given a satisfying assignment, place exactly one of $x_(i,0), overline(x)_(i,0)$ at slot $0$ for each variable, propagate the two chains forward one step at a time, schedule the forcing jobs immediately after their branch points, and place the unique matching clause job for each clause at slot $n + 1$ (all other clause jobs at slot $n + 2$). The filler jobs occupy the remaining $p - c_t$ processor positions in slot $t$, so the schedule finishes by time $T = n + 3$. ($arrow.l.double$) Conversely, if the constructed instance has makespan at most $T$, then every slot is full and the filler chains force exactly $p - c_t$ filler jobs into slot $t$, leaving precisely $c_t$ non-filler positions. Ullman's capacity argument then applies: at slot $0$ exactly one of $x_(i,0), overline(x)_(i,0)$ is chosen per variable, this choice propagates consistently through the chains, and the availability of one clause job per clause at slot $n + 1$ implies each clause has a satisfied literal. Hence the extracted assignment satisfies $phi$. - _Solution extraction._ In the binary schedule encoding, inspect the row for each starter job $x_(i,0)$. Set $x_i = 1$ iff that row has its single $1$ in column $0$; otherwise set $x_i = 0$. + _Solution extraction._ In the binary schedule encoding, inspect the row for each starter job $x_(i,0)$. Set $x_i = 1$ iff that row has its single $1$ in column $0$; otherwise set $x_i = 0$. Only schedules meeting the threshold yield a source witness. For aggregate recovery, compare the target optimum to the threshold: at most the threshold means YES, and a larger optimum or infeasibility means NO. ] #let ksat_td = load-example("KSatisfiability", "TimetableDesign") @@ -18699,17 +18701,19 @@ The following table shows concrete target-variable counts for example instances, } ], )[ - This $O(n + m)$ reduction @schaefer1978 @garey1979[GT16] normalizes each 2-literal clause $(ell_1, ell_2)$ to $(ell_1, ell_1, ell_2)$, then builds 4-vertex variable gadgets, 2-vertex signal pairs, 4-vertex $K_4$ clause gadgets, and 2-vertex equality-chain links. For $m$ normalized clauses it produces $4n + 16m$ vertices, $3n + 21m$ edges, and fixes $K = 2$. + This reduction @schaefer1978 @garey1979[GT16] first normalizes NAE clauses to length 3 with auxiliary variables, then constructs variable, signal, clause, and equality-chain gadgets. With $n'$ variables and $m'$ clauses after normalization, it produces $4n' + 16m'$ vertices, $3n' + 21m'$ edges, and fixes $K = 2$. The construction takes $O(n + L)$ time, where $L$ is the original number of literal occurrences. ][ - _Construction._ Let $phi$ be a NAE-SAT instance on variables $x_1, dots, x_n$ whose clauses have size 2 or 3, matching the implemented rule. Replace every 2-literal clause $(ell_1, ell_2)$ by $(ell_1, ell_1, ell_2)$, yielding normalized 3-literal clauses $C_j = (ell_(j,0), ell_(j,1), ell_(j,2))$ for $j = 0, dots, m - 1$. For each variable $x_i$, create vertices $t_i, t'_i, f_i, f'_i$ with edges $(t_i, t'_i)$, $(f_i, f'_i)$, and $(t_i, f_i)$. For each clause position $(j, k)$, create a signal pair $s_(j,k), s'_(j,k)$ with edge $(s_(j,k), s'_(j,k))$. For each clause $C_j$, create vertices $w_(j,0), w_(j,1), w_(j,2), w_(j,3)$ forming a $K_4$, and add connection edges $(s_(j,k), w_(j,k))$ for $k in {0,1,2}$. + _Construction._ Let $phi$ be a NAE-SAT instance on variables $x_1, dots, x_n$ whose clauses have at least two literals. Split each clause of length greater than 3 by replacing $"NAE"(a,b,R)$ with $"NAE"(a,b,z) and "NAE"(not z,R)$ for a fresh variable $z$, repeating as necessary. Here $R$ denotes the remaining literals. Replace every 2-literal clause $(ell_1, ell_2)$ by $(ell_1, ell_1, ell_2)$, yielding normalized 3-literal clauses $C_j = (ell_(j,0), ell_(j,1), ell_(j,2))$ for $j = 0, dots, m - 1$. Include auxiliary variables in this normalized instance. For each variable $x_i$, create vertices $t_i, t'_i, f_i, f'_i$ with edges $(t_i, t'_i)$, $(f_i, f'_i)$, and $(t_i, f_i)$. For each clause position $(j, k)$, create a signal pair $s_(j,k), s'_(j,k)$ with edge $(s_(j,k), s'_(j,k))$. For each clause $C_j$, create vertices $w_(j,0), w_(j,1), w_(j,2), w_(j,3)$ forming a $K_4$, and add connection edges $(s_(j,k), w_(j,k))$ for $k in {0,1,2}$. For each variable, chain its positive occurrences starting from $t_i$ and its negative occurrences starting from $f_i$. If $(j, k)$ is the next occurrence in the chosen sign-order and $"src"$ is the current chain source, create fresh vertices $mu, mu'$ with edges $(mu, mu')$, $("src", mu)$, and $(s_(j,k), mu)$, then update $"src" := s_(j,k)$. Output the Partition Into Perfect Matchings instance $(G, 2)$. + Normalization preserves satisfiability: if $a=b$, the first clause forces $z=not a$ and the second requires some literal of $R$ to differ from $a$, exactly the original condition. If $a != b$, the first clause is already satisfied and choosing $z$ equal to any literal of $R$ satisfies the second. Conversely, both clauses cannot be satisfied when all original literals agree. + _Correctness._ ($arrow.r.double$) Let $alpha$ be a NAE-satisfying assignment. Put $t_i, t'_i$ in group 0 and $f_i, f'_i$ in group 1 when $alpha(x_i) = 1$; swap the two groups when $alpha(x_i) = 0$. Every equality-chain pair forces its signal vertex to share the group of the current chain source, so positive occurrences inherit the group of $t_i$ and negative occurrences inherit the group of $f_i$. In each normalized clause, the three signals are not all equal because $alpha$ satisfies the NAE condition. Assign $w_(j,k)$ to the opposite group from $s_(j,k)$ for $k = 0, 1, 2$, and assign $w_(j,3)$ to the minority group among $w_(j,0), w_(j,1), w_(j,2)$. Then every variable gadget, signal pair, and equality-chain pair contributes exactly one same-group edge, and each $K_4$ splits $2 + 2$, so every vertex has exactly one same-group neighbor. ($arrow.l.double$) Suppose $(G, 2)$ admits a partition into two perfect matchings. In each variable gadget, the edges $(t_i, t'_i)$ and $(f_i, f'_i)$ force those pairs to share a group, while the edge $(t_i, f_i)$ forces $t_i$ and $f_i$ to lie in opposite groups. Each equality-chain pair forces its signal vertex to share the group of the chain source, so positive signals copy $t_i$ and negative signals copy $f_i$. In a clause gadget, each signal vertex is opposite its corresponding $w_(j,k)$, and the $K_4$ must split $2 + 2$; therefore $w_(j,0), w_(j,1), w_(j,2)$ cannot all share one group, so neither can the three signal vertices. Defining $alpha(x_i) = 1$ iff $t_i$ lies in group 0 makes every normalized clause NAE-satisfied, hence every original clause is NAE-satisfied as well. - _Solution extraction._ Read the variable gadgets: set $alpha(x_i) = 1$ iff $t_i$ lies in group 0. + _Solution extraction._ Read the variable gadgets: set $alpha(x_i) = 1$ iff $t_i$ lies in group 0. Return only the original variables, discarding auxiliary variables. ] // 7. ExactCoverBy3Sets → SubsetProduct (#388) diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 83697feac..4dbabf106 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -201,10 +201,15 @@ pub struct BundleReplay { } enum BundleChain { - Witness(problemreductions::rules::ReductionChain), + Witness(Vec), Aggregate(problemreductions::rules::AggregateReductionChain), } +struct WitnessStep { + chain: problemreductions::rules::ReductionChain, + source_variant: &'static problemreductions::registry::VariantEntry, +} + impl BundleReplay { /// Validate the bundle and replay the reduction chain. /// @@ -271,13 +276,30 @@ impl BundleReplay { let graph = ReductionGraph::new(); let chain = match mode { - problemreductions::rules::ReductionMode::Witness => BundleChain::Witness( - graph - .reduce_along_path(&reduction_path, source.as_any())? - .ok_or_else(|| { + problemreductions::rules::ReductionMode::Witness => { + let mut steps: Vec = Vec::new(); + for edge in reduction_path.steps.windows(2) { + let input = steps + .last() + .map_or(source.as_any(), |step| step.chain.target_problem_any()); + let path = problemreductions::rules::ReductionPath { + steps: edge.to_vec(), + }; + let chain = graph.reduce_along_path(&path, input)?.ok_or_else(|| { anyhow::anyhow!("Bundle requires a witness-capable reduction path") - })?, - ), + })?; + let source_variant = problemreductions::registry::find_variant_entry( + &edge[0].name, + &edge[0].variant, + ) + .context("missing intermediate problem registration")?; + steps.push(WitnessStep { + chain, + source_variant, + }); + } + BundleChain::Witness(steps) + } problemreductions::rules::ReductionMode::Aggregate => BundleChain::Aggregate( graph .reduce_aggregate_along_path(&reduction_path, source.as_any())? @@ -295,7 +317,7 @@ impl BundleReplay { // could solve/validate against the bundle's stated target but then // extract through a completely different chain target. let target_any = match &chain { - BundleChain::Witness(chain) => chain.target_problem_any(), + BundleChain::Witness(steps) => steps.last().unwrap().chain.target_problem_any(), BundleChain::Aggregate(chain) => chain.target_problem_any(), }; let replayed_target_data = serialize_any_problem(&last.name, &last.variant, target_any)?; @@ -321,10 +343,15 @@ impl BundleReplay { &self, target_config: &serde_json::Value, ) -> Result<(serde_json::Value, String)> { - let BundleChain::Witness(chain) = &self.chain else { + let BundleChain::Witness(steps) = &self.chain else { anyhow::bail!("value-only reductions do not recover witnesses") }; - let source_config = chain.extract_solution_json(target_config.clone())?; + let source_config = steps + .iter() + .rev() + .try_fold(target_config.clone(), |solution, step| { + step.chain.extract_solution_json(solution) + })?; let source_eval = self.source.evaluate_witness_dyn(&source_config)?.ok_or_else(|| { problemreductions::rules::ExtractionError::invalid(format!( "extracted solution is infeasible for {}; the reduction did not establish a source solution", @@ -338,14 +365,85 @@ impl BundleReplay { let BundleChain::Aggregate(chain) = &self.chain else { anyhow::bail!("value recovery requires an aggregate-capable path") }; - Ok(chain.extract_value_dyn(value)?) + Ok(chain.extract_value(value)?) } - pub fn extract_result(&self, result: &SolveOutcome) -> Result { - let BundleChain::Witness(chain) = &self.chain else { + /// Execute recovery of an exact completed result. The caller establishes + /// optimality or infeasibility; evaluating a candidate cannot establish it. + pub(crate) fn extract_result(&self, result: &SolveOutcome) -> Result { + use problemreductions::rules::ExtractionError; + let BundleChain::Witness(steps) = &self.chain else { anyhow::bail!("value-only reductions require an aggregate value") }; - Ok(chain.extract_result(&*self.source, result)?) + let (mut witness, mut value) = match result { + SolveOutcome::Optimal { + solution, + evaluation, + } => { + let value = self.target.evaluate_json(solution)?; + let actual = self + .target + .aggregate_witness_evaluation(&value)? + .ok_or_else(|| ExtractionError::invalid("target witness is infeasible"))?; + if evaluation != &actual { + return Err(ExtractionError::invalid( + "target evaluation does not match the witness", + ) + .into()); + } + (Some(solution.clone()), value) + } + SolveOutcome::Infeasible => (None, self.target.empty_aggregate_json()?), + }; + let mut evaluation = None; + for (index, step) in steps.iter().enumerate().rev() { + let input: &dyn DynProblem = if index == 0 { + &*self.source + } else { + (step.source_variant.borrow_fn)(steps[index - 1].chain.target_problem_any()) + .context("intermediate problem type mismatch")? + }; + let mapped = if step.chain.has_value_mapping() { + Some(step.chain.extract_value(value.clone())?) + } else { + None + }; + if let Some(mapped_value) = &mapped { + if input.aggregate_witness_evaluation(mapped_value)?.is_none() { + witness = None; + value = mapped_value.clone(); + evaluation = None; + continue; + } + } + let target_witness = witness.take().ok_or_else(|| { + ExtractionError::invalid(format!( + "cannot recover a {} witness from this value-only result", + input.problem_name() + )) + })?; + let solution = step.chain.extract_solution_json(target_witness)?; + value = input.evaluate_json(&solution)?; + evaluation = Some( + input + .aggregate_witness_evaluation(&value)? + .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?, + ); + if mapped.is_some_and(|mapped| mapped != value) { + return Err(ExtractionError::invalid( + "extracted witness does not realize the mapped aggregate", + ) + .into()); + } + witness = Some(solution); + } + Ok(match witness { + Some(solution) => SolveOutcome::Optimal { + solution, + evaluation: evaluation.expect("a recovered witness has an evaluation"), + }, + None => SolveOutcome::Infeasible, + }) } /// Solve the target and map the result back to the source problem. @@ -467,6 +565,162 @@ mod tests { use problemreductions::topology::SimpleGraph; use serde_json::json; + fn problem_step() -> problemreductions::rules::ReductionStep { + problemreductions::rules::ReductionStep { + name: P::NAME.into(), + variant: ReductionGraph::variant_to_map(&P::variant()), + } + } + + fn replay( + source: &P, + targets: Vec, + ) -> BundleReplay { + use problemreductions::rules::{ReductionMode, ReductionPath}; + let mut steps = vec![problem_step::

()]; + steps.extend(targets); + let bundle = crate::commands::reduce::execute_route( + ProblemJson { + problem_type: P::NAME.into(), + variant: ReductionGraph::variant_to_map(&P::variant()), + data: serde_json::to_value(source).unwrap(), + }, + ReductionPath { steps }, + ReductionMode::Witness, + ) + .unwrap(); + BundleReplay::prepare(&bundle, ReductionMode::Witness).unwrap() + } + + #[test] + fn completed_recovery_composes_solution_only_and_value_mapping_steps() { + use problemreductions::models::{Decision, MinimumVertexCover}; + type Cover = MinimumVertexCover; + type Independent = MaximumIndependentSet; + for bound in [1, 2] { + let source = Decision::new( + Cover::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![1; 3], + ), + bound, + ); + let replay = replay( + &source, + vec![problem_step::(), problem_step::()], + ); + for solution in [ + json!([true, false, false]), + json!([false, true, false]), + json!([false, false, true]), + ] { + let recovered = replay + .extract_result(&SolveOutcome::Optimal { + solution, + evaluation: "Max(1)".into(), + }) + .unwrap(); + assert_eq!(matches!(recovered, SolveOutcome::Infeasible), bound == 1); + if let SolveOutcome::Optimal { + solution, + evaluation, + } = recovered + { + assert_eq!(evaluation, "Or(true)"); + assert_eq!( + replay.source.evaluate_witness_dyn(&solution).unwrap(), + Some(evaluation) + ); + } + } + assert!(replay.extract_result(&SolveOutcome::Infeasible).is_err()); + for (solution, evaluation) in [ + (json!([true]), "Max(1)"), + (json!([true, true, true]), "Max(None)"), + (json!([true, false, false]), "Max(99)"), + ] { + assert!(replay + .extract_result(&SolveOutcome::Optimal { + solution, + evaluation: evaluation.into() + }) + .is_err()); + } + } + } + + #[test] + fn completed_recovery_carries_negative_answers_through_value_mappings() { + use problemreductions::models::formula::{CNFClause, NAESatisfiability, Satisfiability}; + use problemreductions::models::graph::MaxCut; + use problemreductions::solvers::BruteForce; + use problemreductions::Problem; + for unsatisfiable in [false, true] { + let clauses = if unsatisfiable { + vec![vec![1], vec![-1]] + } else { + vec![vec![1]] + }; + let source = Satisfiability::new(1, clauses.into_iter().map(CNFClause::new).collect()); + let replay = replay( + &source, + vec![ + problem_step::(), + problem_step::>(), + ], + ); + let target = replay + .target + .as_any() + .downcast_ref::>() + .unwrap(); + for solution in BruteForce::new().find_all_witnesses(target).unwrap() { + let recovered = replay + .extract_result(&SolveOutcome::Optimal { + evaluation: target.evaluate(&solution).unwrap().to_string(), + solution: json!(solution), + }) + .unwrap(); + assert_eq!(matches!(recovered, SolveOutcome::Infeasible), unsatisfiable); + } + } + } + + #[test] + fn completed_recovery_checks_value_and_solution_agreement() { + use problemreductions::models::algebraic::{ObjectiveSense, ILP, QUBO}; + use problemreductions::Problem; + let source = ILP::::new(1, vec![], vec![(0, 1)], ObjectiveSense::Maximize).unwrap(); + let mut replay = replay(&source, vec![problem_step::>()]); + let target_result = replay + .target + .solve(SolverRequest::BruteForce) + .unwrap() + .outcome; + assert!(matches!( + replay.extract_result(&target_result).unwrap(), + SolveOutcome::Optimal { .. } + )); + // A different source objective must not agree with the executed value mapping. + let different = + ILP::::new(1, vec![], vec![(0, 2)], ObjectiveSense::Maximize).unwrap(); + replay.source = load_problem( + ILP::::NAME, + &ReductionGraph::variant_to_map(&ILP::::variant()), + serde_json::to_value(different).unwrap(), + ) + .unwrap(); + assert!(replay + .extract_result(&target_result) + .unwrap_err() + .to_string() + .contains("does not realize the mapped aggregate")); + assert!(replay + .source + .aggregate_witness_evaluation(&json!(true)) + .is_err()); + } + #[test] fn aggregate_only_bundle_executes_and_recovers_without_witnesses() { use problemreductions::rules::{ReductionMode, ReductionPath, ReductionStep}; diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index a12a08172..4e51c6693 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -33,7 +33,7 @@ inventory::submit! { /// vertices of different slots must be disjoint. Empty slots (all zeros) are /// unused and do not count toward the objective. The objective is to maximize /// the number of non-empty valid path slots. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct LengthBoundedDisjointPaths { graph: G, @@ -43,6 +43,35 @@ pub struct LengthBoundedDisjointPaths { max_length: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct LengthBoundedDisjointPathsData { + graph: G, + source: usize, + sink: usize, + max_paths: usize, + max_length: usize, +} + +impl<'de, G> Deserialize<'de> for LengthBoundedDisjointPaths +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = LengthBoundedDisjointPathsData::::deserialize(deserializer)?; + let max_paths = data.max_paths; + let instance = Self::try_new(data.graph, data.source, data.sink, data.max_length) + .map_err(serde::de::Error::custom)?; + if max_paths != instance.max_paths { + return Err(serde::de::Error::custom(format!( + "max_paths must equal min(deg(source), deg(sink)): expected {}, got {max_paths}", + instance.max_paths + ))); + } + Ok(instance) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct LengthBoundedDisjointPathsCreateSpec { /// Undirected graph edges. @@ -102,30 +131,8 @@ impl TryFrom for LengthBoundedDisjointPath "num_vertices {num_vertices} is too small for graph endpoints; need at least {inferred}" ).into()); } - if spec.source >= num_vertices || spec.sink >= num_vertices { - return Err("source and sink must be valid graph vertices" - .to_string() - .into()); - } - if spec.source == spec.sink { - return Err("source and sink must be distinct".to_string().into()); - } - if spec.max_length == 0 { - return Err("max_length must be positive".to_string().into()); - } - let graph = SimpleGraph::new(num_vertices, spec.graph); - let max_paths = graph - .neighbors(spec.source) - .len() - .min(graph.neighbors(spec.sink).len()); - Ok(Self { - graph, - source: spec.source, - sink: spec.sink, - max_paths, - max_length: spec.max_length, - }) + Self::try_new(graph, spec.source, spec.sink, spec.max_length) } } @@ -140,26 +147,37 @@ impl LengthBoundedDisjointPaths { /// Panics if `source` or `sink` is not a valid graph vertex, if `source == /// sink`, or if `max_length == 0`. pub fn new(graph: G, source: usize, sink: usize, max_length: usize) -> Self { - assert!( - source < graph.num_vertices(), - "source must be a valid graph vertex" - ); - assert!( - sink < graph.num_vertices(), - "sink must be a valid graph vertex" - ); - assert_ne!(source, sink, "source and sink must be distinct"); - assert!(max_length > 0, "max_length must be positive"); + Self::try_new(graph, source, sink, max_length).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + source: usize, + sink: usize, + max_length: usize, + ) -> Result { + if source >= graph.num_vertices() { + return Err("source must be a valid graph vertex".into()); + } + if sink >= graph.num_vertices() { + return Err("sink must be a valid graph vertex".into()); + } + if source == sink { + return Err("source and sink must be distinct".into()); + } + if max_length == 0 { + return Err("max_length must be positive".into()); + } let deg_s = graph.neighbors(source).len(); let deg_t = graph.neighbors(sink).len(); let max_paths = deg_s.min(deg_t); - Self { + Ok(Self { graph, source, sink, max_paths, max_length, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 6b1b66cec..23f8980f9 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -42,13 +42,32 @@ inventory::submit! { /// /// A configuration is feasible if removing the cut edges disconnects all /// terminal pairs. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumMultiwayCut { graph: G, terminals: Vec, edge_weights: Vec, } +#[derive(Deserialize)] +struct MinimumMultiwayCutData { + graph: G, + terminals: Vec, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MinimumMultiwayCut +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumMultiwayCutData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.terminals, data.edge_weights) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumMultiwayCutCreateSpec { /// The undirected graph G=(V,E). @@ -62,35 +81,7 @@ struct MinimumMultiwayCutCreateSpec { impl TryFrom for MinimumMultiwayCut { type Error = crate::registry::ConstructionError; fn try_from(spec: MinimumMultiwayCutCreateSpec) -> Result { - if spec.edge_weights.len() != spec.graph.num_edges() { - return Err(format!( - "edge_weights has {} entries, expected {}", - spec.edge_weights.len(), - spec.graph.num_edges() - ) - .into()); - } - if spec.terminals.len() < 2 { - return Err("at least two terminals are required".to_string().into()); - } - let mut distinct = spec.terminals.clone(); - distinct.sort_unstable(); - distinct.dedup(); - if distinct.len() != spec.terminals.len() { - return Err("terminals must be distinct".to_string().into()); - } - if let Some(&terminal) = spec - .terminals - .iter() - .find(|&&t| t >= spec.graph.num_vertices()) - { - return Err(format!( - "terminal {terminal} is outside graph with {} vertices", - spec.graph.num_vertices() - ) - .into()); - } - Ok(Self::new(spec.graph, spec.terminals, spec.edge_weights)) + Self::try_new(spec.graph, spec.terminals, spec.edge_weights) } } @@ -107,24 +98,36 @@ impl MinimumMultiwayCut { /// - If any terminal index is out of bounds /// - If there are duplicate terminal indices pub fn new(graph: G, terminals: Vec, edge_weights: Vec) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - assert!(terminals.len() >= 2, "need at least 2 terminals"); + Self::try_new(graph, terminals, edge_weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + terminals: Vec, + edge_weights: Vec, + ) -> Result { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + if terminals.len() < 2 { + return Err("need at least 2 terminals".into()); + } let mut sorted = terminals.clone(); sorted.sort(); sorted.dedup(); - assert_eq!(sorted.len(), terminals.len(), "duplicate terminal indices"); + if sorted.len() != terminals.len() { + return Err("duplicate terminal indices".into()); + } for &t in &terminals { - assert!(t < graph.num_vertices(), "terminal index out of bounds"); + if t >= graph.num_vertices() { + return Err("terminal index out of bounds".into()); + } } - Self { + Ok(Self { graph, terminals, edge_weights, - } + }) } /// Get a reference to the underlying graph. @@ -169,7 +172,7 @@ fn terminals_separated(graph: &G, terminals: &[usize], config: &[bool] // Build adjacency list from non-cut edges let mut adj: Vec> = vec![vec![]; n]; for (idx, (u, v)) in edges.iter().enumerate() { - if !config.get(idx).copied().unwrap_or(false) { + if !config[idx] { adj[*u].push(*v); adj[*v].push(*u); } @@ -232,13 +235,11 @@ where let mut total = W::Sum::zero(); for (idx, &selected) in config.iter().enumerate() { if selected { - if let Some(w) = self.edge_weights.get(idx) { - total = W::checked_add_to_sum( - total, - w.to_sum(), - "summing multiway cut edge weights", - )?; - } + total = W::checked_add_to_sum( + total, + self.edge_weights[idx].to_sum(), + "summing multiway cut edge weights", + )?; } } Min(Some(total)) diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 201a2c437..0ae1b1c88 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -8,7 +8,6 @@ use crate::models::graph::{HamiltonianCircuit, MinimumVertexCover}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; -use crate::traits::Problem; use std::collections::BTreeSet; #[derive(Debug, Clone)] @@ -20,7 +19,7 @@ enum ConstructionKind { #[derive(Debug, Clone)] struct TheoremConstruction { - num_source_vertices: usize, + forced_cover: Vec, selector_count: usize, edges: Vec<(usize, usize)>, incident_edges: Vec>, @@ -73,7 +72,7 @@ impl TheoremConstruction { #[cfg(any(test, feature = "example-db"))] fn exact_selected_vertices(&self, source_cover: &[bool]) -> Option> { - if source_cover.len() != self.num_source_vertices || !self.covers_all_edges(source_cover) { + if source_cover.len() != self.forced_cover.len() || !self.covers_all_edges(source_cover) { return None; } @@ -184,24 +183,13 @@ impl TheoremConstruction { fn decode_solution( &self, - target_problem: &HamiltonianCircuit, - target_solution: &Vec, + target_solution: &[usize], ) -> crate::rules::ExtractionResult> { Ok({ - let mut source_cover = vec![false; self.num_source_vertices]; - if !target_problem.evaluate(target_solution)?.0 { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not a Hamiltonian circuit", - )); - } + let mut source_cover = self.forced_cover.clone(); let mut positions = vec![usize::MAX; target_solution.len()]; for (idx, &vertex) in target_solution.iter().enumerate() { - if vertex >= positions.len() || positions[vertex] != usize::MAX { - return Err(crate::rules::ExtractionError::invalid( - "target circuit contains an invalid or repeated vertex", - )); - } positions[vertex] = idx; } @@ -222,7 +210,7 @@ impl TheoremConstruction { } } - let selected_count = source_cover.iter().filter(|&&x| x).count(); + let selected_count = self.active_vertices().filter(|&v| source_cover[v]).count(); if selected_count != self.selector_count || !self.covers_all_edges(&source_cover) { return Err(crate::rules::ExtractionError::invalid( "target circuit does not encode a source vertex cover of the required size", @@ -267,26 +255,24 @@ impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target configuration is not a Hamiltonian circuit", + )); + } Ok({ match &self.construction { - ConstructionKind::FixedYes { source_cover } => { - if self.target.evaluate(target_solution)?.0 { - source_cover.clone() - } else { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not the fixed Hamiltonian circuit", - )); - } - } + ConstructionKind::FixedYes { source_cover } => source_cover.clone(), ConstructionKind::FixedNo => { return Err(crate::rules::ExtractionError::invalid( "the fixed negative target instance has no extractable witness", )) } ConstructionKind::Theorem(construction) => { - construction.decode_solution(&self.target, target_solution)? + construction.decode_solution(target_solution)? } } }) @@ -299,6 +285,7 @@ fn normalize_edges(edges: Vec<(usize, usize)>) -> Vec<(usize, usize)> { .map(|(u, v)| if u < v { (u, v) } else { (v, u) }) .collect(); normalized.sort_unstable(); + normalized.dedup(); normalized } @@ -307,7 +294,23 @@ fn insert_edge(edges: &mut BTreeSet<(usize, usize)>, a: usize, b: usize) { edges.insert(edge); } +impl crate::rules::AggregateReductionResult + for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit +{ + type Source = Decision>; + type Target = HamiltonianCircuit; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( + aggregate = identity, transform = unavailable { num_vertices = "the construction size depends on the decision threshold, which is not a problem parameter", num_edges = "the construction size depends on the decision threshold, which is not a problem parameter", @@ -328,7 +331,18 @@ impl ReduceTo> for Decision> for Decision> for Decision= active_count { - let mut source_cover = vec![false; num_source_vertices]; + if raw_bound >= active_count as i128 { + let mut source_cover = forced_cover; for vertex in active_vertices { source_cover[vertex] = true; } @@ -363,7 +375,7 @@ impl ReduceTo> for Decision> for Decision>, aggregate_views: Vec>, - path: ReductionPath, } impl ReductionChain { - fn problem_at<'a>( - &'a self, - index: usize, - source: &'a dyn crate::registry::DynProblem, - ) -> crate::rules::ExtractionResult<&'a dyn crate::registry::DynProblem> { - if index == 0 { - return Ok(source); - } - let node = &self.path.steps[index]; - crate::registry::find_variant_entry(&node.name, &node.variant) - .and_then(|entry| (entry.borrow_fn)(self.steps[index - 1].target_problem_any())) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "cannot borrow executed problem {}", - node.name - )) - }) + /// Whether every step can map an aggregate value using its existing construction. + pub fn has_value_mapping(&self) -> bool { + self.aggregate_views.iter().all(Option::is_some) } - /// Recover a completed exact result through the executed witness/value mappings. - /// - /// The caller must establish target optimality or infeasibility; witness - /// evaluation alone cannot establish either. Numerical backend status must - /// not be passed here as an exact certificate. `source` must be the instance - /// used to execute this chain. Value-only problems use `AggregateReductionChain`. - /// A missing mapping is an error, not evidence of source infeasibility. - pub fn extract_result( + /// Map a target aggregate back to the source using the executed reductions. + /// Every step must provide a value mapping. This does not establish that + /// the supplied value is the target optimum or aggregate. + pub fn extract_value( &self, - source: &dyn crate::registry::DynProblem, - target_result: &crate::solvers::SolveOutcome, - ) -> crate::rules::ExtractionResult { - use crate::rules::ExtractionError; - use crate::solvers::SolveOutcome; - if source.problem_name() != self.path.steps[0].name - || source.variant_map() != self.path.steps[0].variant - { - return Err(ExtractionError::invalid( - "source does not match the executed path", - )); - } - let target = self.problem_at(self.steps.len(), source)?; - let (mut witness, mut value) = match target_result { - SolveOutcome::Optimal { - solution, - evaluation, - } => { - let value = target.evaluate_json(solution)?; - let actual = target - .aggregate_witness_evaluation(&value)? - .ok_or_else(|| ExtractionError::invalid("target witness is infeasible"))?; - if evaluation != &actual { - return Err(ExtractionError::invalid( - "target evaluation does not match the witness", - )); - } - (Some(solution.clone()), value) - } - SolveOutcome::Infeasible => (None, target.empty_aggregate_json()?), - }; - let mut evaluation = None; - for index in (0..self.steps.len()).rev() { - let step = self.steps[index].as_ref(); - let input = self.problem_at(index, source)?; - let mapped = self.aggregate_views[index] - .map(|view| view(step)?.extract_value_dyn(value.clone())) - .transpose()?; - if let Some(mapped_value) = &mapped { - if input.aggregate_witness_evaluation(mapped_value)?.is_none() { - witness = None; - value = mapped_value.clone(); - evaluation = None; - continue; - } - } - let target_witness = witness.take().ok_or_else(|| { - ExtractionError::invalid(format!( - "{} -> {} cannot recover a source witness from this value-only result", - self.path.steps[index].name, - self.path.steps[index + 1].name, - )) - })?; - let typed = step.target_solution_from_json(target_witness)?; - let recovered = step.extract_solution_dyn(typed.as_ref())?; - let solution = step.source_solution_json(recovered.as_ref())?; - value = input.evaluate_json(&solution)?; - evaluation = Some( - input - .aggregate_witness_evaluation(&value)? - .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?, - ); - if mapped.is_some_and(|mapped| mapped != value) { - return Err(ExtractionError::invalid( - "extracted witness does not realize the mapped aggregate", - )); - } - witness = Some(solution); - } - Ok(match witness { - Some(solution) => SolveOutcome::Optimal { - solution, - evaluation: evaluation.expect("a recovered witness has an evaluation"), + target_value: serde_json::Value, + ) -> crate::rules::ExtractionResult { + self.steps.iter().zip(&self.aggregate_views).rev().try_fold( + target_value, + |value, (step, view)| { + let view = view.ok_or_else(|| { + crate::rules::ExtractionError::invalid("reduction has no value mapping") + })?; + view(step.as_ref())?.extract_value_dyn(value) }, - None => SolveOutcome::Infeasible, - }) + ) } + /// Get the final target problem as a type-erased reference. pub fn target_problem_any(&self) -> &dyn Any { self.steps @@ -1720,7 +1638,7 @@ impl AggregateReductionChain { } /// Extract an aggregate value from target space back to source space. - pub fn extract_value_dyn( + pub fn extract_value( &self, target_value: serde_json::Value, ) -> crate::rules::ExtractionResult { @@ -1807,7 +1725,6 @@ impl ReductionGraph { Ok(Some(ReductionChain { steps, aggregate_views, - path: path.clone(), })) } diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index e994b9cda..36bfb650c 100644 --- a/src/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/rules/ksatisfiability_preemptivescheduling.rs @@ -3,8 +3,9 @@ //! This follows Ullman's 1975 construction via a unit-task precedence //! scheduling instance. Since every task has length 1, preemption is inert: //! the constructed instance is a valid preemptive scheduling problem whose -//! optimal makespan hits the threshold `T = num_vars + 3` iff the 3-SAT -//! instance is satisfiable. +//! optimal makespan hits the threshold `T = num_vars + 3` iff a nontrivial +//! 3-SAT instance is satisfiable. Empty formulas and empty clauses use a +//! one-task instance with bound 1 and 0 respectively. //! //! Reference: Jeffrey D. Ullman, "NP-complete scheduling problems", JCSS 10, //! 1975; Garey & Johnson, Appendix A5.2. @@ -38,10 +39,6 @@ fn time_limit(num_vars: usize) -> usize { num_vars + 3 } -fn processor_upper_bound(num_vars: usize, num_clauses: usize) -> usize { - (2 * num_vars + 2).max(6 * num_clauses) -} - fn slot_capacities(num_vars: usize, num_clauses: usize) -> Vec { let mut capacities = vec![0; time_limit(num_vars)]; capacities[0] = num_vars; @@ -72,8 +69,9 @@ fn build_ullman_construction(source: &KSatisfiability) -> UllmanConstruction let num_vars = source.num_vars(); let num_clauses = source.num_clauses(); let time_limit = time_limit(num_vars); - let num_processors = processor_upper_bound(num_vars, num_clauses); let capacities = slot_capacities(num_vars, num_clauses); + // A nonempty filler layer in every slot forces the clock to span all T slots. + let num_processors = capacities.iter().max().unwrap() + 1; let mut next_job = 0usize; @@ -149,8 +147,8 @@ fn build_ullman_construction(source: &KSatisfiability) -> UllmanConstruction for (clause_index, clause) in source.clauses().iter().enumerate() { for (pattern_index, &clause_job) in clause_jobs[clause_index].iter().enumerate() { let pattern = pattern_index + 1; - for position in 0..3 { - let literal = clause.literals[position]; + // Repeating literals preserves shorter nonempty disjunctions. + for (position, &literal) in clause.literals.iter().cycle().take(3).enumerate() { let bit_is_one = ((pattern >> (2 - position)) & 1) == 1; precedences.push(( literal_endpoint( @@ -193,13 +191,6 @@ fn build_ullman_construction(source: &KSatisfiability) -> UllmanConstruction } } -fn task_slot(config: &[Vec], task: usize, d_max: usize) -> Option { - let task_slice = config.get(task)?; - (task_slice.len() == d_max) - .then(|| task_slice.iter().position(|&value| value)) - .flatten() -} - #[cfg(any(test, feature = "example-db"))] fn set_task_slot(task_slots: &mut [Option], job: usize, slot: usize) { task_slots[job] = Some(slot); @@ -211,9 +202,9 @@ fn clause_pattern_for_assignment( assignment: &[bool], ) -> usize { let mut pattern = 0usize; - for (position, &literal) in clause.literals.iter().enumerate() { + for (position, &literal) in clause.literals.iter().cycle().take(3).enumerate() { let variable = literal.unsigned_abs() as usize - 1; - let value = assignment.get(variable).copied().unwrap_or(false); + let value = assignment[variable]; let literal_true = if literal > 0 { value } else { !value }; if literal_true { pattern |= 1 << (2 - position); @@ -228,6 +219,12 @@ fn construct_schedule_from_assignment( assignment: &[bool], source: &KSatisfiability, ) -> Option>> { + if source.num_clauses() == 0 || source.clauses().iter().any(|c| c.literals.is_empty()) { + return crate::traits::Problem::evaluate(source, &assignment.to_vec()) + .unwrap() + .0 + .then(|| vec![vec![true]]); + } let construction = build_ullman_construction(source); if assignment.len() != source.num_vars() || target.num_tasks() != construction.num_jobs { return None; @@ -339,23 +336,44 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target schedule does not meet the satisfiability threshold", + )); + } + Ok(self + .positive_start_jobs + .iter() + .map(|&job| target_solution[job][0]) + .collect()) + } +} - Ok({ - let d_max = self.target.d_max(); - self.positive_start_jobs - .iter() - .map(|&job| task_slot(target_solution, job, d_max) == Some(0)) - .collect() - }) +impl crate::rules::AggregateReductionResult for Reduction3SATToPreemptiveScheduling { + type Source = KSatisfiability; + type Target = PreemptiveScheduling; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or( + value + .0 + .is_some_and(|makespan| i128::from(makespan) <= self.threshold as i128), + ) } } #[reduction( + aggregate = custom, transform = upper_bound { - num_tasks = "(2 * num_vars + 2 + 6 * num_clauses) * (num_vars + 3)", - num_processors = "2 * num_vars + 2 + 6 * num_clauses", - d_max = "(2 * num_vars + 2 + 6 * num_clauses) * (num_vars + 3)", + num_tasks = "(2 * num_vars + 3 + 6 * num_clauses) * (num_vars + 3)", + num_processors = "2 * num_vars + 3 + 6 * num_clauses", + d_max = "(2 * num_vars + 3 + 6 * num_clauses) * (num_vars + 3)", }, unavailable = { num_precedences = "the exact target parameter is not represented by this reduction's symbolic transform", @@ -365,6 +383,16 @@ impl ReduceTo for KSatisfiability { type Result = Reduction3SATToPreemptiveScheduling; fn reduce_to(&self) -> Result { + let has_empty_clause = self.clauses().iter().any(|c| c.literals.is_empty()); + if self.num_clauses() == 0 || has_empty_clause { + // A one-task schedule meets bound 1, but cannot meet bound 0. + return Ok(Reduction3SATToPreemptiveScheduling { + target: PreemptiveScheduling::new(vec![1], 1, vec![]) + .map_err(>::target_construction)?, + positive_start_jobs: vec![0; self.num_vars()], + threshold: usize::from(!has_empty_clause), + }); + } let construction = build_ullman_construction(self); let target = PreemptiveScheduling::new( vec![1_i64; construction.num_jobs], diff --git a/src/rules/minimummultiwaycut_qubo.rs b/src/rules/minimummultiwaycut_qubo.rs index 8679c2285..d6b94f090 100644 --- a/src/rules/minimummultiwaycut_qubo.rs +++ b/src/rules/minimummultiwaycut_qubo.rs @@ -7,7 +7,8 @@ //! QUBO Hamiltonian: H = H_A + H_B //! //! H_A enforces valid partition (one-hot per vertex) and terminal pinning. -//! H_B encodes the cut cost objective. +//! H_B encodes nonnegative cut costs. Negative edges are always deleted: +//! deleting them reduces the cost and cannot reconnect terminals. //! //! Reference: Heidari, Dinneen & Delmas (2022). @@ -24,6 +25,8 @@ pub struct ReductionMinimumMultiwayCutToQUBO { num_vertices: usize, num_terminals: usize, edges: Vec<(usize, usize)>, + negative_edges: Vec, + terminals: Vec, } impl ReductionResult for ReductionMinimumMultiwayCutToQUBO { @@ -60,10 +63,22 @@ impl ReductionResult for ReductionMinimumMultiwayCutToQUBO { }) .collect::>()?; + if self + .terminals + .iter() + .enumerate() + .any(|(label, &vertex)| assignments[vertex] != label) + { + return Err(crate::rules::ExtractionError::invalid( + "target assignment does not pin each terminal to its own component", + )); + } + // For each edge, output 1 (cut) if endpoints differ, 0 (keep) otherwise self.edges .iter() - .map(|&(u, v)| assignments[u] != assignments[v]) + .zip(&self.negative_edges) + .map(|(&(u, v), &negative)| negative || assignments[u] != assignments[v]) .collect() }) } @@ -91,15 +106,11 @@ impl ReduceTo> for MinimumMultiwayCut { .checked_mul(k) .ok_or_else(|| overflow("computing the number of QUBO variables"))?; - // Penalty: sum of all edge weights + 1 + // All remaining costs are nonnegative; one penalty exceeds their sum. let alpha = edge_weights.iter().try_fold(0i64, |total, &weight| { total - .checked_add( - weight - .checked_abs() - .ok_or_else(|| overflow("taking the absolute value of a cut weight"))?, - ) - .ok_or_else(|| overflow("summing absolute cut weights")) + .checked_add(weight.max(0)) + .ok_or_else(|| overflow("summing nonnegative cut weights")) })?; let alpha = alpha .checked_add(1) @@ -158,7 +169,7 @@ impl ReduceTo> for MinimumMultiwayCut { // For each edge (u,v) with weight w, for each pair of distinct // terminal positions s != t: add w to Q[u*k+s, v*k+t] for (edge_idx, &(u, v)) in edges.iter().enumerate() { - let w = edge_weights[edge_idx]; + let w = edge_weights[edge_idx].max(0); for s in 0..k { for t in 0..k { if s != t { @@ -178,6 +189,8 @@ impl ReduceTo> for MinimumMultiwayCut { num_vertices: n, num_terminals: k, edges, + negative_edges: edge_weights.iter().map(|&weight| weight < 0).collect(), + terminals: terminals.to_vec(), }) } } diff --git a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs index c7a09e983..4d3949f45 100644 --- a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -2,7 +2,8 @@ //! //! This implements the Schaefer-style reduction for the `K = 2` case. //! Clauses with two literals are normalized to three literals by duplicating -//! the first literal, and clauses with more than three literals are rejected. +//! the first literal. Longer clauses are split using auxiliary variables: +//! NAE(a,b,R) iff there exists z: NAE(a,b,z) and NAE(-z,R). use crate::models::formula::NAESatisfiability; use crate::models::graph::PartitionIntoPerfectMatchings; @@ -39,6 +40,9 @@ struct ChainPairVertices { #[derive(Debug, Clone)] struct ReductionLayout { + source_num_vars: usize, + #[cfg(any(test, feature = "example-db"))] + auxiliary_inputs: Vec<[i64; 3]>, variables: Vec, #[cfg(any(test, feature = "example-db"))] clauses: Vec, @@ -69,12 +73,19 @@ impl ReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target partition is not a partition into perfect matchings", + )); + } Ok({ self.layout .variables .iter() + .take(self.layout.source_num_vars) .map(|variable| target_solution[variable.t] == 0) .collect() }) @@ -86,12 +97,25 @@ impl ReductionNAESATToPartitionIntoPerfectMatchings { fn construct_target_solution(&self, source_solution: &[bool]) -> Vec { assert_eq!( source_solution.len(), - self.layout.variables.len(), + self.layout.source_num_vars, "source solution has {} variables but reduction expects {}", source_solution.len(), - self.layout.variables.len() + self.layout.source_num_vars ); + let mut source_solution = source_solution.to_vec(); + for &[a, b, last] in &self.layout.auxiliary_inputs { + let value = |literal: i64| { + source_solution[literal.unsigned_abs() as usize - 1] == (literal > 0) + }; + let z = if value(a) == value(b) { + !value(a) + } else { + value(last) + }; + source_solution.push(z); + } + let mut target_solution = vec![usize::MAX; self.layout.num_vertices]; let mut true_groups = Vec::with_capacity(self.layout.variables.len()); let mut false_groups = Vec::with_capacity(self.layout.variables.len()); @@ -164,30 +188,38 @@ impl ReductionNAESATToPartitionIntoPerfectMatchings { } } -fn normalize_clauses( - problem: &NAESatisfiability, -) -> Result, crate::registry::ConstructionError> { - problem - .clauses() - .iter() - .map(|clause| match clause.literals.as_slice() { - [a, b] => Ok([*a, *a, *b]), - [a, b, c] => Ok([*a, *b, *c]), - literals => Err(format!( - "the construction expects clauses of size 2 or 3, got {}", - literals.len() - ) - .into()), - }) - .collect() -} - fn build_layout( problem: &NAESatisfiability, ) -> Result { - let num_vars = problem.num_vars(); - let clauses = normalize_clauses(problem)?; + let mut allocator = crate::rules::sat_helpers::SatVariableAllocator::new( + "NAESatisfiability -> PartitionIntoPerfectMatchings", + problem.num_vars(), + )?; + let mut clauses = Vec::new(); + #[cfg(any(test, feature = "example-db"))] + let mut auxiliary_inputs = Vec::new(); + for clause in problem.clauses() { + let literals = &clause.literals; + if literals.len() == 2 { + clauses.push([literals[0], literals[0], literals[1]]); + continue; + } + let mut first = literals[0]; + for &middle in &literals[1..literals.len() - 2] { + let auxiliary = allocator.allocate()?; + #[cfg(any(test, feature = "example-db"))] + auxiliary_inputs.push([first, middle, literals[literals.len() - 1]]); + clauses.push([first, middle, auxiliary]); + first = -auxiliary; + } + clauses.push([ + first, + literals[literals.len() - 2], + literals[literals.len() - 1], + ]); + } let num_clauses = clauses.len(); + let num_vars = allocator.num_vars(); let mut next_vertex = 0usize; let mut edges = Vec::with_capacity(3 * num_vars + 21 * num_clauses); @@ -299,6 +331,9 @@ fn build_layout( } Ok(ReductionLayout { + source_num_vars: problem.num_vars(), + #[cfg(any(test, feature = "example-db"))] + auxiliary_inputs, variables, #[cfg(any(test, feature = "example-db"))] clauses: clause_layouts, @@ -311,10 +346,24 @@ fn build_layout( }) } +impl crate::rules::AggregateReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { + type Source = NAESatisfiability; + type Target = PartitionIntoPerfectMatchings; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { - num_vertices = "4 * num_vars + 16 * num_clauses", - num_edges = "3 * num_vars + 21 * num_clauses", + aggregate = identity, + transform = upper_bound { + num_vertices = "4 * num_vars + 20 * num_literals - 24 * num_clauses", + num_edges = "3 * num_vars + 24 * num_literals - 27 * num_clauses", num_matchings = "2", } )] @@ -322,12 +371,9 @@ impl ReduceTo> for NAESatisfiability type Result = ReductionNAESATToPartitionIntoPerfectMatchings; fn reduce_to(&self) -> Result { - let layout = build_layout(self).map_err(|message| { - crate::rules::ReductionError::invalid_target::< - NAESatisfiability, - PartitionIntoPerfectMatchings, - >(message.to_string()) - })?; + let layout = build_layout(self).map_err( + >>::target_construction, + )?; let target = PartitionIntoPerfectMatchings::new( SimpleGraph::new(layout.num_vertices, layout.edges.clone()), 2, diff --git a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs index 619d0d892..f34b5451e 100644 --- a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -186,6 +186,26 @@ fn test_length_bounded_disjoint_paths_serialization() { assert_eq!(round_trip.max_length(), 3); } +#[test] +fn test_deserialization_rejects_invalid_path_parameters() { + let json = serde_json::to_value(sample_problem()).unwrap(); + for (field, value) in [ + ("source", 5), + ("sink", 5), + ("sink", 0), + ("max_length", 0), + ("max_paths", 0), + ("max_paths", 4), + ] { + let mut invalid = json.clone(); + invalid[field] = serde_json::json!(value); + assert!( + serde_json::from_value::>(invalid).is_err(), + "{field}={value}" + ); + } +} + #[test] fn test_length_bounded_disjoint_paths_graph_getter() { let problem = sample_problem(); diff --git a/src/unit_tests/models/graph/minimum_multiway_cut.rs b/src/unit_tests/models/graph/minimum_multiway_cut.rs index c9831cc6e..7a1fc9ca0 100644 --- a/src/unit_tests/models/graph/minimum_multiway_cut.rs +++ b/src/unit_tests/models/graph/minimum_multiway_cut.rs @@ -125,6 +125,22 @@ fn test_minimummultiwaycut_serialization() { assert_eq!(restored.terminals(), &[0, 2]); } +#[test] +fn test_deserialization_rejects_invalid_cut_parameters() { + let problem = MinimumMultiwayCut::new(SimpleGraph::path(3), vec![0, 2], vec![-1i64, 2]); + let json = serde_json::to_value(problem).unwrap(); + for (field, value) in [ + ("terminals", serde_json::json!([0])), + ("terminals", serde_json::json!([0, 0])), + ("terminals", serde_json::json!([0, 3])), + ("edge_weights", serde_json::json!([1])), + ] { + let mut invalid = json.clone(); + invalid[field] = value; + assert!(serde_json::from_value::>(invalid).is_err()); + } +} + #[test] fn test_minimummultiwaycut_name() { assert_eq!( diff --git a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 67ba7ee19..fd55e223e 100644 --- a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -115,3 +115,62 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_rejects_non_unit_weight crate::rules::ReductionError::InvalidTarget { .. } )); } + +#[test] +fn test_self_loops_consume_cover_budget() { + for (edges, bound, cover) in [ + (vec![(0, 0), (0, 1)], 1, vec![true, false, false, false]), + ( + vec![(0, 0), (1, 2), (2, 3)], + 2, + vec![true, false, true, false], + ), + ] { + let source = decision_mvc(4, &edges, &[1; 4], bound); + let result = ReduceTo::>::reduce_to(&source).unwrap(); + let witness = result.build_target_witness(&cover); + assert!(result.target_problem().evaluate(&witness).unwrap().0); + let extracted = result.extract_solution(&witness).unwrap(); + assert!(extracted[0]); + assert!(source.evaluate(&extracted).unwrap().0); + assert!(result.extract_solution(&vec![]).is_err()); + } + for bound in [-1, 0, 1] { + let source = decision_mvc(2, &[(0, 0), (1, 1)], &[1, 1], bound); + let result = ReduceTo::>::reduce_to(&source).unwrap(); + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + assert!(BruteForce::new() + .solve(result.target_problem()) + .unwrap() + .is_none()); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value(&result, crate::types::Or(false)), + crate::types::Or(false) + ); + assert!(result.extract_solution(&vec![0, 1, 2]).is_err()); + } +} + +#[test] +fn test_registered_aggregate_preserves_decision() { + let entries = crate::rules::registry::reduction_entries(); + let edge = entries + .iter() + .find(|edge| { + edge.source_name == "DecisionMinimumVertexCover" + && (edge.source_variant_fn)() + == Decision::>::variant() + && edge.target_name == "HamiltonianCircuit" + }) + .unwrap(); + for bound in [0, 1] { + let source = decision_mvc(1, &[(0, 0)], &[1], bound); + let result = (edge.reduce_aggregate_fn.unwrap())(&source).unwrap(); + assert_eq!( + result + .extract_value_from_solution_dyn(&vec![0usize, 1, 2]) + .unwrap(), + serde_json::json!(bound == 1), + ); + } +} diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 8da900d22..cf109625f 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -76,9 +76,9 @@ fn problem_step() -> ReductionStep { } #[test] -fn completed_decision_recovery_shares_construction_and_handles_both_answers() { +fn decision_chain_shares_construction_for_solution_and_value_mapping() { use crate::models::Decision; - use crate::solvers::{BruteForce, SolveOutcome}; + use crate::solvers::BruteForce; type Cover = MinimumVertexCover; let graph = ReductionGraph::new(); let path = ReductionPath { @@ -93,6 +93,7 @@ fn completed_decision_recovery_shares_construction_and_handles_both_answers() { bound, ); let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + assert!(chain.has_value_mapping()); let step = chain.steps[0].as_ref(); assert!( crate::rules::aggregate_view::>( @@ -110,27 +111,29 @@ fn completed_decision_recovery_shares_construction_and_handles_both_answers() { )); let target = chain.target_problem::(); for solution in BruteForce::new().find_all_witnesses(target).unwrap() { - let result = SolveOutcome::Optimal { - evaluation: target.evaluate(&solution).unwrap().to_string(), - solution: json!(solution), - }; - let recovered = chain.extract_result(&source, &result).unwrap(); - if bound == 1 { - assert_eq!(recovered, SolveOutcome::Infeasible); - } else { - assert!( - matches!(recovered, SolveOutcome::Optimal { evaluation, .. } if evaluation == "Or(true)") - ); - } + let value = serde_json::to_value(target.evaluate(&solution).unwrap()).unwrap(); + assert_eq!(chain.extract_value(value).unwrap(), json!(bound == 2)); + assert_eq!( + chain.extract_solution_json(json!(solution)).is_ok(), + bound == 2 + ); } + assert!(chain.extract_value(json!(true)).is_err()); } } #[test] -fn completed_recovery_propagates_value_only_results_through_multiple_mappings() { +fn solution_and_aggregate_chains_map_values_through_multiple_steps() { use crate::models::formula::CNFClause; - use crate::solvers::{BruteForce, SolveOutcome}; + use crate::solvers::BruteForce; let graph = ReductionGraph::new(); + let path = ReductionPath { + steps: vec![ + problem_step::(), + problem_step::(), + problem_step::>(), + ], + }; for unsatisfiable in [false, true] { let clauses = if unsatisfiable { vec![vec![1], vec![-1]] @@ -138,71 +141,34 @@ fn completed_recovery_propagates_value_only_results_through_multiple_mappings() vec![vec![1]] }; let source = Satisfiability::new(1, clauses.into_iter().map(CNFClause::new).collect()); - let decision_path = ReductionPath { - steps: vec![ - problem_step::(), - problem_step::(), - ], - }; - let decision_chain = graph - .reduce_along_path(&decision_path, &source) - .unwrap() - .unwrap(); - let nae = decision_chain.target_problem::(); - assert!(decision_chain - .extract_solution_json(json!([false, false])) - .is_err()); - let values = graph - .reduce_aggregate_along_path(&decision_path, &source) + let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + let aggregates = graph + .reduce_aggregate_along_path(&path, &source) .unwrap() .unwrap(); - assert_eq!( - values.target_problem::().num_vars(), - nae.num_vars() - ); - let target_result = match BruteForce::new().solve(nae).unwrap() { - Some(solution) => SolveOutcome::Optimal { - evaluation: nae.evaluate(&solution).unwrap().to_string(), - solution: json!(solution), - }, - None => SolveOutcome::Infeasible, - }; - let expected = decision_chain - .extract_result(&source, &target_result) - .unwrap(); - assert_eq!(matches!(expected, SolveOutcome::Infeasible), unsatisfiable); - - let path = ReductionPath { - steps: vec![ - problem_step::(), - problem_step::(), - problem_step::>(), - ], - }; - let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + assert!(chain.has_value_mapping()); let target = chain.target_problem::>(); for solution in BruteForce::new().find_all_witnesses(target).unwrap() { - let result = SolveOutcome::Optimal { - evaluation: target.evaluate(&solution).unwrap().to_string(), - solution: json!(solution), - }; - let recovered = chain.extract_result(&source, &result).unwrap(); - assert_eq!(matches!(recovered, SolveOutcome::Infeasible), unsatisfiable); - if let SolveOutcome::Optimal { solution, .. } = recovered { - assert_eq!( - source - .evaluate(&serde_json::from_value(solution).unwrap()) - .unwrap(), - crate::types::Or(true) - ); + let value = serde_json::to_value(target.evaluate(&solution).unwrap()).unwrap(); + assert_eq!( + chain.extract_value(value.clone()).unwrap(), + json!(!unsatisfiable) + ); + assert_eq!( + aggregates.extract_value(value).unwrap(), + json!(!unsatisfiable) + ); + if !unsatisfiable { + let recovered = chain.extract_solution::, _>(&solution).unwrap(); + assert_eq!(source.evaluate(&recovered).unwrap(), crate::types::Or(true)); } } } } #[test] -fn completed_optimization_recovery_validates_results_and_requires_value_mappings_for_absence() { - use crate::solvers::{BruteForce, SolveOutcome}; +fn solution_only_chain_rejects_value_mapping() { + use crate::solvers::BruteForce; type Independent = MaximumIndependentSet; type Cover = MinimumVertexCover; let source = Independent::new(SimpleGraph::path(3), vec![1; 3]); @@ -213,68 +179,17 @@ fn completed_optimization_recovery_validates_results_and_requires_value_mappings .reduce_along_path(&path, &source) .unwrap() .unwrap(); - let target = chain.target_problem::(); - let solution = BruteForce::new().solve(target).unwrap().unwrap(); - let result = SolveOutcome::Optimal { - evaluation: target.evaluate(&solution).unwrap().to_string(), - solution: json!(solution), - }; - assert!( - matches!(chain.extract_result(&source, &result).unwrap(), SolveOutcome::Optimal { evaluation, .. } if evaluation == "Max(2)") + assert!(!chain.has_value_mapping()); + assert!(chain.extract_value(json!(1)).is_err()); + let solution = BruteForce::new() + .solve(chain.target_problem::()) + .unwrap() + .unwrap(); + let recovered = chain.extract_solution::, _>(&solution).unwrap(); + assert_eq!( + source.evaluate(&recovered).unwrap(), + crate::types::Max(Some(2)) ); - assert!(chain - .extract_result(&source, &SolveOutcome::Infeasible) - .is_err()); - assert!(chain.extract_result(target, &result).is_err()); - for (solution, evaluation) in [ - (json!([true]), "Min(1)"), - (json!([false, false, false]), "Min(None)"), - (json!([false, true, false]), "Min(999)"), - ] { - assert!(chain - .extract_result( - &source, - &SolveOutcome::Optimal { - solution, - evaluation: evaluation.into() - } - ) - .is_err()); - } -} - -#[test] -fn completed_recovery_rejects_inconsistent_value_and_witness_mappings() { - use crate::rules::VariantReductionResult; - use crate::solvers::SolveOutcome; - type Cover = MinimumVertexCover; - let source = Cover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1; 2]); - let target = Cover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![2; 2]); - let chain = ReductionChain { - steps: vec![Box::new(VariantReductionResult::::new( - target, - ))], - aggregate_views: vec![Some( - crate::rules::aggregate_view::>, - )], - path: ReductionPath { - steps: vec![problem_step::(), problem_step::()], - }, - }; - let error = chain - .extract_result( - &source, - &SolveOutcome::Optimal { - solution: json!([true, false]), - evaluation: "Min(2)".into(), - }, - ) - .unwrap_err(); - assert!(error - .to_string() - .contains("does not realize the mapped aggregate")); - let problem: &dyn crate::registry::DynProblem = &source; - assert!(problem.aggregate_witness_evaluation(&json!(true)).is_err()); } #[test] @@ -300,7 +215,7 @@ fn counting_and_universal_values_compose_without_witness_recovery() { assert_eq!(count, Sum(3)); assert_eq!( chain - .extract_value_dyn(serde_json::to_value(count).unwrap()) + .extract_value(serde_json::to_value(count).unwrap()) .unwrap(), json!(3) ); @@ -320,11 +235,11 @@ fn counting_and_universal_values_compose_without_witness_recovery() { .unwrap(); assert_eq!( chain - .extract_value_dyn(serde_json::to_value(value).unwrap()) + .extract_value(serde_json::to_value(value).unwrap()) .unwrap(), json!(tautology) ); - assert!(chain.extract_value_dyn(json!(123)).is_err()); + assert!(chain.extract_value(json!(123)).is_err()); } } @@ -962,8 +877,8 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { chain.target_problem::().dimensions(), vec![1] ); - assert_eq!(chain.extract_value_dyn(json!(7)).unwrap(), json!(12)); - assert!(chain.extract_value_dyn(json!("not an aggregate")).is_err()); + assert_eq!(chain.extract_value(json!(7)).unwrap(), json!(12)); + assert!(chain.extract_value(json!("not an aggregate")).is_err()); } #[test] diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs index a434e2b90..084d666ac 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -285,7 +285,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_registered_aggregate_path() { let optimum = target.evaluate(&best).unwrap(); assert_eq!( chain - .extract_value_dyn(serde_json::to_value(optimum).unwrap()) + .extract_value(serde_json::to_value(optimum).unwrap()) .unwrap(), serde_json::to_value(Or(expected)).unwrap(), ); diff --git a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs index 375509297..6fb987b1a 100644 --- a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs @@ -32,7 +32,11 @@ fn solve_threshold_schedule_via_ilp( target.precedences().to_vec(), ); let pcs_to_ilp = ReduceTo::>::reduce_to(&pcs).expect("reduction should succeed"); - let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem()).ok()?; + let ilp_solution = match ILPSolver::new().solve(pcs_to_ilp.target_problem()) { + Ok(solution) => solution, + Err(crate::solvers::ILPSolveError::Infeasible) => return None, + Err(error) => panic!("threshold solver failed: {error}"), + }; let slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution).unwrap(); let mut config = vec![vec![false; target.d_max()]; target.num_tasks()]; @@ -50,10 +54,14 @@ fn test_ksatisfiability_to_preemptivescheduling_structure() { let target = reduction.target_problem(); assert_eq!(reduction.threshold(), 4); - assert_eq!(target.num_processors(), 6); - assert_eq!(target.num_tasks(), 24); - assert_eq!(target.d_max(), 24); - assert_eq!(target.num_precedences(), 49); + assert_eq!(target.num_processors(), 7); + assert_eq!(target.num_tasks(), 28); + assert_eq!(target.d_max(), 28); + let construction = build_ullman_construction(&source); + assert!(construction + .filler_jobs_by_slot + .iter() + .all(|layer| !layer.is_empty())); assert!(target.lengths().iter().all(|&length| length == 1)); } @@ -132,3 +140,112 @@ fn test_ksatisfiability_to_preemptivescheduling_unsatisfiable_threshold_gap() { "unsatisfiable instance should not admit a schedule by the threshold" ); } + +#[test] +fn test_threshold_value_mapping_and_short_clauses() { + use crate::rules::AggregateReductionResult; + use crate::types::Or; + + for source in [ + KSatisfiability::::new(0, vec![]), + KSatisfiability::::new(2, vec![]), + KSatisfiability::::new_allow_less(0, vec![CNFClause::new(vec![])]), + KSatisfiability::::new_allow_less(1, vec![CNFClause::new(vec![1])]), + KSatisfiability::::new_allow_less(2, vec![CNFClause::new(vec![1, -2])]), + KSatisfiability::::new_allow_less( + 1, + vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])], + ), + yes_single_variable_instance(), + no_single_variable_instance(), + ] { + let result = ReduceTo::::reduce_to(&source).unwrap(); + let expected = crate::solvers::BruteForce::new() + .solve(&source) + .unwrap() + .is_some(); + let target = ReductionResult::target_problem(&result); + if result.threshold() == 0 { + let optimum = crate::solvers::BruteForce::new() + .solve(target) + .unwrap() + .unwrap(); + assert!(!expected); + assert_eq!( + result.extract_value(target.evaluate(&optimum).unwrap()), + Or(false) + ); + assert!(result.extract_solution(&optimum).is_err()); + continue; + } + let schedule = solve_threshold_schedule_via_ilp(target, result.threshold()); + assert_eq!(schedule.is_some(), expected); + assert_eq!( + result.extract_value(Min(Some(result.threshold() as i64 + 1))), + Or(false) + ); + assert_eq!(result.extract_value(Min(None)), Or(false)); + if let Some(schedule) = schedule { + assert!(construct_schedule_from_assignment( + target, + &vec![true; source.num_vars()], + &source + ) + .is_some()); + let value = target.evaluate(&schedule).unwrap(); + assert_eq!(result.extract_value(value), Or(true)); + assert!( + source + .evaluate(&result.extract_solution(&schedule).unwrap()) + .unwrap() + .0 + ); + } + } +} + +#[test] +fn test_extract_rejects_invalid_and_late_schedules() { + let source = yes_single_variable_instance(); + let result = ReduceTo::::reduce_to(&source).unwrap(); + let mut schedule = + construct_schedule_from_assignment(result.target_problem(), &[true], &source).unwrap(); + for task in &mut schedule { + task.rotate_right(1); + } + assert_eq!( + result.target_problem().evaluate(&schedule).unwrap(), + Min(Some(5)) + ); + assert!(result.extract_solution(&schedule).is_err()); + assert!(result + .extract_solution(&vec![ + vec![false; result.target_problem().d_max()]; + schedule.len() + ]) + .is_err()); + assert!(result.extract_solution(&vec![]).is_err()); +} + +#[test] +fn test_registered_aggregate_mapping() { + let entries = crate::rules::registry::reduction_entries(); + let edge = entries + .iter() + .find(|edge| { + edge.source_name == "KSatisfiability" + && (edge.source_variant_fn)() == KSatisfiability::::variant() + && edge.target_name == "PreemptiveScheduling" + }) + .unwrap(); + for (clauses, expected) in [(vec![], true), (vec![CNFClause::new(vec![])], false)] { + let source = KSatisfiability::::new_allow_less(0, clauses); + let result = (edge.reduce_aggregate_fn.unwrap())(&source).unwrap(); + assert_eq!( + result + .extract_value_from_solution_dyn(&vec![vec![true]]) + .unwrap(), + serde_json::json!(expected), + ); + } +} diff --git a/src/unit_tests/rules/minimummultiwaycut_qubo.rs b/src/unit_tests/rules/minimummultiwaycut_qubo.rs index b300fa285..7a48fa954 100644 --- a/src/unit_tests/rules/minimummultiwaycut_qubo.rs +++ b/src/unit_tests/rules/minimummultiwaycut_qubo.rs @@ -4,6 +4,44 @@ use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; +#[test] +fn test_signed_cut_weights_preserve_every_target_optimum() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (1, 1), (0, 1)]); + let solver = BruteForce::new(); + for encoding in 0..81 { + let mut digits = encoding; + let weights = (0..4) + .map(|_| { + let weight = [-2, 0, 3][digits % 3]; + digits /= 3; + weight + }) + .collect(); + let source = MinimumMultiwayCut::new(graph.clone(), vec![0, 2], weights); + let best = solver.solve(&source).unwrap().unwrap(); + let optimum = source.evaluate(&best).unwrap(); + let result = ReduceTo::>::reduce_to(&source).unwrap(); + for target in solver.find_all_witnesses(result.target_problem()).unwrap() { + let recovered = result.extract_solution(&target).unwrap(); + assert_eq!(source.evaluate(&recovered).unwrap(), optimum); + } + } +} + +#[test] +fn test_cut_extraction_rejects_invalid_partitions() { + let source = MinimumMultiwayCut::new(SimpleGraph::path(3), vec![0, 2], vec![-1, 2]); + let result = ReduceTo::>::reduce_to(&source).unwrap(); + for assignment in [ + vec![], + vec![false; 6], + vec![true; 6], + vec![true, false, true, false, true, false], + ] { + assert!(result.extract_solution(&assignment).is_err()); + } +} + #[test] fn test_minimummultiwaycut_to_qubo_closed_loop() { // 5 vertices, terminals {0,2,4}, 6 edges with weights [2,3,1,2,4,5] diff --git a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs index faf837e52..3f0d6b569 100644 --- a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -280,14 +280,82 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_two_literal_clause_no } #[test] -fn test_naesatisfiability_to_partitionintoperfectmatchings_rejects_long_clauses() { - let source = NAESatisfiability::new(4, vec![CNFClause::new(vec![1, 2, 3, 4])]); - let error = - ReduceTo::>::reduce_to(&source).unwrap_err(); - assert!(matches!( - error, - crate::rules::ReductionError::InvalidTarget { .. } - )); +fn test_long_clauses_preserve_assignments() { + let entry = inventory::iter:: + .into_iter() + .find(|e| { + e.source_name == NAESatisfiability::NAME + && e.target_name == PartitionIntoPerfectMatchings::::NAME + }) + .unwrap(); + let contract = entry.parameter_contract().unwrap(); + for literals in [ + vec![1, -2], + vec![1, 2, 3], + vec![1, 2, 3, 4], + vec![1, -2, 1, 3, -4, 2], + vec![1, 1, 1, 1], + ] { + let source = NAESatisfiability::new(4, vec![CNFClause::new(literals)]); + let result = + ReduceTo::>::reduce_to(&source).unwrap(); + let layout = &result.layout; + let bound = contract + .transform() + .unwrap() + .evaluate(&source.parameters()) + .unwrap(); + assert!(layout.num_vertices as u64 <= bound.get("num_vertices").unwrap()); + assert!(layout.edges.len() as u64 <= bound.get("num_edges").unwrap()); + for bits in 0..16 { + let assignment: Vec<_> = (0..4).map(|i| bits & (1 << i) != 0).collect(); + let extendible = (0..(1 << (layout.variables.len() - 4))).any(|aux| { + let mut extended = assignment.clone(); + extended.extend((0..layout.variables.len() - 4).map(|i| aux & (1 << i) != 0)); + layout.clauses.iter().all(|clause| { + let values = clause + .literals + .map(|l| extended[l.unsigned_abs() as usize - 1] == (l > 0)); + values.iter().any(|&v| v) && values.iter().any(|&v| !v) + }) + }); + assert_eq!(extendible, source.evaluate(&assignment).unwrap().0); + if extendible { + let witness = result.construct_target_solution(&assignment); + assert!(result.target_problem().evaluate(&witness).unwrap().0); + assert_eq!(result.extract_solution(&witness).unwrap(), assignment); + } + } + assert!(result + .extract_solution(&vec![0; layout.num_vertices]) + .is_err()); + assert!(result.extract_solution(&vec![]).is_err()); + } +} + +#[test] +fn test_auxiliary_literal_overflow_is_an_error() { + let source = NAESatisfiability::new(i64::MAX as usize, vec![CNFClause::new(vec![1; 4])]); + assert!(ReduceTo::>::reduce_to(&source).is_err()); +} + +#[test] +fn test_registered_partition_value_mapping() { + let source = NAESatisfiability::new(1, vec![]); + let entry = inventory::iter:: + .into_iter() + .find(|e| { + e.source_name == NAESatisfiability::NAME + && e.target_name == PartitionIntoPerfectMatchings::::NAME + }) + .unwrap(); + let result = (entry.reduce_aggregate_fn.unwrap())(&source).unwrap(); + for (assignment, expected) in [(vec![0usize, 0, 1, 1], true), (vec![0; 4], false)] { + assert_eq!( + result.extract_value_from_solution_dyn(&assignment).unwrap(), + serde_json::json!(expected) + ); + } } #[cfg(feature = "example-db")] From 4b7b327c846489da9cad580443363cb095619a69 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 18 Sep 2026 23:56:40 +0800 Subject: [PATCH 03/44] Preserve TSP reduction correctness for signed costs and boundary graphs --- docs/paper/reductions.typ | 10 +- problemreductions-cli/tests/cli_tests.rs | 2 +- src/models/graph/traveling_salesman.rs | 57 +++--- src/rules/travelingsalesman_qubo.rs | 181 ++++++++++++++---- .../models/graph/traveling_salesman.rs | 14 ++ .../rules/travelingsalesman_qubo.rs | 136 +++++++++++++ 6 files changed, 335 insertions(+), 65 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 4b6f4f18d..f13d0a2d6 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -14080,15 +14080,17 @@ The following reductions to Integer Linear Programming are straightforward formu )[ Position-based QUBO encoding @lucas2014 maps a Hamiltonian tour to $n^2$ binary variables $x_(v,p)$, where $x_(v,p) = 1$ iff city $v$ is visited at position $p$. The QUBO Hamiltonian $H = H_A + H_B + H_C$ combines permutation constraints with the distance objective ($n^2$ variables indexed by $v dot n + p$). ][ - _Construction._ For graph $G = (V, E)$ with $n = |V|$ and edge weights $w_(u v)$. Let $A = 1 + sum_((u,v) in E) |w_(u v)|$ be the penalty coefficient. + _Construction._ For $n = |V| >= 3$, discard loops and retain the cheapest edge of each parallel class, recording its original index. Write $E'$ for these retained edges and set $s = min({0} union {w_e : e in E'})$, $c_e = w_e - s >= 0$, and $A = 1 + max(sum_(e in E') c_e, sum_(e in E') |w_e|)$. Every tour uses $n$ edges, so this shift changes every tour cost by the same amount $-n s$. _Variables:_ Binary $x_(v,p) in {0, 1}$ for vertex $v in V$ and position $p in {0, dots, n-1}$. QUBO variable index: $v dot n + p$. - _QUBO matrix:_ (1) Row constraint $H_A = A sum_v (1 - sum_p x_(v,p))^2$: diagonal $Q[v n + p, v n + p] += -A$, off-diagonal $Q[v n + p, v n + p'] += 2A$ for $p < p'$. (2) Column constraint $H_B = A sum_p (1 - sum_v x_(v,p))^2$: symmetric to $H_A$. (3) Distance $H_C = sum_((u,v) in E) w_(u v) sum_p (x_(u,p) x_(v,(p+1) mod n) + x_(v,p) x_(u,(p+1) mod n))$. For non-edges, penalty $A$ replaces $w_(u v)$. + _QUBO matrix:_ (1) Row constraint $H_A = A sum_v (1 - sum_p x_(v,p))^2$: diagonal $Q[v n + p, v n + p] += -A$, off-diagonal $Q[v n + p, v n + p'] += 2A$ for $p < p'$. (2) Column constraint $H_B = A sum_p (1 - sum_v x_(v,p))^2$: symmetric to $H_A$. (3) Distance $H_C = sum_((u,v) in E') c_(u v) sum_p (x_(u,p) x_(v,(p+1) mod n) + x_(v,p) x_(u,(p+1) mod n))$. For non-edges, penalty $A$ replaces $c_(u v)$. The stored energy is $E = H_A + H_B + H_C - 2n A$. - _Correctness._ ($arrow.r.double$) A valid tour defines a permutation matrix satisfying $H_A = H_B = 0$; the $H_C$ terms sum to the tour cost. ($arrow.l.double$) The minimum-energy state has $H_A = H_B = 0$ (penalty $A$ exceeds any tour cost), so it encodes a valid permutation; $H_C$ equals the tour cost, selecting the shortest tour. + _Correctness._ ($arrow.r.double$) A valid tour defines a permutation matrix with $H_A = H_B = 0$ and $H_C <= sum_e c_e < A$. ($arrow.l.double$) All objective terms are nonnegative before dropping the constant. A violated permutation constraint or a permutation using a missing edge costs at least $A$. Consequently, a source tour exists iff the target optimum satisfies $E < A - 2n A$. Below that bound, every optimum encodes a valid tour, and shifting costs preserves their ordering. Choosing the cheapest parallel edge preserves the source optimum. - _Solution extraction._ From QUBO solution $x^*$, for each position $p$ find the unique vertex $v$ with $x^*_(v n + p) = 1$. Map consecutive position pairs to edge indices. + _Solution extraction._ Require energy below $A - 2n A$. For each position $p$, find the unique vertex $v$ with $x^*_(v n + p) = 1$ and map consecutive pairs to the recorded cheapest edge indices. Aggregate recovery returns the source optimum $E + 2n A + n s$ below the bound, or infeasibility otherwise. Construction checks the coefficient arithmetic and requires the nonnegative offset $2n A + n s$ to fit `i64`. + + _Small instances._ The source model uses a connected degree-two edge set: for one vertex, the optimum is its cheapest loop; for two vertices, it is the two cheapest parallel edges joining them. If those edges do not exist, or if there are no vertices, the source is infeasible. These cases map to a zero QUBO with $n^2$ variables and a constant solution/value mapping recording that exact answer. ] #let lcs_mis = load-example("LongestCommonSubsequence", "MaximumIndependentSet") diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 0cd79b5b4..b87129de2 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -9925,7 +9925,7 @@ fn test_extract_rejects_structurally_invalid_one_hot_config() { assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( - stderr.contains("tour position 0 does not select exactly one vertex"), + stderr.contains("target energy does not encode a feasible tour"), "unexpected stderr: {stderr}" ); diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index b658067da..e6d26142f 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -47,7 +47,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`, `KingsSubgraph`) /// * `W` - The weight type for edges (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct TravelingSalesman { /// The underlying graph. graph: G, @@ -55,6 +55,23 @@ pub struct TravelingSalesman { edge_weights: Vec, } +#[derive(Deserialize)] +struct TravelingSalesmanData { + graph: G, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for TravelingSalesman +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = TravelingSalesmanData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct TravelingSalesmanCreateSpec { #[create(codec = "edge-list")] @@ -72,15 +89,7 @@ impl TryFrom for TravelingSalesman TravelingSalesman { /// Create a TravelingSalesman problem from a graph with given edge weights. pub fn new(graph: G, edge_weights: Vec) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self { + Self::try_new(graph, edge_weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, edge_weights: Vec) -> Result { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + Ok(Self { graph, edge_weights, - } + }) } /// Create a TravelingSalesman problem with unit weights. @@ -233,13 +244,11 @@ where let mut total = W::Sum::zero(); for (idx, &selected) in config.iter().enumerate() { if selected { - if let Some(w) = self.edge_weights.get(idx) { - total = W::checked_add_to_sum( - total, - w.to_sum(), - "summing traveling salesman edge weights", - )?; - } + total = W::checked_add_to_sum( + total, + self.edge_weights[idx].to_sum(), + "summing traveling salesman edge weights", + )?; } } Min(Some(total)) diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index 42cbda1a9..f8b952ff7 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -10,7 +10,7 @@ use crate::models::algebraic::QUBO; use crate::models::graph::TravelingSalesman; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::topology::{Graph, SimpleGraph}; +use crate::topology::SimpleGraph; use std::collections::HashMap; /// Result of reducing TravelingSalesman to QUBO. @@ -20,6 +20,9 @@ pub struct ReductionTravelingSalesmanToQUBO { num_vertices: usize, num_edges: usize, edge_index: HashMap<(usize, usize), usize>, + objective_offset: i64, + feasible_energy_upper: i128, + small_optimum: Option<(Vec, i64)>, } impl ReductionResult for ReductionTravelingSalesmanToQUBO { @@ -38,7 +41,24 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if crate::rules::AggregateReductionResult::extract_value(self, value) + .0 + .is_none() + { + return Err(crate::rules::ExtractionError::invalid( + "target energy does not encode a feasible tour", + )); + } + if self.num_vertices < 3 { + return Ok(self + .small_optimum + .as_ref() + .expect("value mapping established a small tour") + .0 + .clone()); + } Ok({ let n = self.num_vertices; @@ -75,7 +95,35 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { } } +impl crate::rules::AggregateReductionResult for ReductionTravelingSalesmanToQUBO { + type Source = TravelingSalesman; + type Target = QUBO; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Min { + if self.num_vertices < 3 { + return crate::types::Min( + value + .0 + .and(self.small_optimum.as_ref().map(|(_, cost)| *cost)), + ); + } + // The offset is nonnegative; below the feasibility bound, the sum is + // less than A + n * shift <= A, so addition cannot overflow in either direction. + crate::types::Min( + value + .0 + .filter(|&energy| i128::from(energy) < self.feasible_energy_upper) + .map(|energy| energy + self.objective_offset), + ) + } +} + #[reduction( + aggregate = custom, transform = exact { num_vars = "num_vertices^2", } @@ -87,43 +135,102 @@ impl ReduceTo> for TravelingSalesman { let n = self.num_vertices(); let edges = self.edges(); - // Build edge weight map (both directions for undirected lookup) let overflow = |operation| { - crate::rules::ReductionError::integer_overflow::< - TravelingSalesman, - QUBO, - >(operation) + crate::rules::ReductionError::integer_overflow::>(operation) }; - let mut edge_weight_map: HashMap<(usize, usize), i64> = HashMap::new(); - let mut weight_sum = 0i64; - for &(u, v, w) in &edges { - edge_weight_map.insert((u, v), w); - edge_weight_map.insert((v, u), w); - let magnitude = w - .checked_abs() - .ok_or_else(|| overflow("taking the absolute value of a tour weight"))?; - weight_sum = weight_sum - .checked_add(magnitude) - .ok_or_else(|| overflow("summing absolute tour weights"))?; + let num_edges = edges.len(); + let dim = n + .checked_mul(n) + .ok_or_else(|| overflow("computing the number of QUBO variables"))?; + + // The source represents a connected degree-two edge set. With fewer + // than three vertices this means one loop or two parallel edges. + if n < 3 { + let mut candidates: Vec = edges + .iter() + .enumerate() + .filter(|&(_, &(u, v, _))| (n == 1 && u == v) || (n == 2 && u != v)) + .map(|(index, _)| index) + .collect(); + + let small_optimum = if n > 0 && candidates.len() >= n { + candidates.select_nth_unstable_by_key(n - 1, |&index| (edges[index].2, index)); + let mut solution = vec![false; num_edges]; + let mut cost = 0i64; + for &index in &candidates[..n] { + solution[index] = true; + cost = cost + .checked_add(edges[index].2) + .ok_or_else(|| overflow("summing a small tour cost"))?; + } + Some((solution, cost)) + } else { + None + }; + return Ok(ReductionTravelingSalesmanToQUBO { + target: QUBO::from_matrix(vec![vec![0; dim]; dim]) + .map_err(>>::target_construction)?, + num_vertices: n, + num_edges, + edge_index: HashMap::new(), + objective_offset: 0, + feasible_energy_upper: 0, + small_optimum, + }); } - // Build edge index map: canonical (min, max) → edge index - let graph_edges = self.graph().edges(); - let num_edges = graph_edges.len(); + // A tour on at least three vertices uses no loops and at most one + // edge per endpoint pair. Retain the cheapest parallel edge. let mut edge_index: HashMap<(usize, usize), usize> = HashMap::new(); - for (idx, &(u, v)) in graph_edges.iter().enumerate() { - edge_index.insert((u.min(v), u.max(v)), idx); + for (index, &(u, v, weight)) in edges.iter().enumerate() { + if u == v { + continue; + } + let key = (u.min(v), u.max(v)); + edge_index + .entry(key) + .and_modify(|previous| { + if weight < edges[*previous].2 { + *previous = index; + } + }) + .or_insert(index); } - - // Penalty weight: must exceed any possible tour cost - let a = weight_sum + let shift = edge_index + .values() + .map(|&index| edges[index].2) + .fold(0, i64::min); + let mut shifted_sum = 0i64; + let mut absolute_sum = 0i64; + for &index in edge_index.values() { + let weight = edges[index].2; + absolute_sum = absolute_sum + .checked_add( + weight + .checked_abs() + .ok_or_else(|| overflow("taking the absolute value of a tour weight"))?, + ) + .ok_or_else(|| overflow("summing absolute tour weights"))?; + let shifted = weight + .checked_sub(shift) + .ok_or_else(|| overflow("shifting a tour weight"))?; + shifted_sum = shifted_sum + .checked_add(shifted) + .ok_or_else(|| overflow("summing shifted tour weights"))?; + } + // Every permutation tour uses n edges. Shifting each cost therefore + // adds a constant. All costs are now nonnegative even off-premise. + let a = shifted_sum + .max(absolute_sum) .checked_add(1) .ok_or_else(|| overflow("computing the tour penalty"))?; + let omitted_constant = 2 * n as i128 * i128::from(a); + // A >= |shift| makes this offset positive. Check its transport once. + let objective_offset = i64::try_from(omitted_constant + n as i128 * i128::from(shift)) + .map_err(|_| overflow("computing the tour objective offset"))?; + let feasible_energy_upper = i128::from(a) - omitted_constant; // Build n^2 x n^2 upper-triangular QUBO matrix - let dim = n - .checked_mul(n) - .ok_or_else(|| overflow("computing the number of QUBO variables"))?; let mut matrix = vec![vec![0i64; dim]; dim]; // Helper: add value to upper-triangular position @@ -189,7 +296,10 @@ impl ReduceTo> for TravelingSalesman { // For each pair (u, v), add cost for x_{u,p} * x_{v,p_next} and x_{v,p} * x_{u,p_next} for u in 0..n { for v in (u + 1)..n { - let cost = edge_weight_map.get(&(u, v)).copied().unwrap_or(a); + let cost = edge_index.get(&(u, v)).map_or(a, |&index| { + // The bound calculation already checked this subtraction. + edges[index].2 - shift + }); for p in 0..n { let p_next = (p + 1) % n; // x_{u,p} * x_{v,p_next} @@ -200,18 +310,17 @@ impl ReduceTo> for TravelingSalesman { } } - let target = QUBO::from_matrix(matrix).map_err(|message| { - crate::rules::ReductionError::construction::< - TravelingSalesman, - QUBO, - >(message) - })?; + let target = QUBO::from_matrix(matrix) + .map_err(>>::target_construction)?; Ok(ReductionTravelingSalesmanToQUBO { target, num_vertices: n, num_edges, edge_index, + objective_offset, + feasible_energy_upper, + small_optimum: None, }) } } diff --git a/src/unit_tests/models/graph/traveling_salesman.rs b/src/unit_tests/models/graph/traveling_salesman.rs index ef89561a5..4613b9a0d 100644 --- a/src/unit_tests/models/graph/traveling_salesman.rs +++ b/src/unit_tests/models/graph/traveling_salesman.rs @@ -292,3 +292,17 @@ fn create_spec_uses_edge_weights_and_defaults_to_one() { assert_eq!(problem.weights(), vec![1, 1, 1]); assert_eq!(TravelingSalesmanCreateSpec::FIELDS[2].name, "edge_weights"); } + +#[test] +fn test_deserialization_rejects_mismatched_edge_weights() { + let problem = TravelingSalesman::new(SimpleGraph::complete(3), vec![-3i64, 0, 2]); + let json = serde_json::to_value(&problem).unwrap(); + let restored: TravelingSalesman = + serde_json::from_value(json.clone()).unwrap(); + assert_eq!(restored.evaluate(&vec![true; 3]).unwrap(), Min(Some(-1))); + for weights in [serde_json::json!([]), serde_json::json!([1, 2, 3, 4])] { + let mut invalid = json.clone(); + invalid["edge_weights"] = weights; + assert!(serde_json::from_value::>(invalid).is_err()); + } +} diff --git a/src/unit_tests/rules/travelingsalesman_qubo.rs b/src/unit_tests/rules/travelingsalesman_qubo.rs index 77199d7e3..f688c8d1b 100644 --- a/src/unit_tests/rules/travelingsalesman_qubo.rs +++ b/src/unit_tests/rules/travelingsalesman_qubo.rs @@ -4,6 +4,142 @@ use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; +fn assert_tour_recovery(source: TravelingSalesman) { + let solver = BruteForce::new(); + let expected = solver + .solve(&source) + .unwrap() + .map(|solution| source.evaluate(&solution).unwrap()) + .unwrap_or(Min(None)); + let result = ReduceTo::>::reduce_to(&source).unwrap(); + assert_eq!( + result.target_problem().num_vars(), + source.num_vertices().pow(2) + ); + for witness in solver.find_all_witnesses(result.target_problem()).unwrap() { + let energy = result.target_problem().evaluate(&witness).unwrap(); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value(&result, energy), + expected + ); + match expected.0 { + Some(_) => assert_eq!( + source + .evaluate(&result.extract_solution(&witness).unwrap()) + .unwrap(), + expected + ), + None => assert!(result.extract_solution(&witness).is_err()), + } + } +} + +#[test] +fn test_signed_tour_costs_preserve_all_optima() { + for encoding in 0..27 { + let mut digits = encoding; + let weights = (0..3) + .map(|_| { + let weight = [-3, 0, 2][digits % 3]; + digits /= 3; + weight + }) + .collect(); + assert_tour_recovery(TravelingSalesman::new(SimpleGraph::complete(3), weights)); + } + assert_tour_recovery(TravelingSalesman::new(SimpleGraph::path(3), vec![-5, 1])); + assert_tour_recovery(TravelingSalesman::new( + SimpleGraph::complete(4), + vec![-9, 1, 2, 3, -4, 8], + )); +} + +#[test] +fn test_tours_with_parallel_edges_and_loops() { + for weights in [ + vec![1, 2, 3, 9, -100], + vec![9, 2, 3, 1, -100], + vec![1, 2, 3, 1, -100], + ] { + assert_tour_recovery(TravelingSalesman::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0), (1, 0), (0, 0)]), + weights, + )); + } +} + +#[test] +fn test_small_tours_follow_edge_set_definition() { + for (n, edges, weights) in [ + (0, vec![], vec![]), + (1, vec![], vec![]), + (1, vec![(0, 0), (0, 0)], vec![8, -2]), + (1, vec![(0, 0)], vec![i64::MIN]), + (2, vec![(0, 1)], vec![1]), + ( + 2, + vec![(0, 1), (1, 0), (0, 1), (0, 0)], + vec![8, -2, 1, -100], + ), + ] { + assert_tour_recovery(TravelingSalesman::new(SimpleGraph::new(n, edges), weights)); + } +} + +#[test] +fn test_tour_numeric_limits_fail_during_construction() { + for source in [ + TravelingSalesman::new(SimpleGraph::complete(3), vec![i64::MIN, 0, 0]), + TravelingSalesman::new(SimpleGraph::complete(3), vec![i64::MAX, 1, 1]), + TravelingSalesman::new(SimpleGraph::complete(3), vec![i64::MAX / 10; 3]), + TravelingSalesman::new(SimpleGraph::new(2, vec![(0, 1), (1, 0)]), vec![i64::MAX; 2]), + ] { + assert!(matches!( + ReduceTo::>::reduce_to(&source), + Err(crate::rules::ReductionError::IntegerOverflow { .. }) + )); + } +} + +#[test] +fn test_tour_value_mapping_and_invalid_configurations() { + let source = TravelingSalesman::new(SimpleGraph::complete(3), vec![-3, 0, 2]); + let result = ReduceTo::>::reduce_to(&source).unwrap(); + for config in [ + vec![], + vec![false; 9], + vec![true; 9], + vec![true, true, true, false, false, false, false, false, false], + ] { + assert!(result.extract_solution(&config).is_err()); + } + assert_eq!( + crate::rules::AggregateReductionResult::extract_value(&result, Min(None)), + Min(None) + ); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value(&result, Min(Some(i64::MAX))), + Min(None) + ); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value(&result, Min(Some(i64::MIN))), + Min(Some(i64::MIN + result.objective_offset)) + ); + let entry = inventory::iter:: + .into_iter() + .find(|entry| entry.source_name == "TravelingSalesman" && entry.target_name == "QUBO") + .unwrap(); + let dynamic = (entry.reduce_aggregate_fn.unwrap())(&source).unwrap(); + let optimum = BruteForce::new() + .solve(result.target_problem()) + .unwrap() + .unwrap(); + assert_eq!( + dynamic.extract_value_from_solution_dyn(&optimum).unwrap(), + serde_json::json!(-1) + ); +} + #[test] fn test_travelingsalesman_to_qubo_closed_loop() { // K3 complete graph with weights [1, 2, 3] From 8c99ce66a1251064cee10a3aa1dddac8ad650074 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 19 Sep 2026 04:07:18 +0800 Subject: [PATCH 04/44] fix: enforce model construction constraints when loading JSON --- problemreductions-cli/tests/cli_tests.rs | 21 +++ src/models/algebraic/minimum_matrix_cover.rs | 32 +++- .../graph/biconnectivity_augmentation.rs | 84 ++++++--- .../bounded_component_spanning_forest.rs | 75 ++++++-- .../graph/bounded_diameter_spanning_tree.rs | 97 +++++++---- .../graph/degree_constrained_spanning_tree.rs | 32 +++- src/models/graph/disjoint_connecting_paths.rs | 64 ++++--- src/models/graph/generalized_hex.rs | 47 ++++- .../hamiltonian_path_between_two_vertices.rs | 61 +++++-- src/models/graph/kclique.rs | 33 +++- src/models/graph/longest_circuit.rs | 69 +++++--- src/models/graph/longest_path.rs | 107 ++++++++---- src/models/graph/max_cut.rs | 37 +++- src/models/graph/maximal_is.rs | 35 +++- src/models/graph/maximum_clique.rs | 37 +++- src/models/graph/maximum_co_k_plex.rs | 57 +++++-- src/models/graph/maximum_independent_set.rs | 33 +++- .../graph/maximum_leaf_spanning_tree.rs | 31 +++- src/models/graph/maximum_matching.rs | 47 +++-- src/models/graph/min_max_multicenter.rs | 87 +++++++--- .../minimum_capacitated_spanning_tree.rs | 94 +++++++--- .../graph/minimum_cut_into_bounded_sets.rs | 69 ++++++-- src/models/graph/minimum_dominating_set.rs | 37 +++- src/models/graph/minimum_feedback_arc_set.rs | 51 ++++-- .../graph/minimum_feedback_vertex_set.rs | 53 ++++-- src/models/graph/minimum_sum_multicenter.rs | 63 +++++-- src/models/graph/minimum_vertex_cover.rs | 37 +++- src/models/graph/partition_into_cliques.rs | 37 +++- src/models/graph/partition_into_forests.rs | 30 +++- .../graph/partition_into_paths_of_length_2.rs | 38 ++++- .../graph/partition_into_perfect_matchings.rs | 39 ++++- src/models/graph/partition_into_triangles.rs | 37 +++- src/models/graph/rural_postman.rs | 65 +++++-- .../graph/shortest_weight_constrained_path.rs | 143 ++++++++++------ .../misc/minimum_tardiness_sequencing.rs | 161 +++++++++--------- src/models/set/minimum_set_covering.rs | 59 +++++-- .../models/algebraic/minimum_matrix_cover.rs | 15 ++ .../graph/biconnectivity_augmentation.rs | 28 +++ .../bounded_component_spanning_forest.rs | 27 +++ .../graph/bounded_diameter_spanning_tree.rs | 41 +++++ .../graph/degree_constrained_spanning_tree.rs | 29 ++++ .../models/graph/disjoint_connecting_paths.rs | 26 +++ .../models/graph/generalized_hex.rs | 22 +++ .../hamiltonian_path_between_two_vertices.rs | 24 +++ src/unit_tests/models/graph/kclique.rs | 17 ++ .../models/graph/longest_circuit.rs | 22 +++ src/unit_tests/models/graph/longest_path.rs | 22 +++ src/unit_tests/models/graph/max_cut.rs | 16 ++ src/unit_tests/models/graph/maximal_is.rs | 16 ++ src/unit_tests/models/graph/maximum_clique.rs | 17 ++ .../models/graph/maximum_co_k_plex.rs | 40 +++++ .../models/graph/maximum_independent_set.rs | 18 ++ .../graph/maximum_leaf_spanning_tree.rs | 17 ++ .../models/graph/maximum_matching.rs | 17 ++ .../models/graph/min_max_multicenter.rs | 26 +++ .../minimum_capacitated_spanning_tree.rs | 26 +++ .../graph/minimum_cut_into_bounded_sets.rs | 25 +++ .../models/graph/minimum_dominating_set.rs | 18 ++ .../models/graph/minimum_feedback_arc_set.rs | 16 ++ .../graph/minimum_feedback_vertex_set.rs | 16 ++ .../models/graph/minimum_sum_multicenter.rs | 25 +++ .../models/graph/minimum_vertex_cover.rs | 18 ++ .../models/graph/partition_into_cliques.rs | 22 +++ .../models/graph/partition_into_forests.rs | 17 ++ .../graph/partition_into_paths_of_length_2.rs | 17 ++ .../graph/partition_into_perfect_matchings.rs | 24 +++ .../models/graph/partition_into_triangles.rs | 17 ++ src/unit_tests/models/graph/rural_postman.rs | 20 +++ .../graph/shortest_weight_constrained_path.rs | 28 +++ .../misc/minimum_tardiness_sequencing.rs | 37 ++++ .../models/set/minimum_set_covering.rs | 20 +++ ...ionintocliques_minimumcoveringbycliques.rs | 17 +- tests/suites/reductions.rs | 5 +- 73 files changed, 2300 insertions(+), 607 deletions(-) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index b87129de2..1f3d891b5 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1,5 +1,26 @@ use std::process::Command; +#[test] +fn test_evaluate_rejects_invalid_model_json_without_panicking() { + use std::io::Write; + let mut child = pred() + .args(["evaluate", "-", "--config", "[true]"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(br#"{"type":"MaximumIndependentSet","variant":{"graph":"SimpleGraph","weight":"i64"},"data":{"graph":{"num_vertices":1,"edges":[]},"weights":[]}}"#).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("weights length must match graph num_vertices"), + "{stderr}" + ); + assert!(!stderr.contains("panicked"), "{stderr}"); +} + fn pred() -> Command { Command::new(env!("CARGO_BIN_EXE_pred")) } diff --git a/src/models/algebraic/minimum_matrix_cover.rs b/src/models/algebraic/minimum_matrix_cover.rs index 8df124c06..f637f08dd 100644 --- a/src/models/algebraic/minimum_matrix_cover.rs +++ b/src/models/algebraic/minimum_matrix_cover.rs @@ -50,12 +50,23 @@ inventory::submit! { /// let witness = solver.solve(&problem).unwrap(); /// assert!(witness.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumMatrixCover { /// The n×n nonnegative integer matrix. matrix: Vec>, } +impl<'de> Deserialize<'de> for MinimumMatrixCover { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct Data { + matrix: Vec>, + } + let data = Data::deserialize(deserializer)?; + Self::try_new(data.matrix).map_err(serde::de::Error::custom) + } +} + impl MinimumMatrixCover { /// Create a new MinimumMatrixCover instance. /// @@ -63,16 +74,21 @@ impl MinimumMatrixCover { /// /// Panics if the matrix is not square or has inconsistent row lengths. pub fn new(matrix: Vec>) -> Self { + Self::try_new(matrix).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(matrix: Vec>) -> Result { let n = matrix.len(); for (i, row) in matrix.iter().enumerate() { - assert_eq!( - row.len(), - n, - "Matrix must be square: row {i} has {} columns, expected {n}", - row.len() - ); + if row.len() != n { + return Err(format!( + "Matrix must be square: row {i} has {} columns, expected {n}", + row.len() + ) + .into()); + } } - Self { matrix } + Ok(Self { matrix }) } /// Returns the number of rows (= columns) of the matrix. diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index 994a13a06..44b2a1a8c 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -36,11 +36,8 @@ inventory::submit! { /// determine whether there exists a subset of potential edges `E'` such that: /// - `sum_{e in E'} w(e) <= B` /// - `(V, E union E')` is biconnected -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound( - serialize = "G: serde::Serialize, W: serde::Serialize, W::Sum: serde::Serialize", - deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>, W::Sum: serde::Deserialize<'de>" -))] +#[derive(Debug, Clone, Serialize)] +#[serde(bound(serialize = "G: serde::Serialize, W: serde::Serialize, W::Sum: serde::Serialize"))] pub struct BiconnectivityAugmentation where W: WeightElement, @@ -53,6 +50,29 @@ where budget: W::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct BiconnectivityAugmentationData { + graph: G, + potential_weights: Vec<(usize, usize, W)>, + budget: W::Sum, +} + +impl<'de, G, W> Deserialize<'de> for BiconnectivityAugmentation +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = BiconnectivityAugmentationData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.potential_weights, data.budget) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BiconnectivityAugmentationCreateSpec { #[create(codec = "edge-list")] @@ -120,37 +140,47 @@ impl BiconnectivityAugmentation { /// is a self-loop, duplicates another candidate edge, or already exists in /// the input graph. pub fn new(graph: G, potential_weights: Vec<(usize, usize, W)>, budget: W::Sum) -> Self { + Self::try_new(graph, potential_weights, budget).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + potential_weights: Vec<(usize, usize, W)>, + budget: W::Sum, + ) -> Result { let num_vertices = graph.num_vertices(); let mut seen_potential_edges = BTreeSet::new(); for &(u, v, _) in &potential_weights { - assert!( - u < num_vertices && v < num_vertices, - "potential edge ({}, {}) references vertex >= num_vertices ({})", - u, - v, - num_vertices - ); - assert!(u != v, "potential edge ({}, {}) is a self-loop", u, v); + if u >= num_vertices || v >= num_vertices { + return Err(format!( + "potential edge ({}, {}) references vertex >= num_vertices ({})", + u, v, num_vertices + ) + .into()); + } + if u == v { + return Err(format!("potential edge ({}, {}) is a self-loop", u, v).into()); + } let edge = normalize_edge(u, v); - assert!( - !graph.has_edge(edge.0, edge.1), - "potential edge ({}, {}) already exists in the graph", - edge.0, - edge.1 - ); - assert!( - seen_potential_edges.insert(edge), - "potential edge ({}, {}) is duplicated", - edge.0, - edge.1 - ); + if graph.has_edge(edge.0, edge.1) { + return Err(format!( + "potential edge ({}, {}) already exists in the graph", + edge.0, edge.1 + ) + .into()); + } + if !seen_potential_edges.insert(edge) { + return Err( + format!("potential edge ({}, {}) is duplicated", edge.0, edge.1).into(), + ); + } } - Self { + Ok(Self { graph, potential_weights, budget, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 155532cbc..9b240d754 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -34,7 +34,7 @@ inventory::submit! { /// integer `K`, and a bound `B`, determine whether the vertices can be /// partitioned into at most `K` non-empty sets such that every set induces a /// connected subgraph and the total weight of each set is at most `B`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct BoundedComponentSpanningForest { /// The underlying graph. graph: G, @@ -46,6 +46,35 @@ pub struct BoundedComponentSpanningForest { max_weight: W::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct BoundedComponentSpanningForestData { + graph: G, + weights: Vec, + max_components: usize, + max_weight: W::Sum, +} + +impl<'de, G, W> Deserialize<'de> for BoundedComponentSpanningForest +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = BoundedComponentSpanningForestData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.weights, + data.max_components, + data.max_weight, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BoundedComponentSpanningForestCreateSpec { /// The underlying graph G=(V,E). @@ -81,32 +110,44 @@ impl TryFrom if spec.max_weight <= 0 { return Err("max_weight must be positive".to_string().into()); } - Ok(Self::new(spec.graph, spec.weights, spec.k, spec.max_weight)) + Self::try_new(spec.graph, spec.weights, spec.k, spec.max_weight) } } impl BoundedComponentSpanningForest { /// Create a new bounded-component spanning forest instance. pub fn new(graph: G, weights: Vec, max_components: usize, max_weight: W::Sum) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - assert!( - weights - .iter() - .all(|weight| weight.to_sum() >= W::Sum::zero()), - "weights must be nonnegative" - ); - assert!(max_components >= 1, "max_components must be at least 1"); - assert!(max_weight > W::Sum::zero(), "max_weight must be positive"); - Self { + Self::try_new(graph, weights, max_components, max_weight) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + weights: Vec, + max_components: usize, + max_weight: W::Sum, + ) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + if !weights + .iter() + .all(|weight| weight.to_sum() >= W::Sum::zero()) + { + return Err("weights must be nonnegative".into()); + } + if max_components == 0 { + return Err("max_components must be at least 1".into()); + } + if max_weight.partial_cmp(&W::Sum::zero()) != Some(std::cmp::Ordering::Greater) { + return Err("max_weight must be positive".into()); + } + Ok(Self { graph, weights, max_components, max_weight, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index e156f4525..6cd1c6b54 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -59,10 +59,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound( - deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>, W::Sum: serde::Deserialize<'de>" -))] +#[derive(Debug, Clone, Serialize)] pub struct BoundedDiameterSpanningTree { /// The underlying graph. graph: G, @@ -76,6 +73,35 @@ pub struct BoundedDiameterSpanningTree { edge_list: Vec<(usize, usize)>, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct BoundedDiameterSpanningTreeData { + graph: G, + edge_weights: Vec, + weight_bound: W::Sum, + diameter_bound: usize, +} + +impl<'de, G, W> Deserialize<'de> for BoundedDiameterSpanningTree +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = BoundedDiameterSpanningTreeData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_weights, + data.weight_bound, + data.diameter_bound, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BoundedDiameterSpanningTreeCreateSpec { #[create(codec = "edge-list")] @@ -114,12 +140,7 @@ impl TryFrom if spec.diameter_bound == 0 { return Err("diameter_bound must be at least 1".to_string().into()); } - Ok(Self::new( - graph, - edge_weights, - spec.weight_bound, - spec.diameter_bound, - )) + Self::try_new(graph, edge_weights, spec.weight_bound, spec.diameter_bound) } } @@ -163,26 +184,32 @@ impl BoundedDiameterSpanningTree { weight_bound: W::Sum, diameter_bound: usize, ) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); + Self::try_new(graph, edge_weights, weight_bound, diameter_bound) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_weights: Vec, + weight_bound: W::Sum, + diameter_bound: usize, + ) -> Result { + Self::check_weights(&graph, &edge_weights)?; let zero = W::Sum::zero(); - assert!( - edge_weights.iter().all(|w| w.to_sum() > zero.clone()), - "All edge weights must be positive (> 0)" - ); - assert!(weight_bound > zero, "weight_bound must be positive (> 0)"); - assert!(diameter_bound >= 1, "diameter_bound must be at least 1"); + if weight_bound.partial_cmp(&zero) != Some(std::cmp::Ordering::Greater) { + return Err("weight_bound must be positive (> 0)".into()); + } + if diameter_bound == 0 { + return Err("diameter_bound must be at least 1".into()); + } let edge_list = graph.edges(); - Self { + Ok(Self { graph, edge_weights, weight_bound, diameter_bound, edge_list, - } + }) } /// Get a reference to the underlying graph. @@ -197,19 +224,23 @@ impl BoundedDiameterSpanningTree { /// Set new edge weights. pub fn set_weights(&mut self, edge_weights: Vec) { - assert_eq!( - edge_weights.len(), - self.graph.num_edges(), - "edge_weights length must match num_edges" - ); - let zero = W::Sum::zero(); - assert!( - edge_weights.iter().all(|w| w.to_sum() > zero.clone()), - "All edge weights must be positive (> 0)" - ); + Self::check_weights(&self.graph, &edge_weights).unwrap_or_else(|error| panic!("{error}")); self.edge_weights = edge_weights; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + if !weights + .iter() + .all(|weight| weight.to_sum() > W::Sum::zero()) + { + return Err("All edge weights must be positive (> 0)".into()); + } + Ok(()) + } + /// Get the weight bound B. pub fn weight_bound(&self) -> &W::Sum { &self.weight_bound diff --git a/src/models/graph/degree_constrained_spanning_tree.rs b/src/models/graph/degree_constrained_spanning_tree.rs index 005289d25..0978dd16d 100644 --- a/src/models/graph/degree_constrained_spanning_tree.rs +++ b/src/models/graph/degree_constrained_spanning_tree.rs @@ -55,8 +55,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct DegreeConstrainedSpanningTree { /// The underlying graph. graph: G, @@ -66,19 +65,42 @@ pub struct DegreeConstrainedSpanningTree { edge_list: Vec<(usize, usize)>, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct DegreeConstrainedSpanningTreeData { + graph: G, + max_degree: usize, +} + +impl<'de, G> Deserialize<'de> for DegreeConstrainedSpanningTree +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = DegreeConstrainedSpanningTreeData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.max_degree).map_err(serde::de::Error::custom) + } +} + impl DegreeConstrainedSpanningTree { /// Create a new Degree-Constrained Spanning Tree instance. /// /// # Panics /// Panics if `max_degree` is zero. pub fn new(graph: G, max_degree: usize) -> Self { - assert!(max_degree >= 1, "max_degree must be at least 1"); + Self::try_new(graph, max_degree).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, max_degree: usize) -> Result { + if max_degree == 0 { + return Err("max_degree must be at least 1".into()); + } let edge_list = graph.edges(); - Self { + Ok(Self { graph, max_degree, edge_list, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index 551e97e0f..281a0c841 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -30,13 +30,29 @@ inventory::submit! { /// A configuration uses one binary variable per edge in the graph's canonical /// sorted edge list. A valid solution selects exactly the edges of one simple /// path for each terminal pair, with all such paths pairwise vertex-disjoint. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct DisjointConnectingPaths { graph: G, terminal_pairs: Vec<(usize, usize)>, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct DisjointConnectingPathsData { + graph: G, + terminal_pairs: Vec<(usize, usize)>, +} + +impl<'de, G> Deserialize<'de> for DisjointConnectingPaths +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = DisjointConnectingPathsData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.terminal_pairs).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct DisjointConnectingPathsCreateSpec { #[create(codec = "edge-list")] @@ -101,33 +117,43 @@ impl DisjointConnectingPaths { /// Panics if no terminal pairs are provided, if a pair uses invalid or /// repeated endpoints, or if any terminal appears in more than one pair. pub fn new(graph: G, terminal_pairs: Vec<(usize, usize)>) -> Self { - assert!( - !terminal_pairs.is_empty(), - "terminal_pairs must contain at least one pair" - ); + Self::try_new(graph, terminal_pairs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + terminal_pairs: Vec<(usize, usize)>, + ) -> Result { + if terminal_pairs.is_empty() { + return Err("terminal_pairs must contain at least one pair".into()); + } let num_vertices = graph.num_vertices(); let mut used = vec![false; num_vertices]; for &(source, sink) in &terminal_pairs { - assert!(source < num_vertices, "terminal pair source out of bounds"); - assert!(sink < num_vertices, "terminal pair sink out of bounds"); - assert_ne!(source, sink, "terminal pair endpoints must be distinct"); - assert!( - !used[source], - "terminal vertices must be pairwise disjoint across pairs" - ); - assert!( - !used[sink], - "terminal vertices must be pairwise disjoint across pairs" - ); + if source >= num_vertices { + return Err("terminal pair source out of bounds".into()); + } + if sink >= num_vertices { + return Err("terminal pair sink out of bounds".into()); + } + if source == sink { + return Err("terminal pair endpoints must be distinct".into()); + } + if used[source] { + return Err("terminal vertices must be pairwise disjoint across pairs".into()); + } + if used[sink] { + return Err("terminal vertices must be pairwise disjoint across pairs".into()); + } used[source] = true; used[sink] = true; } - Self { + Ok(Self { graph, terminal_pairs, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index 7e91bc9b9..14188eb3e 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -32,14 +32,31 @@ inventory::submit! { /// The problem is represented as a zero-variable decision problem: the graph /// instance fully determines the question, so `evaluate([])` runs a memoized /// game-tree search from the initial empty board. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct GeneralizedHex { graph: G, source: usize, target: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct GeneralizedHexData { + graph: G, + source: usize, + target: usize, +} + +impl<'de, G> Deserialize<'de> for GeneralizedHex +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = GeneralizedHexData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.source, data.target).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct GeneralizedHexCreateSpec { /// The underlying graph G=(V,E). @@ -72,7 +89,7 @@ impl TryFrom for GeneralizedHex { if spec.source == spec.sink { return Err("source and sink must be distinct".to_string().into()); } - Ok(Self::new(spec.graph, spec.source, spec.sink)) + Self::try_new(spec.graph, spec.source, spec.sink) } } @@ -86,15 +103,29 @@ enum ClaimState { impl GeneralizedHex { /// Create a new Generalized Hex instance. pub fn new(graph: G, source: usize, target: usize) -> Self { + Self::try_new(graph, source, target).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + source: usize, + target: usize, + ) -> Result { let num_vertices = graph.num_vertices(); - assert!(source < num_vertices, "source must be a valid graph vertex"); - assert!(target < num_vertices, "target must be a valid graph vertex"); - assert_ne!(source, target, "source and target must be distinct"); - Self { + if source >= num_vertices { + return Err("source must be a valid graph vertex".into()); + } + if target >= num_vertices { + return Err("target must be a valid graph vertex".into()); + } + if source == target { + return Err("source and target must be distinct".into()); + } + Ok(Self { graph, source, target, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/hamiltonian_path_between_two_vertices.rs b/src/models/graph/hamiltonian_path_between_two_vertices.rs index 7ae5bbbcd..de5a26c1c 100644 --- a/src/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/models/graph/hamiltonian_path_between_two_vertices.rs @@ -68,14 +68,32 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct HamiltonianPathBetweenTwoVertices { graph: G, source_vertex: usize, target_vertex: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct HamiltonianPathBetweenTwoVerticesData { + graph: G, + source_vertex: usize, + target_vertex: usize, +} + +impl<'de, G> Deserialize<'de> for HamiltonianPathBetweenTwoVertices +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = HamiltonianPathBetweenTwoVerticesData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.source_vertex, data.target_vertex) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct HamiltonianPathBetweenTwoVerticesRandomSpec { /// Number of graph vertices. @@ -97,24 +115,35 @@ impl HamiltonianPathBetweenTwoVertices { /// /// Panics if `source_vertex` or `target_vertex` is out of range, or if they are equal. pub fn new(graph: G, source_vertex: usize, target_vertex: usize) -> Self { + Self::try_new(graph, source_vertex, target_vertex).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + source_vertex: usize, + target_vertex: usize, + ) -> Result { let n = graph.num_vertices(); - assert!( - source_vertex < n, - "source_vertex {source_vertex} out of range for graph with {n} vertices" - ); - assert!( - target_vertex < n, - "target_vertex {target_vertex} out of range for graph with {n} vertices" - ); - assert_ne!( - source_vertex, target_vertex, - "source_vertex and target_vertex must be distinct" - ); - Self { + if source_vertex >= n { + return Err(format!( + "source_vertex {source_vertex} out of range for graph with {n} vertices" + ) + .into()); + } + if target_vertex >= n { + return Err(format!( + "target_vertex {target_vertex} out of range for graph with {n} vertices" + ) + .into()); + } + if source_vertex == target_vertex { + return Err("source_vertex and target_vertex must be distinct".into()); + } + Ok(Self { graph, source_vertex, target_vertex, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index 07bca75e7..a9b740d21 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -26,12 +26,29 @@ inventory::submit! { /// Given a graph `G = (V, E)` and a positive integer `k`, determine whether /// there exists a subset `K ⊆ V` of size at least `k` such that every pair of /// distinct vertices in `K` is adjacent. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct KClique { graph: G, k: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct KCliqueData { + graph: G, + k: usize, +} + +impl<'de, G> Deserialize<'de> for KClique +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = KCliqueData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.k).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct KCliqueCreateSpec { #[create(codec = "edge-list")] @@ -79,9 +96,17 @@ impl TryFrom for KClique { impl KClique { /// Create a new k-Clique problem instance. pub fn new(graph: G, k: usize) -> Self { - assert!(k > 0, "k must be positive"); - assert!(k <= graph.num_vertices(), "k must be <= graph num_vertices"); - Self { graph, k } + Self::try_new(graph, k).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, k: usize) -> Result { + if k == 0 { + return Err("k must be positive".into()); + } + if k > graph.num_vertices() { + return Err("k must be <= graph num_vertices".into()); + } + Ok(Self { graph, k }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index f60e24bff..d6d154e0b 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -40,12 +40,30 @@ inventory::submit! { /// /// A valid configuration must select edges that form exactly one connected /// simple circuit using only edges from `graph`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct LongestCircuit { graph: G, edge_lengths: Vec, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct LongestCircuitData { + graph: G, + edge_lengths: Vec, +} + +impl<'de, G, W> Deserialize<'de> for LongestCircuit +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = LongestCircuitData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_lengths).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct LongestCircuitCreateSpec { #[create(codec = "edge-list")] @@ -74,7 +92,7 @@ impl TryFrom for LongestCircuit { if edge_lengths.iter().any(|&length| length <= 0) { return Err("edge_weights must be positive".to_string().into()); } - Ok(Self::new(graph, edge_lengths)) + Self::try_new(graph, edge_lengths) } } @@ -117,22 +135,15 @@ impl LongestCircuit { /// Panics if the number of edge lengths does not match the graph's edge /// count, or if any edge length is non-positive. pub fn new(graph: G, edge_lengths: Vec) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - let zero = W::Sum::zero(); - assert!( - edge_lengths - .iter() - .all(|length| length.to_sum() > zero.clone()), - "All edge lengths must be positive (> 0)" - ); - Self { + Self::try_new(graph, edge_lengths).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, edge_lengths: Vec) -> Result { + Self::check_weights(&graph, &edge_lengths)?; + Ok(Self { graph, edge_lengths, - } + }) } /// Get a reference to the underlying graph. @@ -147,21 +158,23 @@ impl LongestCircuit { /// Replace the edge lengths. pub fn set_lengths(&mut self, edge_lengths: Vec) { - assert_eq!( - edge_lengths.len(), - self.graph.num_edges(), - "edge_lengths length must match num_edges" - ); - let zero = W::Sum::zero(); - assert!( - edge_lengths - .iter() - .all(|length| length.to_sum() > zero.clone()), - "All edge lengths must be positive (> 0)" - ); + Self::check_weights(&self.graph, &edge_lengths).unwrap_or_else(|error| panic!("{error}")); self.edge_lengths = edge_lengths; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + if !weights + .iter() + .all(|weight| weight.to_sum() > W::Sum::zero()) + { + return Err("All edge lengths must be positive (> 0)".into()); + } + Ok(()) + } + /// Replace the edge lengths via the generic weight-management naming. pub fn set_weights(&mut self, weights: Vec) { self.set_lengths(weights); diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index 2423f24fb..1446db033 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -40,7 +40,7 @@ inventory::submit! { /// /// A valid configuration must select exactly the edges of one simple /// undirected path from `source_vertex` to `target_vertex`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct LongestPath { graph: G, edge_lengths: Vec, @@ -48,6 +48,32 @@ pub struct LongestPath { target_vertex: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct LongestPathData { + graph: G, + edge_lengths: Vec, + source_vertex: usize, + target_vertex: usize, +} + +impl<'de, G, W> Deserialize<'de> for LongestPath +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = LongestPathData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_lengths, + data.source_vertex, + data.target_vertex, + ) + .map_err(serde::de::Error::custom) + } +} + macro_rules! longest_path_create_spec { (@lengths $spec:ident, $lengths:ident) => { $spec.$lengths }; (@lengths $spec:ident) => { vec![One; $spec.graph.len()] }; @@ -109,42 +135,41 @@ longest_path_create_spec!(LongestPathI64CreateSpec, i64, edge_lengths); longest_path_create_spec!(LongestPathOneCreateSpec, One); impl LongestPath { - fn assert_positive_edge_lengths(edge_lengths: &[W]) { - let zero = W::Sum::zero(); - assert!( - edge_lengths - .iter() - .all(|length| length.to_sum() > zero.clone()), - "All edge lengths must be positive (> 0)" - ); - } - /// Create a new LongestPath instance. pub fn new(graph: G, edge_lengths: Vec, source_vertex: usize, target_vertex: usize) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - Self::assert_positive_edge_lengths(&edge_lengths); - assert!( - source_vertex < graph.num_vertices(), - "source_vertex {} out of bounds (graph has {} vertices)", - source_vertex, - graph.num_vertices() - ); - assert!( - target_vertex < graph.num_vertices(), - "target_vertex {} out of bounds (graph has {} vertices)", - target_vertex, - graph.num_vertices() - ); - Self { + Self::try_new(graph, edge_lengths, source_vertex, target_vertex) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_lengths: Vec, + source_vertex: usize, + target_vertex: usize, + ) -> Result { + Self::check_weights(&graph, &edge_lengths)?; + if source_vertex >= graph.num_vertices() { + return Err(format!( + "source_vertex {} out of bounds (graph has {} vertices)", + source_vertex, + graph.num_vertices() + ) + .into()); + } + if target_vertex >= graph.num_vertices() { + return Err(format!( + "target_vertex {} out of bounds (graph has {} vertices)", + target_vertex, + graph.num_vertices() + ) + .into()); + } + Ok(Self { graph, edge_lengths, source_vertex, target_vertex, - } + }) } /// Get a reference to the underlying graph. @@ -159,15 +184,23 @@ impl LongestPath { /// Replace the edge lengths with a new vector. pub fn set_lengths(&mut self, edge_lengths: Vec) { - assert_eq!( - edge_lengths.len(), - self.graph.num_edges(), - "edge_lengths length must match num_edges" - ); - Self::assert_positive_edge_lengths(&edge_lengths); + Self::check_weights(&self.graph, &edge_lengths).unwrap_or_else(|error| panic!("{error}")); self.edge_lengths = edge_lengths; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + if !weights + .iter() + .all(|weight| weight.to_sum() > W::Sum::zero()) + { + return Err("All edge lengths must be positive (> 0)".into()); + } + Ok(()) + } + /// Get the source vertex. pub fn source_vertex(&self) -> usize { self.source_vertex diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 0d414cfac..a26ebed82 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -67,7 +67,7 @@ inventory::submit! { /// assert_eq!(size, Max(Some(2))); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaxCut { /// The underlying graph structure. graph: G, @@ -75,6 +75,23 @@ pub struct MaxCut { edge_weights: Vec, } +#[derive(Deserialize)] +struct MaxCutData { + graph: G, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaxCut +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaxCutData::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + } +} + macro_rules! max_cut_create_spec { ($name:ident, $weight:ty, $one:expr $(, $edge_weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -102,7 +119,7 @@ macro_rules! max_cut_create_spec { ) .into()); } - Ok(Self::new(graph, edge_weights)) + Self::try_new(graph, edge_weights) } } }; @@ -146,15 +163,17 @@ impl MaxCut { /// * `graph` - The underlying graph /// * `edge_weights` - Weights for each edge (must match graph.num_edges()) pub fn new(graph: G, edge_weights: Vec) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self { + Self::try_new(graph, edge_weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, edge_weights: Vec) -> Result { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + Ok(Self { graph, edge_weights, - } + }) } /// Create a MaxCut problem with unit weights. diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index 079e0752d..f28e33834 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -53,7 +53,7 @@ inventory::submit! { /// assert!(problem.evaluate(sol).unwrap().is_valid()); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximalIS { /// The underlying graph. graph: G, @@ -61,6 +61,23 @@ pub struct MaximalIS { weights: Vec, } +#[derive(Deserialize)] +struct MaximalISData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximalIS +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximalISData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximalISCreateSpec { /// The underlying graph G=(V,E). @@ -80,19 +97,21 @@ impl TryFrom for MaximalIS { ) .into()); } - Ok(Self::new(spec.graph, spec.weights)) + Self::try_new(spec.graph, spec.weights) } } impl MaximalIS { /// Create a Maximal Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 423242ba1..97ec9fac9 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -56,7 +56,7 @@ inventory::submit! { /// // Maximum clique in a triangle (K3) is size 3 /// assert!(solutions.iter().all(|s| s.iter().filter(|&&selected| selected).count() == 3)); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximumClique { /// The underlying graph. graph: G, @@ -64,6 +64,23 @@ pub struct MaximumClique { weights: Vec, } +#[derive(Deserialize)] +struct MaximumCliqueData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximumClique +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumCliqueData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumCliqueCreateSpec { /// The underlying graph G=(V,E). @@ -83,19 +100,21 @@ impl TryFrom> for MaximumClique MaximumClique { /// Create a MaximumClique problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -225,7 +244,7 @@ impl TryFrom for MaximumClique { type Error = crate::registry::ConstructionError; fn try_from(spec: MaximumCliqueOneCreateSpec) -> Result { let weights = vec![One; spec.graph.num_vertices()]; - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index 29495f0d6..5af8c7a46 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -63,8 +63,7 @@ inventory::submit! { /// MaximumCoKPlex::<_, One, KN>::with_k(graph, vec![One; 5], 2); /// assert_eq!(problem.bound_k(), 2); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct MaximumCoKPlex { /// The underlying graph. graph: G, @@ -81,6 +80,25 @@ pub struct MaximumCoKPlex { _phantom: std::marker::PhantomData, } +#[derive(Deserialize)] +struct MaximumCoKPlexData { + graph: G, + weights: Vec, + bound_k: usize, +} + +impl<'de, G, W, K> Deserialize<'de> for MaximumCoKPlex +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, + K: KValue, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumCoKPlexData::deserialize(deserializer)?; + Self::try_with_k(data.graph, data.weights, data.bound_k).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumCoKPlexCreateSpec { /// The underlying graph G=(V,E). @@ -108,7 +126,7 @@ impl TryFrom> if spec.k == 0 { return Err("k must be at least 1".to_string().into()); } - Ok(Self::with_k(spec.graph, spec.weights, spec.k)) + Self::try_with_k(spec.graph, spec.weights, spec.k) } } @@ -120,24 +138,31 @@ impl MaximumCoKPlex { /// `bound_k == 0`, or if `K` declares a fixed value that disagrees with /// `bound_k`. pub fn with_k(graph: G, weights: Vec, bound_k: usize) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - assert!(bound_k >= 1, "co-k-plex parameter k must be at least 1"); + Self::try_with_k(graph, weights, bound_k).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_with_k( + graph: G, + weights: Vec, + bound_k: usize, + ) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + if bound_k == 0 { + return Err("co-k-plex parameter k must be at least 1".into()); + } if let Some(fixed) = K::K { - assert_eq!( - fixed, bound_k, - "fixed K type disagrees with runtime bound_k" - ); + if fixed != bound_k { + return Err("fixed K type disagrees with runtime bound_k".into()); + } } - Self { + Ok(Self { graph, weights, bound_k, _phantom: std::marker::PhantomData, - } + }) } /// Create a new instance using the compile-time `K`. @@ -282,7 +307,7 @@ impl TryFrom for MaximumCoKPlex { /// The underlying graph. graph: G, @@ -66,6 +66,23 @@ pub struct MaximumIndependentSet { weights: Vec, } +#[derive(Deserialize)] +struct MaximumIndependentSetData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximumIndependentSet +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumIndependentSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + macro_rules! simple_mis_spec { ($name:ident,$weight:ty,$one:expr $(, $weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -213,12 +230,14 @@ unit_disk_mis_spec!( impl MaximumIndependentSet { /// Create an Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/maximum_leaf_spanning_tree.rs b/src/models/graph/maximum_leaf_spanning_tree.rs index 9a8c8d3bd..8dbc9ae0e 100644 --- a/src/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/models/graph/maximum_leaf_spanning_tree.rs @@ -43,22 +43,41 @@ inventory::submit! { /// # Type Parameters /// /// * `G` - The graph type (e.g., `SimpleGraph`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximumLeafSpanningTree { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct MaximumLeafSpanningTreeData { + graph: G, +} + +impl<'de, G> Deserialize<'de> for MaximumLeafSpanningTree +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumLeafSpanningTreeData::::deserialize(deserializer)?; + Self::try_new(data.graph).map_err(serde::de::Error::custom) + } +} + impl MaximumLeafSpanningTree { /// Create a MaximumLeafSpanningTree problem from a graph. /// /// The graph must have at least 2 vertices. pub fn new(graph: G) -> Self { - assert!( - graph.num_vertices() >= 2, - "graph must have at least 2 vertices" - ); - Self { graph } + Self::try_new(graph).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G) -> Result { + if graph.num_vertices() < 2 { + return Err("graph must have at least 2 vertices".into()); + } + Ok(Self { graph }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index 6e68acfd5..542497fdd 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -56,7 +56,7 @@ inventory::submit! { /// assert_eq!(sol.iter().filter(|&&selected| selected).count(), 1); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximumMatching { /// The underlying graph. graph: G, @@ -64,6 +64,23 @@ pub struct MaximumMatching { edge_weights: Vec, } +#[derive(Deserialize)] +struct MaximumMatchingData { + graph: G, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximumMatching +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumMatchingData::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumMatchingCreateSpec { #[create(codec = "edge-list")] @@ -89,7 +106,7 @@ impl TryFrom for MaximumMatching { ) .into()); } - Ok(Self::new(graph, edge_weights)) + Self::try_new(graph, edge_weights) } } @@ -131,15 +148,15 @@ impl MaximumMatching { /// * `graph` - The graph /// * `edge_weights` - Weight for each edge (in graph.edges() order) pub fn new(graph: G, edge_weights: Vec) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self { + Self::try_new(graph, edge_weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, edge_weights: Vec) -> Result { + Self::check_weights(&graph, &edge_weights)?; + Ok(Self { graph, edge_weights, - } + }) } /// Create a MaximumMatching problem with unit weights. @@ -209,10 +226,20 @@ impl MaximumMatching { /// Set new weights for the problem. pub fn set_weights(&mut self, weights: Vec) { - assert_eq!(weights.len(), self.graph.num_edges()); + Self::check_weights(&self.graph, &weights).unwrap_or_else(|error| panic!("{error}")); self.edge_weights = weights; } + fn check_weights( + graph: &G, + edge_weights: &[W], + ) -> Result<(), crate::registry::ConstructionError> { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + Ok(()) + } + /// Get the weights for the problem. pub fn weights(&self) -> Vec { self.edge_weights.clone() diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index bcc697aff..2da29bc69 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -53,7 +53,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinMaxMulticenter { /// The underlying graph. graph: G, @@ -65,6 +65,27 @@ pub struct MinMaxMulticenter { k: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct MinMaxMulticenterData { + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, +} + +impl<'de, G, W> Deserialize<'de> for MinMaxMulticenter +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinMaxMulticenterData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) + .map_err(serde::de::Error::custom) + } +} + macro_rules! min_max_multicenter_create_spec { ($name:ident, $weight:ty, $one:expr $(, $weights:ident, $edge_weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -122,7 +143,7 @@ macro_rules! min_max_multicenter_create_spec { if spec.k == 0 || spec.k > graph.num_vertices() { return Err(format!("k must be between 1 and {}", graph.num_vertices()).into()); } - Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + Self::try_new(graph, vertex_weights, edge_lengths, spec.k) } } }; @@ -174,37 +195,47 @@ impl MinMaxMulticenter { /// - If any vertex weight or edge length is negative /// - If `k == 0` or `k > graph.num_vertices()` pub fn new(graph: G, vertex_weights: Vec, edge_lengths: Vec, k: usize) -> Self { - assert_eq!( - vertex_weights.len(), - graph.num_vertices(), - "vertex_weights length must match num_vertices" - ); - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); + Self::try_new(graph, vertex_weights, edge_lengths, k) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, + ) -> Result { + if vertex_weights.len() != graph.num_vertices() { + return Err("vertex_weights length must match num_vertices".into()); + } + if edge_lengths.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } let zero = W::Sum::zero(); - assert!( - vertex_weights - .iter() - .all(|weight| weight.to_sum() >= zero.clone()), - "vertex_weights must be non-negative" - ); - assert!( - edge_lengths - .iter() - .all(|length| length.to_sum() >= zero.clone()), - "edge_lengths must be non-negative" - ); - assert!(k > 0, "k must be positive"); - assert!(k <= graph.num_vertices(), "k must not exceed num_vertices"); - Self { + if !vertex_weights + .iter() + .all(|weight| weight.to_sum() >= zero.clone()) + { + return Err("vertex_weights must be non-negative".into()); + } + if !edge_lengths + .iter() + .all(|length| length.to_sum() >= zero.clone()) + { + return Err("edge_lengths must be non-negative".into()); + } + if k == 0 { + return Err("k must be positive".into()); + } + if k > graph.num_vertices() { + return Err("k must not exceed num_vertices".into()); + } + Ok(Self { graph, vertex_weights, edge_lengths, k, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 631a960b6..7133d30f1 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -48,7 +48,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `W` - The weight type for edges and requirements (e.g., `i64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumCapacitatedSpanningTree { /// The underlying graph. graph: G, @@ -62,6 +62,37 @@ pub struct MinimumCapacitatedSpanningTree { capacity: W::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct MinimumCapacitatedSpanningTreeData { + graph: G, + weights: Vec, + root: usize, + requirements: Vec, + capacity: W::Sum, +} + +impl<'de, G, W> Deserialize<'de> for MinimumCapacitatedSpanningTree +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumCapacitatedSpanningTreeData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.weights, + data.root, + data.requirements, + data.capacity, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumCapacitatedSpanningTreeCreateSpec { /// The underlying graph. @@ -99,13 +130,13 @@ impl TryFrom if spec.root >= vertices { return Err("root is outside the graph".to_string().into()); } - Ok(Self::new( + Self::try_new( spec.graph, weights, spec.root, spec.requirements, spec.capacity, - )) + ) } } @@ -124,32 +155,38 @@ impl MinimumCapacitatedSpanningTree { requirements: Vec, capacity: W::Sum, ) -> Self { - assert_eq!( - weights.len(), - graph.num_edges(), - "weights length must match num_edges" - ); - assert_eq!( - requirements.len(), - graph.num_vertices(), - "requirements length must match num_vertices" - ); - assert!( - root < graph.num_vertices(), - "root {root} out of range (num_vertices = {})", - graph.num_vertices() - ); - assert!( - graph.num_vertices() >= 2, - "graph must have at least 2 vertices" - ); - Self { + Self::try_new(graph, weights, root, requirements, capacity) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + weights: Vec, + root: usize, + requirements: Vec, + capacity: W::Sum, + ) -> Result { + Self::check_weights(&graph, &weights)?; + if requirements.len() != graph.num_vertices() { + return Err("requirements length must match num_vertices".into()); + } + if root >= graph.num_vertices() { + return Err(format!( + "root {root} out of range (num_vertices = {})", + graph.num_vertices() + ) + .into()); + } + if graph.num_vertices() < 2 { + return Err("graph must have at least 2 vertices".into()); + } + Ok(Self { graph, weights, root, requirements, capacity, - } + }) } /// Get a reference to the underlying graph. @@ -164,10 +201,17 @@ impl MinimumCapacitatedSpanningTree { /// Set new edge weights. pub fn set_weights(&mut self, weights: Vec) { - assert_eq!(weights.len(), self.graph.num_edges()); + Self::check_weights(&self.graph, &weights).unwrap_or_else(|error| panic!("{error}")); self.weights = weights; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("weights length must match num_edges".into()); + } + Ok(()) + } + /// Check if the problem uses a non-unit weight type. pub fn is_weighted(&self) -> bool { !W::IS_UNIT diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 2f5167a65..90bac2d32 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -56,7 +56,7 @@ inventory::submit! { /// let val = problem.evaluate(&vec![false, false, true, true]).unwrap(); /// assert_eq!(val, problemreductions::types::Min(Some(1))); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumCutIntoBoundedSets { /// The underlying graph structure. graph: G, @@ -70,6 +70,34 @@ pub struct MinimumCutIntoBoundedSets { size_bound: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct MinimumCutIntoBoundedSetsData { + graph: G, + edge_weights: Vec, + source: usize, + sink: usize, + size_bound: usize, +} + +impl<'de, G, W> Deserialize<'de> for MinimumCutIntoBoundedSets +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumCutIntoBoundedSetsData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_weights, + data.source, + data.sink, + data.size_bound, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumCutIntoBoundedSetsCreateSpec { /// The undirected graph. @@ -101,13 +129,13 @@ impl TryFrom for MinimumCutIntoBoundedSets< .to_string() .into()); } - Ok(Self::new( + Self::try_new( spec.graph, edge_weights, spec.source, spec.sink, spec.size_bound, - )) + ) } } @@ -131,21 +159,36 @@ impl MinimumCutIntoBoundedSets { sink: usize, size_bound: usize, ) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - assert!(source < graph.num_vertices(), "source vertex out of bounds"); - assert!(sink < graph.num_vertices(), "sink vertex out of bounds"); - assert_ne!(source, sink, "source and sink must be different vertices"); - Self { + Self::try_new(graph, edge_weights, source, sink, size_bound) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_weights: Vec, + source: usize, + sink: usize, + size_bound: usize, + ) -> Result { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + if source >= graph.num_vertices() { + return Err("source vertex out of bounds".into()); + } + if sink >= graph.num_vertices() { + return Err("sink vertex out of bounds".into()); + } + if source == sink { + return Err("source and sink must be different vertices".into()); + } + Ok(Self { graph, edge_weights, source, sink, size_bound, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index b8c85a1a4..84d3859be 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -52,7 +52,7 @@ inventory::submit! { /// // Minimum dominating set is just the center vertex /// assert!(solutions.contains(&vec![true, false, false, false])); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumDominatingSet { /// The underlying graph. graph: G, @@ -60,6 +60,23 @@ pub struct MinimumDominatingSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumDominatingSetData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MinimumDominatingSet +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumDominatingSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumDominatingSetCreateSpec { /// The underlying graph G=(V,E). @@ -81,19 +98,21 @@ impl TryFrom> ) .into()); } - Ok(Self::new(spec.graph, spec.weights)) + Self::try_new(spec.graph, spec.weights) } } impl MinimumDominatingSet { /// Create a Dominating Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -227,7 +246,7 @@ impl TryFrom for MinimumDominatingSet Result { let weights = vec![One; spec.graph.num_vertices()]; - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index 7c337a1d5..8e62fb8ef 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -55,7 +55,7 @@ inventory::submit! { /// // Minimum FAS has size 1 (remove any single arc to break the cycle) /// assert_eq!(solution.iter().filter(|&&selected| selected).count(), 1); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumFeedbackArcSet { /// The directed graph. graph: DirectedGraph, @@ -63,6 +63,22 @@ pub struct MinimumFeedbackArcSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumFeedbackArcSetData { + graph: DirectedGraph, + weights: Vec, +} + +impl<'de, W> Deserialize<'de> for MinimumFeedbackArcSet +where + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumFeedbackArcSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumFeedbackArcSetCreateSpec { /// The directed graph. @@ -78,19 +94,22 @@ impl TryFrom for MinimumFeedbackArcSet { if weights.len() != count { return Err(format!("weights has {} entries, expected {count}", weights.len()).into()); } - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } impl MinimumFeedbackArcSet { /// Create a Minimum Feedback Arc Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_arcs(), - "weights length must match graph num_arcs" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + weights: Vec, + ) -> Result { + Self::check_weights(&graph, &weights)?; + Ok(Self { graph, weights }) } /// Get a reference to the underlying directed graph. @@ -105,14 +124,20 @@ impl MinimumFeedbackArcSet { /// Set arc weights. pub fn set_weights(&mut self, weights: Vec) { - assert_eq!( - weights.len(), - self.graph.num_arcs(), - "weights length must match graph num_arcs" - ); + Self::check_weights(&self.graph, &weights).unwrap_or_else(|error| panic!("{error}")); self.weights = weights; } + fn check_weights( + graph: &DirectedGraph, + weights: &[W], + ) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_arcs() { + return Err("weights length must match graph num_arcs".into()); + } + Ok(()) + } + /// Check if a configuration is a valid feedback arc set. /// /// A configuration is valid if removing the selected arcs makes the graph acyclic. diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index 762efe3da..e1fe25b29 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -49,7 +49,7 @@ inventory::submit! { /// // Any single vertex breaks the cycle /// assert_eq!(solutions.len(), 3); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumFeedbackVertexSet { /// The underlying directed graph. graph: DirectedGraph, @@ -57,6 +57,22 @@ pub struct MinimumFeedbackVertexSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumFeedbackVertexSetData { + graph: DirectedGraph, + weights: Vec, +} + +impl<'de, W> Deserialize<'de> for MinimumFeedbackVertexSet +where + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumFeedbackVertexSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumFeedbackVertexSetCreateSpec { /// The directed graph. @@ -74,19 +90,22 @@ impl TryFrom> if weights.len() != count { return Err(format!("weights has {} entries, expected {count}", weights.len()).into()); } - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } impl MinimumFeedbackVertexSet { /// Create a Feedback Vertex Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + weights: Vec, + ) -> Result { + Self::check_weights(&graph, &weights)?; + Ok(Self { graph, weights }) } /// Get a reference to the underlying directed graph. @@ -101,14 +120,20 @@ impl MinimumFeedbackVertexSet { /// Set vertex weights. pub fn set_weights(&mut self, weights: Vec) { - assert_eq!( - weights.len(), - self.graph.num_vertices(), - "weights length must match graph num_vertices" - ); + Self::check_weights(&self.graph, &weights).unwrap_or_else(|error| panic!("{error}")); self.weights = weights; } + fn check_weights( + graph: &DirectedGraph, + weights: &[W], + ) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(()) + } + /// Check if a configuration is a valid feedback vertex set. pub fn is_valid_solution(&self, config: &[usize]) -> bool { if config.len() != self.graph.num_vertices() { @@ -200,7 +225,7 @@ impl TryFrom for MinimumFeedbackVertexSet type Error = crate::registry::ConstructionError; fn try_from(spec: MinimumFeedbackVertexSetOneCreateSpec) -> Result { let weights = vec![One; spec.graph.num_vertices()]; - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 2ba567460..01097f26c 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -54,7 +54,7 @@ inventory::submit! { /// // Center at vertex 1 gives total distance 0+1+1 = 2 (optimal) /// assert_eq!(solution, vec![false, true, false]); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumSumMulticenter { /// The underlying graph. graph: G, @@ -66,6 +66,27 @@ pub struct MinimumSumMulticenter { k: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] +struct MinimumSumMulticenterData { + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, +} + +impl<'de, G, W> Deserialize<'de> for MinimumSumMulticenter +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumSumMulticenterData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumSumMulticenterCreateSpec { #[create(codec = "edge-list")] @@ -120,7 +141,7 @@ impl TryFrom for MinimumSumMulticenter graph.num_vertices() { return Err(format!("k must be between 1 and {}", graph.num_vertices()).into()); } - Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + Self::try_new(graph, vertex_weights, edge_lengths, spec.k) } } @@ -160,24 +181,34 @@ impl MinimumSumMulticenter { /// - If `edge_lengths.len() != graph.num_edges()` /// - If `k == 0` or `k > graph.num_vertices()` pub fn new(graph: G, vertex_weights: Vec, edge_lengths: Vec, k: usize) -> Self { - assert_eq!( - vertex_weights.len(), - graph.num_vertices(), - "vertex_weights length must match num_vertices" - ); - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - assert!(k > 0, "k must be positive"); - assert!(k <= graph.num_vertices(), "k must not exceed num_vertices"); - Self { + Self::try_new(graph, vertex_weights, edge_lengths, k) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, + ) -> Result { + if vertex_weights.len() != graph.num_vertices() { + return Err("vertex_weights length must match num_vertices".into()); + } + if edge_lengths.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + if k == 0 { + return Err("k must be positive".into()); + } + if k > graph.num_vertices() { + return Err("k must not exceed num_vertices".into()); + } + Ok(Self { graph, vertex_weights, edge_lengths, k, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 5f511118b..650fcc31c 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -52,7 +52,7 @@ inventory::submit! { /// // Minimum vertex cover is just vertex 1 /// assert!(solutions.contains(&vec![false, true, false])); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumVertexCover { /// The underlying graph. graph: G, @@ -60,6 +60,23 @@ pub struct MinimumVertexCover { weights: Vec, } +#[derive(Deserialize)] +struct MinimumVertexCoverData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MinimumVertexCover +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumVertexCoverData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumVertexCoverCreateSpec { /// The underlying graph G=(V,E). @@ -84,19 +101,21 @@ impl TryFrom> ) .into()); } - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } impl MinimumVertexCover { /// Create a Vertex Covering problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -217,7 +236,7 @@ impl TryFrom for MinimumVertexCover Result { let weights = vec![One; spec.graph.num_vertices()]; - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } diff --git a/src/models/graph/partition_into_cliques.rs b/src/models/graph/partition_into_cliques.rs index 5bb87de80..661e13c10 100644 --- a/src/models/graph/partition_into_cliques.rs +++ b/src/models/graph/partition_into_cliques.rs @@ -53,8 +53,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct PartitionIntoCliques { /// The underlying graph. graph: G, @@ -62,18 +61,40 @@ pub struct PartitionIntoCliques { num_cliques: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoCliquesData { + graph: G, + num_cliques: usize, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoCliques +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoCliquesData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.num_cliques).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoCliques { /// Create a new Partition Into Cliques instance. /// /// # Panics /// Panics if `num_cliques` is zero or greater than `graph.num_vertices()`. pub fn new(graph: G, num_cliques: usize) -> Self { - assert!(num_cliques >= 1, "num_cliques must be at least 1"); - assert!( - num_cliques <= graph.num_vertices(), - "num_cliques must be at most num_vertices" - ); - Self { graph, num_cliques } + Self::try_new(graph, num_cliques).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, num_cliques: usize) -> Result { + if num_cliques == 0 { + return Err("num_cliques must be at least 1".into()); + } + if num_cliques > graph.num_vertices() { + return Err("num_cliques must be at most num_vertices".into()); + } + Ok(Self { graph, num_cliques }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/partition_into_forests.rs b/src/models/graph/partition_into_forests.rs index a156e7968..4fe68f062 100644 --- a/src/models/graph/partition_into_forests.rs +++ b/src/models/graph/partition_into_forests.rs @@ -54,8 +54,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct PartitionIntoForests { /// The underlying graph. graph: G, @@ -63,14 +62,37 @@ pub struct PartitionIntoForests { num_forests: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoForestsData { + graph: G, + num_forests: usize, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoForests +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoForestsData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.num_forests).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoForests { /// Create a new Partition Into Forests instance. /// /// # Panics /// Panics if `num_forests` is zero. pub fn new(graph: G, num_forests: usize) -> Self { - assert!(num_forests >= 1, "num_forests must be at least 1"); - Self { graph, num_forests } + Self::try_new(graph, num_forests).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, num_forests: usize) -> Result { + if num_forests == 0 { + return Err("num_forests must be at least 1".into()); + } + Ok(Self { graph, num_forests }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/partition_into_paths_of_length_2.rs b/src/models/graph/partition_into_paths_of_length_2.rs index 5c198c54f..c8b2717cc 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -58,26 +58,46 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct PartitionIntoPathsOfLength2 { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoPathsOfLength2Data { + graph: G, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoPathsOfLength2 +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoPathsOfLength2Data::::deserialize(deserializer)?; + Self::try_new(data.graph).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoPathsOfLength2 { /// Create a new PartitionIntoPathsOfLength2 problem from a graph. /// /// # Panics /// Panics if `graph.num_vertices()` is not divisible by 3. pub fn new(graph: G) -> Self { - assert_eq!( - graph.num_vertices() % 3, - 0, - "Number of vertices ({}) must be divisible by 3", - graph.num_vertices() - ); - Self { graph } + Self::try_new(graph).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G) -> Result { + if !graph.num_vertices().is_multiple_of(3) { + return Err(format!( + "Number of vertices ({}) must be divisible by 3", + graph.num_vertices() + ) + .into()); + } + Ok(Self { graph }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/partition_into_perfect_matchings.rs b/src/models/graph/partition_into_perfect_matchings.rs index e93976e53..4b6b2ced4 100644 --- a/src/models/graph/partition_into_perfect_matchings.rs +++ b/src/models/graph/partition_into_perfect_matchings.rs @@ -55,8 +55,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct PartitionIntoPerfectMatchings { /// The underlying graph. graph: G, @@ -64,21 +63,43 @@ pub struct PartitionIntoPerfectMatchings { num_matchings: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoPerfectMatchingsData { + graph: G, + num_matchings: usize, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoPerfectMatchings +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoPerfectMatchingsData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.num_matchings).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoPerfectMatchings { /// Create a new Partition Into Perfect Matchings instance. /// /// # Panics /// Panics if `num_matchings` is zero or greater than `graph.num_vertices()`. pub fn new(graph: G, num_matchings: usize) -> Self { - assert!(num_matchings >= 1, "num_matchings must be at least 1"); - assert!( - num_matchings <= graph.num_vertices(), - "num_matchings must be at most num_vertices" - ); - Self { + Self::try_new(graph, num_matchings).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, num_matchings: usize) -> Result { + if num_matchings == 0 { + return Err("num_matchings must be at least 1".into()); + } + if num_matchings > graph.num_vertices() { + return Err("num_matchings must be at most num_vertices".into()); + } + Ok(Self { graph, num_matchings, - } + }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs index 8638705d2..ce4f0c8a8 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -50,25 +50,46 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct PartitionIntoTriangles { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoTrianglesData { + graph: G, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoTriangles +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoTrianglesData::::deserialize(deserializer)?; + Self::try_new(data.graph).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoTriangles { /// Create a new Partition Into Triangles problem from a graph. /// /// # Panics /// Panics if the number of vertices is not divisible by 3. pub fn new(graph: G) -> Self { - assert!( - graph.num_vertices().is_multiple_of(3), - "Number of vertices ({}) must be divisible by 3", - graph.num_vertices() - ); - Self { graph } + Self::try_new(graph).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G) -> Result { + if !graph.num_vertices().is_multiple_of(3) { + return Err(format!( + "Number of vertices ({}) must be divisible by 3", + graph.num_vertices() + ) + .into()); + } + Ok(Self { graph }) } /// Get a reference to the underlying graph. diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 2561b04e1..bac988674 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -52,7 +52,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `W` - The weight type for edge lengths (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct RuralPostman { /// The underlying graph. graph: G, @@ -62,6 +62,26 @@ pub struct RuralPostman { required_edges: Vec, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct RuralPostmanData { + graph: G, + edge_lengths: Vec, + required_edges: Vec, +} + +impl<'de, G, W> Deserialize<'de> for RuralPostman +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = RuralPostmanData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_lengths, data.required_edges) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct RuralPostmanCreateSpec { #[create(codec = "edge-list")] @@ -96,7 +116,7 @@ impl TryFrom for RuralPostman { { return Err(format!("required edge index {edge} is out of bounds").into()); } - Ok(Self::new(graph, edge_lengths, spec.required_edges)) + Self::try_new(graph, edge_lengths, spec.required_edges) } } @@ -138,24 +158,30 @@ impl RuralPostman { /// Panics if edge_lengths length does not match graph edges, /// or if any required edge index is out of bounds. pub fn new(graph: G, edge_lengths: Vec, required_edges: Vec) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); + Self::try_new(graph, edge_lengths, required_edges).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_lengths: Vec, + required_edges: Vec, + ) -> Result { + Self::check_weights(&graph, &edge_lengths)?; for &idx in &required_edges { - assert!( - idx < graph.num_edges(), - "required edge index {} out of bounds (graph has {} edges)", - idx, - graph.num_edges() - ); + if idx >= graph.num_edges() { + return Err(format!( + "required edge index {} out of bounds (graph has {} edges)", + idx, + graph.num_edges() + ) + .into()); + } } - Self { + Ok(Self { graph, edge_lengths, required_edges, - } + }) } /// Get a reference to the underlying graph. @@ -190,10 +216,17 @@ impl RuralPostman { /// Set new edge lengths. pub fn set_weights(&mut self, weights: Vec) { - assert_eq!(weights.len(), self.graph.num_edges()); + Self::check_weights(&self.graph, &weights).unwrap_or_else(|error| panic!("{error}")); self.edge_lengths = weights; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + Ok(()) + } + /// Get the edge lengths as a Vec. pub fn weights(&self) -> Vec { self.edge_lengths.clone() diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index 0dab498cf..10dd20666 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -51,7 +51,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `N` - The edge length / weight type (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct ShortestWeightConstrainedPath { /// The underlying graph. graph: G, @@ -67,6 +67,39 @@ pub struct ShortestWeightConstrainedPath { weight_bound: N::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, N: WeightElement + Deserialize<'de>, N::Sum: Deserialize<'de>" +))] +struct ShortestWeightConstrainedPathData { + graph: G, + edge_lengths: Vec, + edge_weights: Vec, + source_vertex: usize, + target_vertex: usize, + weight_bound: N::Sum, +} + +impl<'de, G, N> Deserialize<'de> for ShortestWeightConstrainedPath +where + G: Graph + Deserialize<'de>, + N: WeightElement + Deserialize<'de>, + N::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = ShortestWeightConstrainedPathData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_lengths, + data.edge_weights, + data.source_vertex, + data.target_vertex, + data.weight_bound, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct ShortestWeightConstrainedPathCreateSpec { /// The underlying graph G=(V,E). @@ -127,29 +160,30 @@ impl TryFrom if spec.weight_bound <= 0 { return Err("weight_bound must be positive".to_string().into()); } - Ok(Self::new( + Self::try_new( spec.graph, spec.edge_lengths, spec.edge_weights, spec.source_vertex, spec.target_vertex, spec.weight_bound, - )) + ) } } impl ShortestWeightConstrainedPath { - fn assert_positive_edge_values(values: &[N], label: &str) { - let zero = N::Sum::zero(); - assert!( - values.iter().all(|value| value.to_sum() > zero.clone()), - "All {label} must be positive (> 0)" - ); - } - - fn assert_positive_bound(bound: &N::Sum, label: &str) { - let zero = N::Sum::zero(); - assert!(bound > &zero, "{label} must be positive (> 0)"); + fn check_edge_values( + graph: &G, + values: &[N], + label: &str, + ) -> Result<(), crate::registry::ConstructionError> { + if values.len() != graph.num_edges() { + return Err(format!("{label} length must match num_edges").into()); + } + if !values.iter().all(|value| value.to_sum() > N::Sum::zero()) { + return Err(format!("All {label} must be positive (> 0)").into()); + } + Ok(()) } /// Create a new ShortestWeightConstrainedPath instance. @@ -166,39 +200,54 @@ impl ShortestWeightConstrainedPath { target_vertex: usize, weight_bound: N::Sum, ) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self::assert_positive_edge_values(&edge_lengths, "edge lengths"); - Self::assert_positive_edge_values(&edge_weights, "edge weights"); - assert!( - source_vertex < graph.num_vertices(), - "source_vertex {} out of bounds (graph has {} vertices)", + Self::try_new( + graph, + edge_lengths, + edge_weights, source_vertex, - graph.num_vertices() - ); - assert!( - target_vertex < graph.num_vertices(), - "target_vertex {} out of bounds (graph has {} vertices)", target_vertex, - graph.num_vertices() - ); - Self::assert_positive_bound(&weight_bound, "weight_bound"); - Self { + weight_bound, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_lengths: Vec, + edge_weights: Vec, + source_vertex: usize, + target_vertex: usize, + weight_bound: N::Sum, + ) -> Result { + Self::check_edge_values(&graph, &edge_lengths, "edge lengths")?; + Self::check_edge_values(&graph, &edge_weights, "edge weights")?; + if source_vertex >= graph.num_vertices() { + return Err(format!( + "source_vertex {} out of bounds (graph has {} vertices)", + source_vertex, + graph.num_vertices() + ) + .into()); + } + if target_vertex >= graph.num_vertices() { + return Err(format!( + "target_vertex {} out of bounds (graph has {} vertices)", + target_vertex, + graph.num_vertices() + ) + .into()); + } + if weight_bound.partial_cmp(&N::Sum::zero()) != Some(std::cmp::Ordering::Greater) { + return Err("weight_bound must be positive (> 0)".into()); + } + Ok(Self { graph, edge_lengths, edge_weights, source_vertex, target_vertex, weight_bound, - } + }) } /// Get a reference to the underlying graph. @@ -218,23 +267,15 @@ impl ShortestWeightConstrainedPath { /// Set new edge lengths. pub fn set_lengths(&mut self, edge_lengths: Vec) { - assert_eq!( - edge_lengths.len(), - self.graph.num_edges(), - "edge_lengths length must match num_edges" - ); - Self::assert_positive_edge_values(&edge_lengths, "edge lengths"); + Self::check_edge_values(&self.graph, &edge_lengths, "edge lengths") + .unwrap_or_else(|error| panic!("{error}")); self.edge_lengths = edge_lengths; } /// Set new edge weights. pub fn set_weights(&mut self, edge_weights: Vec) { - assert_eq!( - edge_weights.len(), - self.graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self::assert_positive_edge_values(&edge_weights, "edge weights"); + Self::check_edge_values(&self.graph, &edge_weights, "edge weights") + .unwrap_or_else(|error| panic!("{error}")); self.edge_weights = edge_weights; } diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index c8865a5e2..11320566d 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -54,46 +54,34 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumTardinessSequencing { lengths: Vec, deadlines: Vec, precedences: Vec<(usize, usize)>, } -macro_rules! minimum_tardiness_create_spec { - ($name:ident, $weight:ty, $construct:expr) => { - #[derive(Debug, Deserialize, crate::CreateSpec)] - struct $name { - lengths: Vec<$weight>, - deadlines: Vec, - precedences: Option>, - } +#[derive(Deserialize)] +struct MinimumTardinessSequencingData { + lengths: Vec, + deadlines: Vec, + precedences: Vec<(usize, usize)>, +} - impl TryFrom<$name> for MinimumTardinessSequencing<$weight> { - type Error = crate::registry::ConstructionError; +impl<'de> Deserialize<'de> for MinimumTardinessSequencing { + fn deserialize>(deserializer: D) -> Result { + let data = MinimumTardinessSequencingData::::deserialize(deserializer)?; + Self::try_new(data.lengths.len(), data.deadlines, data.precedences) + .map_err(serde::de::Error::custom) + } +} - fn try_from(spec: $name) -> Result { - if spec.lengths.len() != spec.deadlines.len() { - return Err("lengths and deadlines must have the same length" - .to_string() - .into()); - } - let precedences = spec.precedences.unwrap_or_default(); - let num_tasks = spec.lengths.len(); - if let Some(&(pred, succ)) = precedences - .iter() - .find(|&&(pred, succ)| pred >= num_tasks || succ >= num_tasks) - { - return Err(format!( - "precedence ({pred}, {succ}) is out of range for {num_tasks} tasks" - ) - .into()); - } - $construct(spec.lengths, spec.deadlines, precedences) - } - } - }; +impl<'de> Deserialize<'de> for MinimumTardinessSequencing { + fn deserialize>(deserializer: D) -> Result { + let data = MinimumTardinessSequencingData::::deserialize(deserializer)?; + Self::try_with_lengths(data.lengths, data.deadlines, data.precedences) + .map_err(serde::de::Error::custom) + } } #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -112,24 +100,26 @@ impl TryFrom for MinimumTardinessSequen { return Err("precedence indices must be within the task count".into()); } - Ok(Self::new(num_tasks, spec.deadlines, precedences)) + Self::try_new(num_tasks, spec.deadlines, precedences) } } -minimum_tardiness_create_spec!( - MinimumTardinessSequencingI64CreateSpec, - i64, - |lengths: Vec, deadlines, precedences| { - if lengths.iter().any(|&length| length <= 0) { - return Err("all task lengths must be positive".to_string().into()); - } - Ok(MinimumTardinessSequencing::with_lengths( - lengths, - deadlines, - precedences, - )) +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumTardinessSequencingI64CreateSpec { + lengths: Vec, + deadlines: Vec, + precedences: Option>, +} +impl TryFrom for MinimumTardinessSequencing { + type Error = crate::registry::ConstructionError; + fn try_from(spec: MinimumTardinessSequencingI64CreateSpec) -> Result { + Self::try_with_lengths( + spec.lengths, + spec.deadlines, + spec.precedences.unwrap_or_default(), + ) } -); +} impl MinimumTardinessSequencing { /// Create a new unit-length MinimumTardinessSequencing instance. @@ -139,17 +129,20 @@ impl MinimumTardinessSequencing { /// Panics if `deadlines.len() != num_tasks` or if any task index in `precedences` /// is out of range. pub fn new(num_tasks: usize, deadlines: Vec, precedences: Vec<(usize, usize)>) -> Self { - assert_eq!( - deadlines.len(), - num_tasks, - "deadlines length must equal num_tasks" - ); - validate_precedences(num_tasks, &precedences); - Self { + Self::try_new(num_tasks, deadlines, precedences).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_tasks: usize, + deadlines: Vec, + precedences: Vec<(usize, usize)>, + ) -> Result { + validate_task_data(num_tasks, &deadlines, &precedences)?; + Ok(Self { lengths: vec![One; num_tasks], deadlines, precedences, - } + }) } } @@ -165,40 +158,48 @@ impl MinimumTardinessSequencing { deadlines: Vec, precedences: Vec<(usize, usize)>, ) -> Self { - assert_eq!( - lengths.len(), - deadlines.len(), - "lengths and deadlines must have the same length" - ); - assert!( - lengths.iter().all(|&l| l > 0), - "all task lengths must be positive" - ); - let num_tasks = lengths.len(); - validate_precedences(num_tasks, &precedences); - Self { + Self::try_with_lengths(lengths, deadlines, precedences) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_with_lengths( + lengths: Vec, + deadlines: Vec, + precedences: Vec<(usize, usize)>, + ) -> Result { + validate_task_data(lengths.len(), &deadlines, &precedences)?; + if lengths.iter().any(|&length| length <= 0) { + return Err("all task lengths must be positive".into()); + } + Ok(Self { lengths, deadlines, precedences, - } + }) } } -fn validate_precedences(num_tasks: usize, precedences: &[(usize, usize)]) { +fn validate_task_data( + num_tasks: usize, + deadlines: &[i64], + precedences: &[(usize, usize)], +) -> Result<(), crate::registry::ConstructionError> { + if deadlines.len() != num_tasks { + return Err("deadlines length must equal num_tasks".into()); + } for &(pred, succ) in precedences { - assert!( - pred < num_tasks, - "predecessor index {} out of range (num_tasks = {})", - pred, - num_tasks - ); - assert!( - succ < num_tasks, - "successor index {} out of range (num_tasks = {})", - succ, - num_tasks - ); + if pred >= num_tasks { + return Err( + format!("predecessor index {pred} out of range (num_tasks = {num_tasks})").into(), + ); + } + if succ >= num_tasks { + return Err( + format!("successor index {succ} out of range (num_tasks = {num_tasks})").into(), + ); + } } + Ok(()) } impl MinimumTardinessSequencing { diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 81748fc00..47a30a6b9 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -55,7 +55,7 @@ inventory::submit! { /// assert!(problem.evaluate(&sol).unwrap().is_valid()); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumSetCovering { /// Size of the universe (elements are 0..universe_size). universe_size: usize, @@ -65,6 +65,21 @@ pub struct MinimumSetCovering { weights: Vec, } +#[derive(Deserialize)] +struct MinimumSetCoveringData { + universe_size: usize, + sets: Vec>, + weights: Vec, +} + +impl<'de, W: Clone + Default + Deserialize<'de>> Deserialize<'de> for MinimumSetCovering { + fn deserialize>(deserializer: D) -> Result { + let data = MinimumSetCoveringData::deserialize(deserializer)?; + Self::try_with_weights(data.universe_size, data.sets, data.weights) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumSetCoveringCreateSpec { /// Size of the universe U. @@ -96,11 +111,7 @@ impl TryFrom for MinimumSetCovering { .into()); } } - Ok(Self::with_weights( - spec.universe_size, - spec.subsets, - spec.weights, - )) + Self::try_with_weights(spec.universe_size, spec.subsets, spec.weights) } } @@ -110,23 +121,39 @@ impl MinimumSetCovering { where W: WeightElement, { - let num_sets = sets.len(); - let weights = vec![W::unit(); num_sets]; - Self { - universe_size, - sets, - weights, - } + let weights = vec![W::unit(); sets.len()]; + Self::with_weights(universe_size, sets, weights) } /// Create a new Set Covering problem with custom weights. pub fn with_weights(universe_size: usize, sets: Vec>, weights: Vec) -> Self { - assert_eq!(sets.len(), weights.len()); - Self { + Self::try_with_weights(universe_size, sets, weights) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_with_weights( + universe_size: usize, + sets: Vec>, + weights: Vec, + ) -> Result { + if sets.len() != weights.len() { + return Err(format!( + "weights has {} entries, expected one for each of {} subsets", + weights.len(), + sets.len() + ) + .into()); + } + for (index, set) in sets.iter().enumerate() { + if let Some(element) = set.iter().find(|&&element| element >= universe_size) { + return Err(format!("set {index} contains element {element} outside universe of size {universe_size}").into()); + } + } + Ok(Self { universe_size, sets, weights, - } + }) } /// Get the universe size. diff --git a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs index 89eb9ad5f..220c70f71 100644 --- a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs +++ b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs @@ -1,3 +1,18 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"matrix":[[0,1],[1,0]]}); + let problem: MinimumMatrixCover = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumMatrixCover = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["matrix"] = serde_json::json!([[1, 2]]); + assert!( + serde_json::from_value::(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/biconnectivity_augmentation.rs b/src/unit_tests/models/graph/biconnectivity_augmentation.rs index 40b938f7a..436fc4172 100644 --- a/src/unit_tests/models/graph/biconnectivity_augmentation.rs +++ b/src/unit_tests/models/graph/biconnectivity_augmentation.rs @@ -1,3 +1,31 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"potential_weights":[[0,2,1]],"budget":1}); + let problem: BiconnectivityAugmentation = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: BiconnectivityAugmentation = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("potential_weights", serde_json::json!([[0, 3, 1]])), + ("potential_weights", serde_json::json!([[0, 0, 1]])), + ("potential_weights", serde_json::json!([[0, 1, 1]])), + ( + "potential_weights", + serde_json::json!([[0, 2, 1], [2, 0, 2]]), + ), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForceProblem as _; #[test] diff --git a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs index 020d1bfbb..d54f8f248 100644 --- a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs +++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs @@ -1,3 +1,30 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1,1],"max_components":1,"max_weight":3}); + let problem: BoundedComponentSpanningForest = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: BoundedComponentSpanningForest = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("weights", serde_json::json!([])), + ("weights", serde_json::json!([-1, 1, 1])), + ("max_components", serde_json::json!(0)), + ("max_weight", serde_json::json!(0)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>( + data.clone() + ) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs index 77c13c30f..eaf568cfd 100644 --- a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs @@ -1,4 +1,45 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_weights":[1,1],"weight_bound":2,"diameter_bound":2}); + let problem: BoundedDiameterSpanningTree = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: BoundedDiameterSpanningTree = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("edge_weights", serde_json::json!([])), + ("edge_weights", serde_json::json!([0, 1])), + ("weight_bound", serde_json::json!(0)), + ("diameter_bound", serde_json::json!(0)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; + +#[test] +fn test_json_rebuilds_edge_list() { + let problem = BoundedDiameterSpanningTree::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1i64, 1], + 2, + 2, + ); + let mut data = serde_json::to_value(&problem).unwrap(); + data["edge_list"] = serde_json::json!([[0, 99]]); + let restored: BoundedDiameterSpanningTree = + serde_json::from_value(data).unwrap(); + assert_eq!(restored.edge_list(), &[(0, 1), (1, 2)]); + assert!(restored.evaluate(&vec![true, true]).unwrap()); +} use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; diff --git a/src/unit_tests/models/graph/degree_constrained_spanning_tree.rs b/src/unit_tests/models/graph/degree_constrained_spanning_tree.rs index 847436d94..9f0a708ee 100644 --- a/src/unit_tests/models/graph/degree_constrained_spanning_tree.rs +++ b/src/unit_tests/models/graph/degree_constrained_spanning_tree.rs @@ -1,4 +1,33 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"max_degree":2}); + let problem: DegreeConstrainedSpanningTree = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: DegreeConstrainedSpanningTree = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["max_degree"] = serde_json::json!(0); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; + +#[test] +fn test_json_rebuilds_edge_list() { + let problem = DegreeConstrainedSpanningTree::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 2); + let mut data = serde_json::to_value(&problem).unwrap(); + data["edge_list"] = serde_json::json!([[0, 99]]); + let restored: DegreeConstrainedSpanningTree = + serde_json::from_value(data).unwrap(); + assert_eq!(restored.edge_list(), &[(0, 1), (1, 2)]); + assert!(restored.evaluate(&vec![true, true]).unwrap()); +} use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; diff --git a/src/unit_tests/models/graph/disjoint_connecting_paths.rs b/src/unit_tests/models/graph/disjoint_connecting_paths.rs index c1e93c582..9b3a573fe 100644 --- a/src/unit_tests/models/graph/disjoint_connecting_paths.rs +++ b/src/unit_tests/models/graph/disjoint_connecting_paths.rs @@ -1,3 +1,29 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"terminal_pairs":[[0,2]]}); + let problem: DisjointConnectingPaths = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: DisjointConnectingPaths = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("terminal_pairs", serde_json::json!([])), + ("terminal_pairs", serde_json::json!([[3, 2]])), + ("terminal_pairs", serde_json::json!([[0, 3]])), + ("terminal_pairs", serde_json::json!([[0, 0]])), + ("terminal_pairs", serde_json::json!([[0, 1], [0, 2]])), + ("terminal_pairs", serde_json::json!([[0, 1], [2, 1]])), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForceProblem as _; #[test] diff --git a/src/unit_tests/models/graph/generalized_hex.rs b/src/unit_tests/models/graph/generalized_hex.rs index dad6efcb1..47e9ea9fb 100644 --- a/src/unit_tests/models/graph/generalized_hex.rs +++ b/src/unit_tests/models/graph/generalized_hex.rs @@ -1,3 +1,25 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"source":0,"target":2}); + let problem: GeneralizedHex = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: GeneralizedHex = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("source", serde_json::json!(3)), + ("target", serde_json::json!(3)), + ("target", serde_json::json!(0)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/hamiltonian_path_between_two_vertices.rs b/src/unit_tests/models/graph/hamiltonian_path_between_two_vertices.rs index e2e22856f..47b465f9e 100644 --- a/src/unit_tests/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/unit_tests/models/graph/hamiltonian_path_between_two_vertices.rs @@ -1,3 +1,27 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"source_vertex":0,"target_vertex":2}); + let problem: HamiltonianPathBetweenTwoVertices = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: HamiltonianPathBetweenTwoVertices = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("source_vertex", serde_json::json!(3)), + ("target_vertex", serde_json::json!(3)), + ("target_vertex", serde_json::json!(0)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/kclique.rs b/src/unit_tests/models/graph/kclique.rs index 910275599..5b0174a08 100644 --- a/src/unit_tests/models/graph/kclique.rs +++ b/src/unit_tests/models/graph/kclique.rs @@ -1,3 +1,20 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"k":2}); + let problem: KClique = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: KClique = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for k in [0, 4] { + let mut data = valid.clone(); + data["k"] = serde_json::json!(k); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForceProblem as _; #[test] diff --git a/src/unit_tests/models/graph/longest_circuit.rs b/src/unit_tests/models/graph/longest_circuit.rs index 1789804b2..3fc82bc0b 100644 --- a/src/unit_tests/models/graph/longest_circuit.rs +++ b/src/unit_tests/models/graph/longest_circuit.rs @@ -1,3 +1,25 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_lengths":[1,1]}); + let problem: LongestCircuit = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: LongestCircuit = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("edge_lengths", serde_json::json!([])), + ("edge_lengths", serde_json::json!([0, 1])), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/longest_path.rs b/src/unit_tests/models/graph/longest_path.rs index f3d81c1c8..23e1be559 100644 --- a/src/unit_tests/models/graph/longest_path.rs +++ b/src/unit_tests/models/graph/longest_path.rs @@ -1,3 +1,25 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_lengths":[1,1],"source_vertex":0,"target_vertex":2}); + let problem: LongestPath = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: LongestPath = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("edge_lengths", serde_json::json!([])), + ("edge_lengths", serde_json::json!([0, 1])), + ("source_vertex", serde_json::json!(3)), + ("target_vertex", serde_json::json!(3)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForceProblem as _; #[test] diff --git a/src/unit_tests/models/graph/max_cut.rs b/src/unit_tests/models/graph/max_cut.rs index cb7497e77..9faa7f6ff 100644 --- a/src/unit_tests/models/graph/max_cut.rs +++ b/src/unit_tests/models/graph/max_cut.rs @@ -1,3 +1,19 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_weights":[1,1]}); + let problem: MaxCut = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MaxCut = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["edge_weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/maximal_is.rs b/src/unit_tests/models/graph/maximal_is.rs index f68ef7c18..b169294e8 100644 --- a/src/unit_tests/models/graph/maximal_is.rs +++ b/src/unit_tests/models/graph/maximal_is.rs @@ -1,3 +1,19 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1,1]}); + let problem: MaximalIS = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MaximalIS = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/maximum_clique.rs b/src/unit_tests/models/graph/maximum_clique.rs index f83c6107d..7ccd21ffd 100644 --- a/src/unit_tests/models/graph/maximum_clique.rs +++ b/src/unit_tests/models/graph/maximum_clique.rs @@ -1,3 +1,20 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1,1]}); + let problem: MaximumClique = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MaximumClique = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/maximum_co_k_plex.rs b/src/unit_tests/models/graph/maximum_co_k_plex.rs index f92e1eaf2..d391d2c53 100644 --- a/src/unit_tests/models/graph/maximum_co_k_plex.rs +++ b/src/unit_tests/models/graph/maximum_co_k_plex.rs @@ -1,4 +1,44 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1,1],"bound_k":2}); + let problem: MaximumCoKPlex = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MaximumCoKPlex = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("weights", serde_json::json!([])), + ("bound_k", serde_json::json!(0)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; + +#[test] +fn test_json_rejects_mismatched_fixed_k() { + let problem = + MaximumCoKPlex::<_, i64, crate::variant::K2>::new(SimpleGraph::new(2, vec![]), vec![1, 1]); + let mut data = serde_json::to_value(&problem).unwrap(); + assert!( + serde_json::from_value::>( + data.clone() + ) + .is_ok() + ); + data["bound_k"] = serde_json::json!(3); + assert!( + serde_json::from_value::>(data) + .is_err() + ); +} use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; diff --git a/src/unit_tests/models/graph/maximum_independent_set.rs b/src/unit_tests/models/graph/maximum_independent_set.rs index e5fb725ca..201b413e3 100644 --- a/src/unit_tests/models/graph/maximum_independent_set.rs +++ b/src/unit_tests/models/graph/maximum_independent_set.rs @@ -1,3 +1,21 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1,1]}); + let problem: MaximumIndependentSet = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MaximumIndependentSet = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; #[test] diff --git a/src/unit_tests/models/graph/maximum_leaf_spanning_tree.rs b/src/unit_tests/models/graph/maximum_leaf_spanning_tree.rs index 4698599ce..7915e434a 100644 --- a/src/unit_tests/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/unit_tests/models/graph/maximum_leaf_spanning_tree.rs @@ -1,3 +1,20 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]}}); + let problem: MaximumLeafSpanningTree = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MaximumLeafSpanningTree = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["graph"] = serde_json::json!({"num_vertices":1,"edges":[]}); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; use crate::{solvers::BruteForce, topology::SimpleGraph, traits::Problem}; diff --git a/src/unit_tests/models/graph/maximum_matching.rs b/src/unit_tests/models/graph/maximum_matching.rs index d39e31e4c..3cdab9f6a 100644 --- a/src/unit_tests/models/graph/maximum_matching.rs +++ b/src/unit_tests/models/graph/maximum_matching.rs @@ -1,3 +1,20 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_weights":[1,1]}); + let problem: MaximumMatching = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MaximumMatching = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["edge_weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/min_max_multicenter.rs b/src/unit_tests/models/graph/min_max_multicenter.rs index 8db1f88fa..7c10c527a 100644 --- a/src/unit_tests/models/graph/min_max_multicenter.rs +++ b/src/unit_tests/models/graph/min_max_multicenter.rs @@ -1,3 +1,29 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"vertex_weights":[1,1,1],"edge_lengths":[1,1],"k":1}); + let problem: MinMaxMulticenter = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinMaxMulticenter = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("vertex_weights", serde_json::json!([])), + ("edge_lengths", serde_json::json!([])), + ("vertex_weights", serde_json::json!([-1, 1, 1])), + ("edge_lengths", serde_json::json!([-1, 1])), + ("k", serde_json::json!(0)), + ("k", serde_json::json!(4)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs index 3576d79b5..0fcf62e90 100644 --- a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs @@ -1,3 +1,29 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1],"root":0,"requirements":[0,1,1],"capacity":2}); + let problem: MinimumCapacitatedSpanningTree = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumCapacitatedSpanningTree = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("weights", serde_json::json!([])), + ("requirements", serde_json::json!([])), + ("root", serde_json::json!(3)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>( + data.clone() + ) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs b/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs index 84f6d6aab..d493c4774 100644 --- a/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/unit_tests/models/graph/minimum_cut_into_bounded_sets.rs @@ -1,3 +1,28 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_weights":[1,1],"source":0,"sink":2,"size_bound":2}); + let problem: MinimumCutIntoBoundedSets = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumCutIntoBoundedSets = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("edge_weights", serde_json::json!([])), + ("source", serde_json::json!(3)), + ("sink", serde_json::json!(3)), + ("sink", serde_json::json!(0)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/minimum_dominating_set.rs b/src/unit_tests/models/graph/minimum_dominating_set.rs index 5edd12d7b..49dccf178 100644 --- a/src/unit_tests/models/graph/minimum_dominating_set.rs +++ b/src/unit_tests/models/graph/minimum_dominating_set.rs @@ -1,3 +1,21 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1,1]}); + let problem: MinimumDominatingSet = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumDominatingSet = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs index 649abd44b..06786be07 100644 --- a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs @@ -1,3 +1,19 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"arcs":[[0,1],[1,2]]},"weights":[1,1]}); + let problem: MinimumFeedbackArcSet = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumFeedbackArcSet = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs index cc1e4005a..fad4b0297 100644 --- a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs @@ -1,3 +1,19 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"arcs":[[0,1],[1,2]]},"weights":[1,1,1]}); + let problem: MinimumFeedbackVertexSet = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumFeedbackVertexSet = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/minimum_sum_multicenter.rs b/src/unit_tests/models/graph/minimum_sum_multicenter.rs index e636dee31..3f2501ef6 100644 --- a/src/unit_tests/models/graph/minimum_sum_multicenter.rs +++ b/src/unit_tests/models/graph/minimum_sum_multicenter.rs @@ -1,3 +1,28 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"vertex_weights":[1,1,1],"edge_lengths":[1,1],"k":1}); + let problem: MinimumSumMulticenter = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumSumMulticenter = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("vertex_weights", serde_json::json!([])), + ("edge_lengths", serde_json::json!([])), + ("k", serde_json::json!(0)), + ("k", serde_json::json!(4)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/minimum_vertex_cover.rs b/src/unit_tests/models/graph/minimum_vertex_cover.rs index 809475eef..1fe9a0a8b 100644 --- a/src/unit_tests/models/graph/minimum_vertex_cover.rs +++ b/src/unit_tests/models/graph/minimum_vertex_cover.rs @@ -1,3 +1,21 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"weights":[1,1,1]}); + let problem: MinimumVertexCover = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumVertexCover = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["weights"] = serde_json::json!([]); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/partition_into_cliques.rs b/src/unit_tests/models/graph/partition_into_cliques.rs index 2ee0b8fe3..98626481f 100644 --- a/src/unit_tests/models/graph/partition_into_cliques.rs +++ b/src/unit_tests/models/graph/partition_into_cliques.rs @@ -1,3 +1,25 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"num_cliques":2}); + let problem: PartitionIntoCliques = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: PartitionIntoCliques = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("num_cliques", serde_json::json!(0)), + ("num_cliques", serde_json::json!(4)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/partition_into_forests.rs b/src/unit_tests/models/graph/partition_into_forests.rs index de56c11ba..c34bfa3be 100644 --- a/src/unit_tests/models/graph/partition_into_forests.rs +++ b/src/unit_tests/models/graph/partition_into_forests.rs @@ -1,3 +1,20 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"num_forests":1}); + let problem: PartitionIntoForests = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: PartitionIntoForests = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["num_forests"] = serde_json::json!(0); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs index a3421104a..ef6ea5573 100644 --- a/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs +++ b/src/unit_tests/models/graph/partition_into_paths_of_length_2.rs @@ -1,3 +1,20 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]}}); + let problem: PartitionIntoPathsOfLength2 = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: PartitionIntoPathsOfLength2 = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["graph"] = serde_json::json!({"num_vertices":2,"edges":[]}); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/partition_into_perfect_matchings.rs b/src/unit_tests/models/graph/partition_into_perfect_matchings.rs index 8ea2f64a2..d6b14c6a9 100644 --- a/src/unit_tests/models/graph/partition_into_perfect_matchings.rs +++ b/src/unit_tests/models/graph/partition_into_perfect_matchings.rs @@ -1,3 +1,27 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = + serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"num_matchings":1}); + let problem: PartitionIntoPerfectMatchings = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: PartitionIntoPerfectMatchings = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("num_matchings", serde_json::json!(0)), + ("num_matchings", serde_json::json!(4)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/partition_into_triangles.rs b/src/unit_tests/models/graph/partition_into_triangles.rs index 42fb8c9c3..204f080c4 100644 --- a/src/unit_tests/models/graph/partition_into_triangles.rs +++ b/src/unit_tests/models/graph/partition_into_triangles.rs @@ -1,3 +1,20 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]}}); + let problem: PartitionIntoTriangles = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: PartitionIntoTriangles = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + let mut data = valid.clone(); + data["graph"] = serde_json::json!({"num_vertices":2,"edges":[]}); + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/rural_postman.rs b/src/unit_tests/models/graph/rural_postman.rs index aec2d3a5d..c657749be 100644 --- a/src/unit_tests/models/graph/rural_postman.rs +++ b/src/unit_tests/models/graph/rural_postman.rs @@ -1,3 +1,23 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_lengths":[1,1],"required_edges":[0]}); + let problem: RuralPostman = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: RuralPostman = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("edge_lengths", serde_json::json!([])), + ("required_edges", serde_json::json!([2])), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs index 5135bd671..2d213acf2 100644 --- a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs +++ b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs @@ -1,3 +1,31 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"graph":{"num_vertices":3,"edges":[[0,1],[1,2]]},"edge_lengths":[1,1],"edge_weights":[1,1],"source_vertex":0,"target_vertex":2,"weight_bound":2}); + let problem: ShortestWeightConstrainedPath = + serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: ShortestWeightConstrainedPath = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("edge_lengths", serde_json::json!([])), + ("edge_weights", serde_json::json!([])), + ("edge_lengths", serde_json::json!([0, 1])), + ("edge_weights", serde_json::json!([0, 1])), + ("source_vertex", serde_json::json!(3)), + ("target_vertex", serde_json::json!(3)), + ("weight_bound", serde_json::json!(0)), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()) + .is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs index 635e66453..8304875e4 100644 --- a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs +++ b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs @@ -1,4 +1,41 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"lengths":[1,2],"deadlines":[1,3],"precedences":[[0,1]]}); + let problem: MinimumTardinessSequencing = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumTardinessSequencing = + serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("lengths", serde_json::json!([0, 2])), + ("deadlines", serde_json::json!([])), + ("precedences", serde_json::json!([[2, 1]])), + ("precedences", serde_json::json!([[0, 2]])), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; + +#[test] +fn test_unit_json_rejects_invalid_task_data() { + let problem = MinimumTardinessSequencing::new(2, vec![1, 2], vec![(0, 1)]); + let valid = serde_json::to_value(&problem).unwrap(); + for (field, value) in [ + ("deadlines", serde_json::json!([])), + ("precedences", serde_json::json!([[0, 2]])), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!(serde_json::from_value::>(data).is_err()); + } +} use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; diff --git a/src/unit_tests/models/set/minimum_set_covering.rs b/src/unit_tests/models/set/minimum_set_covering.rs index 299647f7c..ba4cb6959 100644 --- a/src/unit_tests/models/set/minimum_set_covering.rs +++ b/src/unit_tests/models/set/minimum_set_covering.rs @@ -1,3 +1,23 @@ +#[test] +fn test_json_enforces_construction_constraints() { + let valid = serde_json::json!({"universe_size":2,"sets":[[0],[1]],"weights":[1,1]}); + let problem: MinimumSetCovering = serde_json::from_value(valid.clone()).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + let restored: MinimumSetCovering = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), encoded); + for (field, value) in [ + ("weights", serde_json::json!([])), + ("sets", serde_json::json!([[0], [2]])), + ] { + let mut data = valid.clone(); + data[field] = value; + assert!( + serde_json::from_value::>(data.clone()).is_err(), + "accepted {data}" + ); + } +} + use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; diff --git a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs index c3e5fb245..2441ab52a 100644 --- a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -35,10 +35,7 @@ fn test_partitionintocliques_aggregate_applies_gadget_offset() { #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_closed_loop() { - let source: PartitionIntoCliques = serde_json::from_value(serde_json::json!({ - "graph": {"num_vertices": 0, "edges": []}, "num_cliques": 0 - })) - .unwrap(); + let source = PartitionIntoCliques::new(SimpleGraph::empty(1), 1); let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); @@ -154,11 +151,15 @@ fn test_partitionintocliques_native_bounds_and_adjacency_semantics() { (3, vec![(0, 1), (1, 0), (0, 0)]), ] { for bound in [0, 1, n, n + 1, usize::MAX] { - let source: PartitionIntoCliques = - serde_json::from_value(serde_json::json!({ + let source = + serde_json::from_value::>(serde_json::json!({ "graph": {"num_vertices": n, "edges": edges}, "num_cliques": bound - })) - .unwrap(); + })); + if bound == 0 || bound > n { + assert!(source.is_err()); + continue; + } + let source = source.unwrap(); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = ReductionResult::target_problem(&reduction); diff --git a/tests/suites/reductions.rs b/tests/suites/reductions.rs index 51f4746e4..a60ece8ea 100644 --- a/tests/suites/reductions.rs +++ b/tests/suites/reductions.rs @@ -337,10 +337,7 @@ mod partition_into_cliques_covering_by_cliques_reductions { #[test] fn test_partition_into_cliques_to_covering_by_cliques_closed_loop() { - let source: PartitionIntoCliques = serde_json::from_value(serde_json::json!({ - "graph": {"num_vertices": 0, "edges": []}, "num_cliques": 0 - })) - .unwrap(); + let source = PartitionIntoCliques::new(SimpleGraph::empty(1), 1); let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); From 90b8c4faaccd9566a869bc74f52bdacfcbcb62a5 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 19 Sep 2026 04:10:29 +0800 Subject: [PATCH 05/44] fix: correct model variants, reference solvers, and reductions Use exact integer CVP and a bipartite solver for K2 coloring. Validate derived model state and report reference-solver size and interval failures explicitly. Correct signed vertex-cover weights and prize-collecting Steiner gadget costs, with regression tests and updated proofs. --- docs/paper/reductions.typ | 71 +++--- .../src/commands/create/tests.rs | 16 +- .../algebraic/closest_vector_problem.rs | 216 +++++------------- src/models/algebraic/mod.rs | 2 +- src/models/graph/kcoloring.rs | 19 +- src/models/graph/monochromatic_triangle.rs | 20 +- src/models/graph/steiner_tree.rs | 8 +- src/rules/closestvectorproblem_casts.rs | 35 --- src/rules/closestvectorproblem_qubo.rs | 4 +- src/rules/coloring_ilp.rs | 4 +- ...bycliques_minimumintersectiongraphbasis.rs | 6 +- ...inimumvertexcover_minimumfeedbackarcset.rs | 20 +- src/rules/mod.rs | 1 - ...rizecollectingsteinerforest_steinertree.rs | 48 +++- src/rules/steinertree_ilp.rs | 4 +- src/rules/subsetsum_closestvectorproblem.rs | 38 ++- .../customized/closest_vector_problem.rs | 73 +++--- .../customized/minimum_decision_tree.rs | 25 +- .../customized/shortest_common_superstring.rs | 29 ++- src/solvers/customized/solver.rs | 51 ++++- src/solvers/decision_search.rs | 29 ++- src/solvers/mod.rs | 6 + src/unit_tests/graph_models.rs | 10 +- .../algebraic/closest_vector_problem.rs | 113 +++++---- src/unit_tests/models/graph/kcoloring.rs | 40 +++- .../models/graph/monochromatic_triangle.rs | 27 +++ src/unit_tests/models/graph/steiner_tree.rs | 24 +- src/unit_tests/registry/variant.rs | 2 +- .../rules/closestvectorproblem_casts.rs | 29 --- .../rules/closestvectorproblem_qubo.rs | 42 +++- src/unit_tests/rules/coloring_ilp.rs | 14 +- ...inimumvertexcover_minimumfeedbackarcset.rs | 46 +++- ...rizecollectingsteinerforest_steinertree.rs | 106 ++++++++- .../rules/subsetsum_closestvectorproblem.rs | 29 ++- .../customized/closest_vector_problem.rs | 83 +++---- .../customized/minimum_decision_tree.rs | 17 ++ .../customized/shortest_common_superstring.rs | 18 ++ src/unit_tests/solvers/customized/solver.rs | 40 ++++ src/unit_tests/solvers/decision_search.rs | 169 +++++++++----- 39 files changed, 894 insertions(+), 640 deletions(-) delete mode 100644 src/rules/closestvectorproblem_casts.rs delete mode 100644 src/unit_tests/rules/closestvectorproblem_casts.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index f13d0a2d6..143cc6907 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -3280,7 +3280,7 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| let steiner-verts = tree-verts.filter(v => not terminals.contains(v)) [ #problem-def("SteinerTree")[ - Given an undirected graph $G = (V, E)$ with edge weights $w: E -> RR_(>= 0)$ and a set of terminal vertices $T subset.eq V$ with $|T| >= 2$, find a tree $S = (V_S, E_S)$ in $G$ such that $T subset.eq V_S$, minimizing $sum_(e in E_S) w(e)$. Vertices in $V_S backslash T$ are called _Steiner vertices_. + Given an undirected graph $G = (V, E)$ with edge weights $w: E -> RR_(>= 0)$ and a nonempty set of terminal vertices $T subset.eq V$, find a tree $S = (V_S, E_S)$ in $G$ such that $T subset.eq V_S$, minimizing $sum_(e in E_S) w(e)$. Vertices in $V_S backslash T$ are called _Steiner vertices_. For a single terminal, the tree consisting of that vertex and no edges is feasible. ][ One of Karp's 21 NP-complete problems @karp1972, foundational in network design with applications in telecommunications backbone routing, VLSI chip interconnect, pipeline planning, and phylogenetic tree construction. When $T = V$, the problem reduces to the minimum spanning tree (polynomial). The NP-hardness arises from choosing which Steiner vertices to include. @@ -5507,14 +5507,14 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| let dist-rounded = calc.round(dist, digits: 3) [ #problem-def("ClosestVectorProblem")[ - Given a full-column-rank integer lattice basis $bold(B) in ZZ^(m times n)$, whose columns span $cal(L)(bold(B)) = {bold(B) bold(x) : bold(x) in ZZ^n}$, and target $bold(t) in RR^m$, find $bold(x) in ZZ^n$ minimizing $norm(bold(B) bold(x) - bold(t))_2$. + Given a full-column-rank integer lattice basis $bold(B) in ZZ^(m times n)$, whose columns span $cal(L)(bold(B)) = {bold(B) bold(x) : bold(x) in ZZ^n}$, and target $bold(t) in ZZ^m$, find $bold(x) in ZZ^n$ minimizing the squared distance $norm(bold(B) bold(x) - bold(t))_2^2$. ][ - The Closest Vector Problem is a fundamental lattice problem @micciancio2002 and is NP-hard @vanemde1981. The implementation provides an integer-target variant for exact reduction data and a finite-`f64` target variant for real input; both keep the lattice basis integral and place no bounds on $bold(x)$. Its reference solver uses exact rational Gram--Schmidt projections and sphere-enumeration bounds following the recursive enumeration structure of Fincke and Pohst @fincke1985. Finite `f64` targets are interpreted as their exact binary rational values. The solver is intended for small instances. Kannan's enumeration algorithm @kannan1987 solves CVP in $n^(O(n))$ time; Micciancio and Voulgaris @micciancio2010 improved this to deterministic $O^*(4^n)$, and Aggarwal, Dadush, and Stephens-Davidowitz @aggarwal2015 achieved randomized $O^*(2^n)$. + The Closest Vector Problem is a fundamental lattice problem @micciancio2002 and is NP-hard @vanemde1981. The implementation uses integer basis and target coordinates and reports squared distance with checked integer arithmetic. Squaring preserves the Euclidean minimizers without introducing rounding. Its reference solver uses exact rational Gram--Schmidt projections and sphere-enumeration bounds following the recursive enumeration structure of Fincke and Pohst @fincke1985. The solver is intended for small instances. Kannan's enumeration algorithm @kannan1987 solves CVP in $n^(O(n))$ time; Micciancio and Voulgaris @micciancio2010 improved this to deterministic $O^*(4^n)$, and Aggarwal, Dadush, and Stephens-Davidowitz @aggarwal2015 achieved randomized $O^*(2^n)$. - *Example.* Consider the 2D lattice with basis #range(basis.len()).map(j => $bold(b)_#(j + 1) = #fmt-vec(basis.at(j))$).join(", ") and target $bold(t) = #fmt-vec(target)$. The point $bold(B)(#coords.map(c => str(c)).join(","))^top = (#bx.map(v => str(int(v))).join(", "))^top$ equals the target, so it is a closest lattice point with distance #dist-rounded. + *Example.* Consider the 2D lattice with basis #range(basis.len()).map(j => $bold(b)_#(j + 1) = #fmt-vec(basis.at(j))$).join(", ") and target $bold(t) = #fmt-vec(target)$. The point $bold(B)(#coords.map(c => str(c)).join(","))^top = (#bx.map(v => str(int(v))).join(", "))^top$ equals the target, so it is a closest lattice point with squared distance #dist-rounded. #pred-commands( - "pred create --example ClosestVectorProblem -o closest-vector-problem.json", + "pred create --example " + problem-spec(x) + " -o closest-vector-problem.json", "pred solve closest-vector-problem.json", "pred evaluate closest-vector-problem.json --config " + cli-config(x.optimal_config), ) @@ -12313,7 +12313,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m together with target $ bold(t) = (#fmt-values(ss-cvp-target-vec))^top $ in the standard CVP model, with no coefficient bounds. - *Step 3 -- Verify the canonical witness.* The fixture stores coefficients $(#fmt-values(ss-cvp-x))$. Its first four entries select sizes $3$ and $8$, and the final three are carry coefficients. The first coordinate block has residual $(1,0,0,1)$, the second has $(0,-1,-1,0)$, and all bit-equation residuals are zero. Thus the Euclidean distance is $sqrt(4) = 2$. + *Step 3 -- Verify the canonical witness.* The fixture stores coefficients $(#fmt-values(ss-cvp-x))$. Its first four entries select sizes $3$ and $8$, and the final three are carry coefficients. The first coordinate block has residual $(1,0,0,1)$, the second has $(0,-1,-1,0)$, and all bit-equation residuals are zero. Thus the squared distance is $4$. *Witness semantics.* The example DB stores one canonical minimizer. This source instance also has another satisfying subset, $(1, 1, 1, 0)$, so the reduction has multiple optimal CVP witnesses even though only one is serialized. ], @@ -12326,11 +12326,11 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Correctness._ Every integer vector satisfies $ norm(bold(B) bold(z)-bold(t))_2^2 = sum_i (x_i^2 + (x_i-1)^2) + sum_j r_j^2 >= n. $ - ($arrow.r.double$) For a binary subset summing to $T$, ordinary integer addition gives carries $0 <= c_j <= n$ satisfying all bit equations and both boundaries. Its squared distance equals $n$. ($arrow.l.double$) Squared distance at most $n$ forces each $x_i in {0,1}$ and each $r_j=0$. Multiplying the bit equations by $2^j$ and summing cancels the internal carries, yielding $sum_i s_i x_i=T$. Thus the optimum is $sqrt(n)$ exactly for YES instances. Empty item lists and target zero use the same construction. + ($arrow.r.double$) For a binary subset summing to $T$, ordinary integer addition gives carries $0 <= c_j <= n$ satisfying all bit equations and both boundaries. Its squared distance equals $n$. ($arrow.l.double$) Squared distance at most $n$ forces each $x_i in {0,1}$ and each $r_j=0$. Multiplying the bit equations by $2^j$ and summing cancels the internal carries, yielding $sum_i s_i x_i=T$. Thus the optimum squared distance is $n$ exactly for YES instances. Empty item lists and target zero use the same construction. - _Solution extraction._ Validate the target configuration once and require a finite distance exactly $sqrt(n)$ through the formal aggregate certificate. Return the first $n$ coefficients as Boolean selections, accepting one as true; the remaining coefficients are the specified carries. A larger optimal distance proves NO and provides no source witness. + _Solution extraction._ Validate the target configuration once and require squared distance exactly $n$ through the formal aggregate certificate. Return the first $n$ coefficients as Boolean selections, accepting one as true; the remaining coefficients are the specified carries. A larger optimal squared distance proves NO and provides no source witness. - _Representation._ The target has $2n+b$ coordinates and $n+b-1$ basis columns. Since bit length is not a registered Subset Sum parameter, the symbolic relations are marked unavailable with that reason. Dimensions and the total dense basis byte count are checked before allocation. On a 64-bit platform this bounds $n < 2^30$; the threshold and the unit squared-distance gap remain distinguishable in the target's floating-point evaluation. The paired coordinates and boundary carry equations also ensure every threshold witness has exactly evaluated small integer residuals. The solver uses exact rational sphere-enumeration bounds; runtime limitations are separate from the mathematical equivalence. + _Representation._ The target has $2n+b$ coordinates and $n+b-1$ basis columns. Since bit length is not a registered Subset Sum parameter, the symbolic relations are marked unavailable with that reason. Dimensions and the total dense basis byte count are checked before allocation. The threshold and squared-distance evaluation use checked `i64` arithmetic; overflow is an error, not a NO certificate. The solver uses exact rational sphere-enumeration bounds; runtime limitations are separate from the mathematical equivalence. ] ] } @@ -16694,15 +16694,6 @@ Problems parameterized by graph type, weight type, target type, or clause width _Solution extraction._ Return the target configuration unchanged. ] -#reduction-rule("ClosestVectorProblem", "ClosestVectorProblem")[ - An integer-target CVP instance converts to the floating-target variant by embedding every target coordinate with `i64_to_exact_f64`. The integer lattice basis is copied unchanged. -][ - _Construction._ Given $(B, bold(t))$ with $B in ZZ^(m times n)$ and $bold(t) in ZZ^m$, construct $(B, bold(t)')$ with $t'_i = "f64"(t_i)$ for every exactly representable coordinate $|t_i| lt.eq 2^53 - 1$. - - _Correctness._ Exact coordinate conversion gives $bold(t)' = bold(t)$ in $RR^m$. Therefore $norm(B bold(x) - bold(t)')_2 = norm(B bold(x) - bold(t))_2$ for every $bold(x) in ZZ^n$, so the minimizers coincide. - - _Solution extraction._ Return the integer coefficient vector unchanged. -] #reduction-rule("QUBO", "QUBO")[ An integer QUBO converts to the floating-coefficient variant by embedding every matrix coefficient with `i64_to_exact_f64`. @@ -17625,13 +17616,13 @@ The following table shows concrete target-variable counts for example instances, *Multiplicity:* The fixture stores one canonical witness. By symmetry of the triangle, any two-vertex cover is optimal. ], )[ - Each vertex $v$ splits into $v^"in"$ and $v^"out"$ joined by an internal arc weighted $w(v)$. Each edge becomes two crossing arcs weighted $M = 1 + sum_v w(v)$. The optimal FAS never includes crossing arcs; selecting internal arcs for cover vertices breaks every cycle. + Each vertex $v$ splits into $v^"in"$ and $v^"out"$ joined by an internal arc weighted $w(v)$. Each edge becomes two crossing arcs weighted $M = 1 + sum_v max(w(v), 0)$. The optimal FAS never includes crossing arcs; selecting internal arcs for cover vertices breaks every cycle. ][ - _Construction._ Given $(G, w)$ with $G = (V, E)$, $n = |V|$. Build directed graph $H$ on $2n$ nodes. Internal arcs $(v^"in", v^"out")$ with weight $w(v)$. For each ${u,v} in E$: crossing arcs $(u^"out", v^"in")$ and $(v^"out", u^"in")$ with weight $M = 1 + sum_(v in V) w(v)$. + _Construction._ Given $(G, w)$ with $G = (V, E)$, $n = |V|$. Build directed graph $H$ on $2n$ nodes. Internal arcs $(v^"in", v^"out")$ with weight $w(v)$. For each ${u,v} in E$: crossing arcs $(u^"out", v^"in")$ and $(v^"out", u^"in")$ with weight $M = 1 + sum_(v in V) max(w(v), 0)$. - _Correctness._ ($arrow.r.double$) A vertex cover $S$ gives FAS $F = {(v^"in", v^"out") : v in S}$; every cycle through a crossing arc has at least one internal arc in $F$. ($arrow.l.double$) Since $M$ exceeds total internal weight, no crossing arc is in the optimal FAS. For each edge ${u,v}$, the 4-cycle through both internal and crossing arcs forces at least one internal arc into $F$. + _Correctness._ ($arrow.r.double$) A vertex cover $S$ gives FAS $F = {(v^"in", v^"out") : v in S}$; every cycle through a crossing arc has at least one internal arc in $F$. ($arrow.l.double$) If an FAS selects any crossing arc, replace all selected crossing arcs by all internal arcs. This still breaks every cycle, adds weight at most $sum_v max(w(v), 0)$, and removes weight at least $M$, strictly reducing cost. Thus an optimal FAS contains only internal arcs. Each source edge then forces at least one endpoint's internal arc into the FAS (also for a self-loop), yielding a cover of equal weight. - _Solution extraction._ Internal arcs at positions $0, dots, n-1$; the cover is $c[0 : n]$. + _Solution extraction._ Internal arcs at positions $0, dots, n-1$; the cover is $c[0 : n]$. Reject a target candidate if these vertices leave any source edge uncovered; target feasibility alone does not guarantee that this mapping produces a cover. ] #let ksat_kc = load-example("KSatisfiability", "KClique") @@ -19623,36 +19614,28 @@ The following table shows concrete target-variable counts for example instances, )[ Bienstock, Goemans, Simchi-Levi, Williamson @BienstockGoemansSimchiLeviWilliamson1993 introduced the prize/penalty framework for prize-collecting network design; Tuncbag and coauthors @TuncbagEtAl2013PCSF @TuncbagEtAl2012RECOMB used the same artificial-root idea to translate PCSF into a rooted prize-collecting Steiner tree on biological networks. The combined construction recorded here adds a per-vertex auxiliary-terminal gadget that compiles the remaining omitted-prize term `beta * p(v)` into ordinary Steiner-tree edge costs, so the target is a plain (unweighted-prize) Steiner Tree instance. ][ - _Construction._ Given a PCSF instance with graph $G = (V, E)$, edge costs $c$, vertex prizes $p$, and parameters $beta >= 0$, $omega >= 0$, let $V_p = {v in V : p(v) > 0}$ and $k = |V_p|$. Build the target graph $H = (V_H, E_H)$ with weights $c_H$ and terminal set $T_H$ as follows. - - 1. Add a fresh artificial root $r$: $V_H = V union {r} union {t_v : v in V_p}$. - 2. Keep every original edge $e in E$ with $c_H(e) = c(e)$. - 3. For every $v in V$, add a root-attachment edge $(r, v)$ with $c_H((r, v)) = omega$. - 4. For every prized vertex $v in V_p$, add an include-edge $(v, t_v)$ with cost $0$ and an omit-edge $(r, t_v)$ with cost $beta dot p(v)$. - 5. Set $T_H = {r} union {t_v : v in V_p}$. Original vertices $V$ and the new gadget terminals coexist; only $r$ and the $t_v$ are terminals. - - Solve $"SteinerTree"(H, c_H, T_H)$ to obtain a minimum-weight tree $T^*$ spanning $T_H$. - - _Witness extraction._ From $T^*$ recover the PCSF witness $(V_F, E_F)$ by - - $ E_F = T^* inter E(G), quad V_F = { v in V : (v, t_v) in T^* } union { "endpoints of edges in" E_F }. $ - - Equivalently, deleting $r$ and the gadget vertices ${t_v}$ from $T^*$ leaves a disjoint union of trees on $V$; $V_F$ is the set of original vertices touched by this restricted forest, and $E_F$ is exactly $T^* inter E(G)$. Both directions are consistent because: + _Construction._ Given a PCSF instance with graph $G = (V, E)$, nonnegative edge costs $c$, nonnegative vertex prizes $p$, and parameters $beta >= 0$, $omega >= 0$, let $V_p = {v in V : p(v) > 0}$, $k = |V_p|$, and $M = omega + 1$. - - any prized vertex $v$ in $V_F$ pays the cost-$0$ include-edge $(v, t_v)$ to reach $t_v$ inside $T^*$; - - any prized vertex $v$ omitted from $V_F$ has $t_v$ joined to the tree exclusively through $(r, t_v)$, paying $beta dot p(v)$. + 1. Add an artificial root $r$ and gadget terminals $t_v$: $V_H = V union {r} union {t_v : v in V_p}$. + 2. Keep every original edge $e in E$ with cost $c(e)$. + 3. For every $v in V$, add $(r, v)$ with cost $omega$. + 4. For every $v in V_p$, add an include-edge $(v, t_v)$ of cost $M$ and an omit-edge $(r, t_v)$ of cost $M + beta dot p(v)$. + 5. Set $T_H = {r} union {t_v : v in V_p}$. - _Correctness._ ($arrow.r.double$) Given any feasible source forest $F$, attach each connected component of $F$ to $r$ via exactly one root-attachment edge (cost $omega$ per component) and resolve each gadget locally: take $(v, t_v)$ if $v in V_F$, else $(r, t_v)$. The resulting subgraph of $H$ is connected, spans $T_H$, and is a tree because every gadget is paid by exactly one of its two edges and the only chord that could close a cycle is removed by the choice of a single root-attachment edge per component. Its cost equals + _Witness extraction._ From an optimal target tree $T^*$ recover + $ E_F = T^* inter E(G), quad V_F = {v in V : (v, t_v) in T^*} union {"endpoints of edges in" E_F}. $ + The restriction is acyclic and contains every endpoint of a selected source edge. - $ sum_(e in E_F) c(e) + omega dot kappa(F) + beta dot sum_(v in.not V_F) p(v) + 0 = f'(F). $ + _Correctness._ ($arrow.r.double$) Attach each component of a feasible forest $F$ to $r$ once. Select the include-edge for each included prized vertex and the omit-edge otherwise. The result is a tree spanning all terminals, of cost + $ sum_(e in E_F) c(e) + omega dot kappa(F) + beta dot sum_(v in.not V_F) p(v) + k M = f'(F) + k M. $ - ($arrow.l.double$) Conversely, given an optimal Steiner tree $T^*$, the restriction $E_F = T^* inter E(G)$ is acyclic (subset of a tree) and respects the PCSF feasibility constraint that selected edges only touch selected vertices, because every endpoint $v$ of an edge in $E_F$ is forced into $V_F$ by the extraction rule. Each connected component of $F$ corresponds to a maximal subtree of $T^*$ confined to $V$, and any optimal $T^*$ uses exactly one root-attachment edge per component (a second incident root edge could be replaced by a cheaper internal path, contradicting optimality). Each prized vertex $v in V_F$ is reached by $T^*$ via original edges, so the include-edge $(v, t_v)$ is selected for free; each omitted prized vertex contributes the omit-edge $(r, t_v)$ of cost $beta dot p(v)$. Summing the contributions reproduces $f'(F)$, so $"cost"_H(T^*) = f'(F^*)$ at optima and the extracted forest is optimal for PCSF. + ($arrow.l.double$) A gadget terminal cannot have both incident edges in an optimum: replacing its omit-edge by $(r,v)$ preserves the tree and lowers cost by $M + beta p(v) - omega > 0$. Thus each gadget terminal is a leaf, contributing a common offset $M$. Each remaining component of original vertices has exactly one root attachment, since two would form a cycle. Extraction may discard isolated zero-prize vertices, which cannot increase cost. Any omitted prized vertex has its omit-edge selected. Therefore the extracted forest has cost at most $"cost"(T^*) - k M$. Combined with the forward construction, this proves equality of the optimal costs up to the offset and optimality of every extracted target optimum. _Overhead._ With $n = |V|$, $m = |E|$, and $k = |V_p|$: $ |V_H| = n + k + 1, quad |E_H| = m + n + 2 k, quad |T_H| = k + 1. $ - Every quantity is linear in the source instance size, so the reduction is a polynomial-time transformation. + Every quantity is linear in the source instance size. - _Remark._ The artificial-root edges all share cost $omega$. Tuncbag et al. originally used this construction with $omega = c$ for any positive scalar $c$ acting as a per-component penalty; we follow that convention. When $omega = 0$, root-attachment edges become free and the construction degenerates: any rooted spanning tree of the prized-vertex closure achieves the same cost, but the witness-extraction recipe still recovers a feasible (cost-equivalent) PCSF forest, possibly with a different component count. + _Boundary cases._ When $k=0$, the target has only terminal $r$; the edge-free tree maps to the empty source forest of cost zero. The same construction works when $beta=0$ or $omega=0$. Gadget costs use checked integer arithmetic. ] #pagebreak() diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index e6babc485..4fd410129 100644 --- a/problemreductions-cli/src/commands/create/tests.rs +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -360,7 +360,7 @@ fn test_create_schema_driven_builds_integer_target_closest_vector_problem() { } #[test] -fn test_create_schema_driven_builds_real_target_closest_vector_problem() { +fn test_create_rejects_fractional_cvp_target() { let cli = Cli::try_parse_from([ "pred", "create", @@ -370,20 +370,12 @@ fn test_create_schema_driven_builds_real_target_closest_vector_problem() { "--target-vec", "0.5,1.25", ]) - .expect("create command parses"); - + .unwrap(); let Commands::Create(args) = cli.command else { panic!("expected create command"); }; - - let resolved_variant = BTreeMap::from([("target".to_string(), "f64".to_string())]); - let (data, variant) = create_schema_driven(&args, "ClosestVectorProblem", &resolved_variant) - .expect("schema-driven create should parse"); - let entry = problemreductions::registry::find_variant_entry("ClosestVectorProblem", &variant) - .expect("variant entry"); - (entry.factory)(data.clone()).expect("factory should deserialize generated JSON"); - assert_eq!(data["basis"], serde_json::json!([[1, 0], [0, 1]])); - assert_eq!(data["target"], serde_json::json!([0.5, 1.25])); + let variant = BTreeMap::from([("target".into(), "i64".into())]); + assert!(create_schema_driven(&args, "ClosestVectorProblem", &variant).is_err()); } #[test] diff --git a/src/models/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index d166b32fb..dd433b4dd 100644 --- a/src/models/algebraic/closest_vector_problem.rs +++ b/src/models/algebraic/closest_vector_problem.rs @@ -1,110 +1,59 @@ //! Closest Vector Problem (CVP). //! //! Given an integer lattice basis `B` and a target vector `t`, find integer -//! coefficients `x` minimizing `||Bx - t||_2`. +//! coefficients `x` minimizing the squared distance `||Bx - t||_2^2`. use crate::registry::{ConstructionError, CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::traits::{EvaluationError, Problem}; use crate::types::Min; +use num_bigint::BigInt; +use num_traits::Zero; use serde::{Deserialize, Serialize}; -/// Target coordinate domains supported by [`ClosestVectorProblem`]. -pub trait ClosestVectorTarget: Clone + std::fmt::Debug + 'static { - /// Registered value of the `target` variant dimension. - const NAME: &'static str; - - /// Validate one stored target coordinate. - fn validate(&self, index: usize) -> Result<(), ConstructionError>; - - /// Convert one coordinate for numerical evaluation and solving. - fn to_f64(&self) -> Result; -} - -impl ClosestVectorTarget for i64 { - const NAME: &'static str = "i64"; - - fn validate(&self, _index: usize) -> Result<(), ConstructionError> { - Ok(()) - } - - fn to_f64(&self) -> Result { - crate::types::i64_to_exact_f64(*self) - .map_err(|error| EvaluationError::InexactFloatConversion(error.to_string())) - } +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct ClosestVectorProblemCreateSpec { + /// Integer basis matrix as semicolon-separated column vectors. + #[create(codec = "semicolon-separated")] + basis: Vec>, + /// Integer target vector. + #[create(name = "target_vec", codec = "comma-separated")] + target: Vec, } -impl ClosestVectorTarget for f64 { - const NAME: &'static str = "f64"; - - fn validate(&self, index: usize) -> Result<(), ConstructionError> { - if self.is_finite() { - Ok(()) - } else { - Err(ConstructionError::NonFiniteFloat(format!( - "target coordinate at index {index} must be finite" - ))) - } - } +impl TryFrom for ClosestVectorProblem { + type Error = ConstructionError; - fn to_f64(&self) -> Result { - Ok(*self) + fn try_from(spec: ClosestVectorProblemCreateSpec) -> Result { + Self::new(spec.basis, spec.target) } } -macro_rules! cvp_create_spec { - ($name:ident, $target:ty) => { - #[derive(Debug, Deserialize, crate::CreateSpec)] - struct $name { - /// Integer basis matrix as semicolon-separated column vectors. - #[create(codec = "semicolon-separated")] - basis: Vec>, - /// Target vector. - #[create(name = "target_vec", codec = "comma-separated")] - target: Vec<$target>, - } - - impl TryFrom<$name> for ClosestVectorProblem<$target> { - type Error = ConstructionError; - - fn try_from(spec: $name) -> Result { - ClosestVectorProblem::new(spec.basis, spec.target) - } - } - }; -} - -cvp_create_spec!(ClosestVectorProblemI64CreateSpec, i64); -cvp_create_spec!(ClosestVectorProblemF64CreateSpec, f64); - inventory::submit! { ProblemSchemaEntry { name: "ClosestVectorProblem", display_name: "Closest Vector Problem", aliases: &["CVP"], - dimensions: &[VariantDimension::new("target", "i64", &["i64", "f64"])], + dimensions: &[VariantDimension::new("target", "i64", &["i64"])], category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find the closest point in an integer lattice to a target vector", - fields: ClosestVectorProblemI64CreateSpec::FIELDS, + fields: ClosestVectorProblemCreateSpec::FIELDS, } } /// Euclidean Closest Vector Problem over an integer lattice basis. #[derive(Debug, Clone, Serialize)] -pub struct ClosestVectorProblem { +pub struct ClosestVectorProblem { /// Basis matrix stored as column vectors. basis: Vec>, /// Target vector in the ambient space. - target: Vec, + target: Vec, } -impl ClosestVectorProblem { +impl ClosestVectorProblem { /// Construct a CVP instance with a full-column-rank integer basis. - pub fn new(basis: Vec>, target: Vec) -> Result { + pub fn new(basis: Vec>, target: Vec) -> Result { let ambient_dimension = target.len(); - for (index, coordinate) in target.iter().enumerate() { - coordinate.validate(index)?; - } for (index, column) in basis.iter().enumerate() { if column.len() != ambient_dimension { return Err(ConstructionError::Conversion(format!( @@ -119,7 +68,7 @@ impl ClosestVectorProblem { basis.len() ))); } - if independent_rows(&basis, ambient_dimension)?.is_none() { + if independent_rows(&basis, ambient_dimension).is_none() { return Err(ConstructionError::Conversion( "closest-vector basis columns must be linearly independent".into(), )); @@ -143,12 +92,12 @@ impl ClosestVectorProblem { } /// Target coordinates. - pub fn target(&self) -> &[T] { + pub fn target(&self) -> &[i64] { &self.target } pub(crate) fn independent_rows(&self) -> Result, ConstructionError> { - independent_rows(&self.basis, self.ambient_dimension())?.ok_or_else(|| { + independent_rows(&self.basis, self.ambient_dimension()).ok_or_else(|| { ConstructionError::Conversion( "closest-vector basis columns must be linearly independent".into(), ) @@ -156,67 +105,53 @@ impl ClosestVectorProblem { } } -fn independent_rows( - basis: &[Vec], - ambient_dimension: usize, -) -> Result>, ConstructionError> { +fn independent_rows(basis: &[Vec], ambient_dimension: usize) -> Option> { let num_columns = basis.len(); if num_columns == 0 { - return Ok(Some(Vec::new())); + return Some(Vec::new()); } let mut matrix = (0..ambient_dimension) - .map(|row| basis.iter().map(|column| column[row]).collect::>()) + .map(|row| { + basis + .iter() + .map(|column| BigInt::from(column[row])) + .collect::>() + }) .collect::>(); - let mut previous_pivot = 1_i64; + // Rank is an exact predicate; elimination intermediates are not model fields. + let mut previous_pivot = BigInt::from(1); let mut row_indices = (0..ambient_dimension).collect::>(); for column in 0..num_columns { - let Some(pivot_row) = (column..ambient_dimension).find(|&row| matrix[row][column] != 0) - else { - return Ok(None); - }; + let pivot_row = (column..ambient_dimension).find(|&row| !matrix[row][column].is_zero())?; matrix.swap(column, pivot_row); row_indices.swap(column, pivot_row); - let pivot = matrix[column][column]; + let pivot = matrix[column][column].clone(); for row in (column + 1)..ambient_dimension { for next_column in (column + 1)..num_columns { - let left = matrix[row][next_column] - .checked_mul(pivot) - .ok_or_else(rank_overflow)?; - let right = matrix[row][column] - .checked_mul(matrix[column][next_column]) - .ok_or_else(rank_overflow)?; - let numerator = left.checked_sub(right).ok_or_else(rank_overflow)?; - matrix[row][next_column] = numerator - .checked_div(previous_pivot) - .ok_or_else(rank_overflow)?; + matrix[row][next_column] = (&matrix[row][next_column] * &pivot + - &matrix[row][column] * &matrix[column][next_column]) + / &previous_pivot; } - matrix[row][column] = 0; + matrix[row][column] = BigInt::zero(); } previous_pivot = pivot; } row_indices.truncate(num_columns); - Ok(Some(row_indices)) -} - -fn rank_overflow() -> ConstructionError { - ConstructionError::IntegerOverflow("checking closest-vector basis rank".into()) + Some(row_indices) } -impl<'de, T> Deserialize<'de> for ClosestVectorProblem -where - T: ClosestVectorTarget + Deserialize<'de>, -{ +impl<'de> Deserialize<'de> for ClosestVectorProblem { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { #[derive(Deserialize)] - struct Raw { + struct Raw { basis: Vec>, - target: Vec, + target: Vec, } let raw = Raw::deserialize(deserializer)?; @@ -224,20 +159,17 @@ where } } -impl Problem for ClosestVectorProblem -where - T: ClosestVectorTarget + Serialize + for<'de> Deserialize<'de>, -{ +impl Problem for ClosestVectorProblem { const NAME: &'static str = "ClosestVectorProblem"; type Solution = Vec; - type Value = Min; + type Value = Min; crate::problem_parameters![ ("ambient_dimension", ambient_dimension), ("num_basis_vectors", num_basis_vectors), ]; - fn evaluate(&self, solution: &Self::Solution) -> Result, EvaluationError> { + fn evaluate(&self, solution: &Self::Solution) -> Result, EvaluationError> { if solution.len() != self.num_basis_vectors() { return Err(EvaluationError::InvalidConfiguration(format!( "expected {} closest-vector coefficients, got {}", @@ -246,52 +178,30 @@ where ))); } - let mut displacement = self - .target - .iter() - .map(ClosestVectorTarget::to_f64) - .collect::, _>>()?; - for value in &mut displacement { - *value = -*value; - } - - for (&coefficient, column) in solution.iter().zip(&self.basis) { - let coefficient = crate::types::i64_to_exact_f64(coefficient) - .map_err(|error| EvaluationError::InexactFloatConversion(error.to_string()))?; - for (value, &basis_entry) in displacement.iter_mut().zip(column) { - let basis_entry = crate::types::i64_to_exact_f64(basis_entry) - .map_err(|error| EvaluationError::InexactFloatConversion(error.to_string()))?; - let next = *value + coefficient * basis_entry; - if !next.is_finite() { - return Err(EvaluationError::NonFiniteResult( - "computing closest-vector displacement".into(), - )); - } - *value = next; + let overflow = || EvaluationError::IntegerOverflow("computing CVP squared distance".into()); + let mut squared = 0_i64; + for (row, &target) in self.target.iter().enumerate() { + let mut coordinate = 0_i64; + for (&coefficient, column) in solution.iter().zip(&self.basis) { + coordinate = coordinate + .checked_add(coefficient.checked_mul(column[row]).ok_or_else(overflow)?) + .ok_or_else(overflow)?; } + let difference = coordinate.checked_sub(target).ok_or_else(overflow)?; + squared = squared + .checked_add(difference.checked_mul(difference).ok_or_else(overflow)?) + .ok_or_else(overflow)?; } - - let squared_norm = displacement.into_iter().try_fold(0.0, |total, value| { - let next = total + value * value; - if next.is_finite() { - Ok(next) - } else { - Err(EvaluationError::NonFiniteResult( - "computing closest-vector norm".into(), - )) - } - })?; - Ok(Min(Some(squared_norm.sqrt()))) + Ok(Min(Some(squared))) } fn variant() -> Vec<(&'static str, &'static str)> { - vec![("target", T::NAME)] + vec![("target", "i64")] } } crate::declare_variants! { - default ClosestVectorProblem => "2^(num_basis_vectors * log(num_basis_vectors))" create ClosestVectorProblemI64CreateSpec, - ClosestVectorProblem => "2^(num_basis_vectors * log(num_basis_vectors))" create ClosestVectorProblemF64CreateSpec, + default ClosestVectorProblem => "2^(num_basis_vectors * log(num_basis_vectors))" create ClosestVectorProblemCreateSpec, } #[cfg(feature = "example-db")] @@ -303,7 +213,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::Coloring if spec.k.is_some_and(|k| k != 3) { return Err("k must match the selected K3 variant".to_string().into()); } Ok(KColoring::new(spec.graph()?)) }); -crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { - if spec.k.is_some_and(|k| k != 4) { return Err("k must match the selected K4 variant".to_string().into()); } - Ok(KColoring::new(spec.graph()?)) -}); -crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { - if spec.k.is_some_and(|k| k != 5) { return Err("k must match the selected K5 variant".to_string().into()); } - Ok(KColoring::new(spec.graph()?)) -}); crate::declare_variants! { default KColoring => "2^num_vertices" create RuntimeKColoringCreateSpec random, - KColoring => "num_vertices + num_edges" create FixedKColoringCreateSpec, KColoring => "num_vertices + num_edges" create FixedKColoringCreateSpec random, KColoring => "1.3289^num_vertices" create FixedKColoringCreateSpec random, - KColoring => "1.7159^num_vertices" create FixedKColoringCreateSpec random, - // Best known: O*((2-ε)^n) for some ε > 0 (Zamir 2021), concrete ε unknown - KColoring => "2^num_vertices" create FixedKColoringCreateSpec random, } crate::register_brute_force! { KColoring, - KColoring, KColoring, KColoring, - KColoring, - KColoring, } #[cfg(test)] diff --git a/src/models/graph/monochromatic_triangle.rs b/src/models/graph/monochromatic_triangle.rs index 272037f0d..f454fb936 100644 --- a/src/models/graph/monochromatic_triangle.rs +++ b/src/models/graph/monochromatic_triangle.rs @@ -57,8 +57,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct MonochromaticTriangle { /// The underlying graph. graph: G, @@ -68,6 +67,23 @@ pub struct MonochromaticTriangle { edge_list: Vec<(usize, usize)>, } +// The persisted triangle and edge lists are derived data; loading rebuilds them. +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct MonochromaticTriangleData { + graph: G, +} + +impl<'de, G> Deserialize<'de> for MonochromaticTriangle +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MonochromaticTriangleData::::deserialize(deserializer)?; + Ok(Self::new(data.graph)) + } +} + impl MonochromaticTriangle { /// Create a new Monochromatic Triangle instance. pub fn new(graph: G) -> Self { diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index 6f819ecae..94285a464 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -47,6 +47,8 @@ inventory::submit! { /// - Selected edges form a tree (connected + acyclic) /// - All terminal vertices are included /// +/// With one terminal, selecting no edges represents that vertex alone. +/// /// # Type Parameters /// /// * `G` - The graph type (e.g., `SimpleGraph`) @@ -105,8 +107,8 @@ impl SteinerTree { if edge_weights.len() != graph.num_edges() { return Err("edge_weights length must match num_edges".into()); } - if terminals.len() < 2 { - return Err("at least 2 terminals required".into()); + if terminals.is_empty() { + return Err("at least one terminal required".into()); } let distinct_terminals: BTreeSet<_> = terminals.iter().copied().collect(); if distinct_terminals.len() != terminals.len() { @@ -222,7 +224,7 @@ fn is_valid_steiner_tree(graph: &G, terminals: &[usize], config: &[boo } if selected_count == 0 { - return false; + return terminals.len() == 1; } // BFS from first terminal to check connectivity diff --git a/src/rules/closestvectorproblem_casts.rs b/src/rules/closestvectorproblem_casts.rs deleted file mode 100644 index 9e45d59df..000000000 --- a/src/rules/closestvectorproblem_casts.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Numeric variant reduction for Closest Vector Problem. - -use crate::impl_variant_reduction; -use crate::models::algebraic::ClosestVectorProblem; -use crate::rules::ReductionError; -use crate::types::i64_to_exact_f64; - -impl_variant_reduction!( - ClosestVectorProblem, - => , - fields: [ambient_dimension, num_basis_vectors], - |src| { - let target = src - .target() - .iter() - .copied() - .map(i64_to_exact_f64) - .collect::, _>>() - .map_err(|error| { - ReductionError::inexact_float_conversion::< - ClosestVectorProblem, - ClosestVectorProblem, - >(error) - })?; - ClosestVectorProblem::new(src.basis().to_vec(), target).map_err(|error| { - ReductionError::construction::, ClosestVectorProblem>( - error, - ) - })? - } -); - -#[cfg(test)] -#[path = "../unit_tests/rules/closestvectorproblem_casts.rs"] -mod tests; diff --git a/src/rules/closestvectorproblem_qubo.rs b/src/rules/closestvectorproblem_qubo.rs index 3efa14979..b5654e166 100644 --- a/src/rules/closestvectorproblem_qubo.rs +++ b/src/rules/closestvectorproblem_qubo.rs @@ -12,7 +12,7 @@ use crate::rules::traits::{ReduceTo, ReductionResult}; use num_bigint::BigInt; use num_traits::Zero; -type Source = ClosestVectorProblem; +type Source = ClosestVectorProblem; type Target = QUBO; #[derive(Debug, Clone)] @@ -234,7 +234,7 @@ fn dot(left: &[i64], right: &[i64], operation: &str) -> Result> for ClosestVectorProblem { +impl ReduceTo> for ClosestVectorProblem { type Result = ReductionCVPToQUBO; fn reduce_to(&self) -> Result { diff --git a/src/rules/coloring_ilp.rs b/src/rules/coloring_ilp.rs index 37c6f5944..85102550e 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -13,7 +13,7 @@ use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; -use crate::variant::{KValue, K1, K2, K3, K4, KN}; +use crate::variant::{KValue, K2, K3, KN}; /// Result of reducing KColoring to ILP. /// @@ -128,7 +128,7 @@ macro_rules! impl_kcoloring_to_ilp { )+}; } -impl_kcoloring_to_ilp!(K1, K2, K3, K4); +impl_kcoloring_to_ilp!(K2, K3); #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { diff --git a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index 892f31b03..bac943d4d 100644 --- a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -8,7 +8,6 @@ use crate::models::graph::{MinimumCoveringByCliques, MinimumIntersectionGraphBas use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; -use crate::traits::Problem; use std::collections::BTreeMap; #[derive(Debug, Clone)] @@ -86,10 +85,11 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToMinimumIntersectionG &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; Ok({ - if !self.target.evaluate(target_solution)?.is_valid() { + if !value.is_valid() { return Err(crate::rules::ExtractionError::invalid( "target configuration is not a valid intersection graph basis", )); diff --git a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs index ef8f39d10..0cb8265ec 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -3,7 +3,7 @@ //! Each vertex v is split into v^in and v^out connected by an internal arc //! (v^in → v^out) with weight w(v). For each edge {u,v}, two crossing arcs //! (u^out → v^in) and (v^out → u^in) are added with a large penalty weight -//! M = 1 + Σ w(v). The penalty ensures no optimal FAS includes crossing arcs. +//! M = 1 + Σ max(w(v), 0). No optimal FAS includes crossing arcs. //! //! A vertex cover of the source maps to a feedback arc set of internal arcs: //! if vertex i is in the cover, remove internal arc i. @@ -19,6 +19,7 @@ pub struct ReductionVCToFAS { target: MinimumFeedbackArcSet, /// Number of vertices in the source graph (= number of internal arcs). num_source_vertices: usize, + source_edges: Vec<(usize, usize)>, } impl ReductionResult for ReductionVCToFAS { @@ -37,7 +38,17 @@ impl ReductionResult for ReductionVCToFAS { ) -> crate::rules::ExtractionResult<::Solution> { crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - Ok(target_solution[..self.num_source_vertices].to_vec()) + let cover = target_solution[..self.num_source_vertices].to_vec(); + if self + .source_edges + .iter() + .any(|&(u, v)| !cover[u] && !cover[v]) + { + return Err(crate::rules::ExtractionError::invalid( + "target feedback arc set does not encode a source vertex cover", + )); + } + Ok(cover) } } @@ -59,11 +70,11 @@ impl ReduceTo> for MinimumVertexCover, MinimumFeedbackArcSet, - >("summing source vertex weights") + >("summing positive source vertex weights") }) })?; let big_m = weight_sum.checked_add(1).ok_or_else(|| { @@ -96,6 +107,7 @@ impl ReduceTo> for MinimumVertexCover::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .is_valid() + { + return Err(crate::rules::ExtractionError::invalid( + "target edges do not form a Steiner tree", + )); + } Ok({ let n = self.num_source_vertices; @@ -156,18 +164,34 @@ impl ReduceTo> for PrizeCollectingSteinerForest, + >("forming the Steiner gadget inclusion cost") + })?; + let omit_cost = beta + .checked_mul(source_prizes[v]) + .and_then(|penalty| penalty.checked_add(include_cost)) + .ok_or_else(|| { + crate::rules::ReductionError::integer_overflow::< + Self, + SteinerTree, + >("forming the Steiner gadget omission cost") + })?; + // The include edge marks a selected prized vertex. target_edges.push((v, t_v)); - target_edge_weights.push(0); + target_edge_weights.push(include_cost); target_to_source_edge.push(None); target_to_include_vertex.push(Some(v)); - // omit-edge: pays beta * p(v) when v is excluded from V_F. + // The omit edge pays the additional omitted prize. target_edges.push((root, t_v)); - target_edge_weights.push(beta * source_prizes[v]); + target_edge_weights.push(omit_cost); target_to_source_edge.push(None); target_to_include_vertex.push(None); } @@ -202,7 +226,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec> for SteinerTree { let n = self.num_vertices(); let m = self.num_edges(); let (num_vars, num_constraints) = tree_ilp_sizes(n, m, self.terminals().len())?; - // The source constructor requires at least two distinct terminals. + // The source constructor requires at least one terminal. let root = self.terminals()[0]; let edges = self.graph().edges(); let vertex_var = |v: usize| m + v; @@ -132,7 +132,7 @@ impl ReduceTo> for SteinerTree { } } -/// Bounds for all offsets and allocation sizes; n >= 2 is a source invariant. +/// Bounds for all offsets and allocation sizes; n >= 1 is a source invariant. fn tree_ilp_sizes( n: usize, m: usize, diff --git a/src/rules/subsetsum_closestvectorproblem.rs b/src/rules/subsetsum_closestvectorproblem.rs index dd4762df5..0b3c25880 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -3,21 +3,20 @@ use crate::models::algebraic::ClosestVectorProblem; use crate::models::misc::SubsetSum; use crate::reduction; -use crate::registry::ConstructionError; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::types::{Min, Or}; /// Result of reducing SubsetSum to ClosestVectorProblem. #[derive(Debug, Clone)] pub struct ReductionSubsetSumToClosestVectorProblem { - target: ClosestVectorProblem, + target: ClosestVectorProblem, num_elements: usize, - target_distance: f64, + target_squared_distance: i64, } impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { type Source = SubsetSum; - type Target = ClosestVectorProblem; + type Target = ClosestVectorProblem; fn target_problem(&self) -> &Self::Target { &self.target @@ -44,14 +43,14 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { impl crate::rules::AggregateReductionResult for ReductionSubsetSumToClosestVectorProblem { type Source = SubsetSum; - type Target = ClosestVectorProblem; + type Target = ClosestVectorProblem; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Min) -> Or { - Or(target_value == Min(Some(self.target_distance))) + fn extract_value(&self, target_value: Min) -> Or { + Or(target_value == Min(Some(self.target_squared_distance))) } } @@ -62,7 +61,7 @@ impl ReductionSubsetSumToClosestVectorProblem { bit_width: u64, ) -> Result<(usize, usize, usize), crate::rules::ReductionError> { let overflow = || { - crate::rules::ReductionError::integer_overflow::>( + crate::rules::ReductionError::integer_overflow::( "sizing the binary-carry lattice", ) }; @@ -87,7 +86,7 @@ impl ReductionSubsetSumToClosestVectorProblem { num_basis_vectors = "n+b-1 depends on input bit length b, which is not a registered SubsetSum parameter", }, )] -impl ReduceTo> for SubsetSum { +impl ReduceTo for SubsetSum { type Result = ReductionSubsetSumToClosestVectorProblem; fn reduce_to(&self) -> Result { @@ -112,9 +111,8 @@ impl ReduceTo> for SubsetSum { } basis.push(column); } - // Carry c_k occurs with +1 in bit k and -2 in bit k-1. Descending - // bit rows and carry columns preserve unit pivots in the formal rank - // checker, without changing its implementation or bypassing validation. + // Carry c_k occurs with +1 in bit k and -2 in bit k-1. + // Descending bit rows and carry columns give unit pivots. for bit in (1..bits).rev() { let mut column = vec![0_i64; rows]; column[rows - 1 - bit] = 1; @@ -126,22 +124,16 @@ impl ReduceTo> for SubsetSum { for bit in 0..bits { target[rows - 1 - bit] = i64::from(self.target().bit(bit as u64)); } - // The checked dense byte count bounds n below 2^30 on 64-bit systems, - // so the integer threshold and its unit squared-distance gap are exact. - let count = >>::exact_i64( + let target_squared_distance = >::exact_i64( n, - "representing the subset-sum distance threshold", + "representing the subset-sum squared-distance threshold", )?; - let target_distance = crate::types::i64_to_exact_f64(count) - .map_err(ConstructionError::from) - .map_err(>>::target_construction)? - .sqrt(); let target = ClosestVectorProblem::new(basis, target) - .map_err(>>::target_construction)?; + .map_err(>::target_construction)?; Ok(ReductionSubsetSumToClosestVectorProblem { target, num_elements: n, - target_distance, + target_squared_distance, }) } } @@ -153,7 +145,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( + crate::example_db::specs::rule_example_with_witness::<_, ClosestVectorProblem>( SubsetSum::new(vec![3u32, 7, 1, 8], 11u32), SolutionPair { source_config: serde_json::json!(vec![true, false, false, true]), diff --git a/src/solvers/customized/closest_vector_problem.rs b/src/solvers/customized/closest_vector_problem.rs index 7f901e8e9..b04025bd0 100644 --- a/src/solvers/customized/closest_vector_problem.rs +++ b/src/solvers/customized/closest_vector_problem.rs @@ -1,15 +1,14 @@ -//! Exact-rational CVP sphere enumeration in nearest-first (Schnorr--Euchner) order. +//! Exact CVP sphere enumeration in nearest-first (Schnorr--Euchner) order. -use crate::models::algebraic::{ClosestVectorProblem, ClosestVectorTarget}; +use crate::models::algebraic::ClosestVectorProblem; use crate::solvers::SolveError; +use num_bigint::BigInt; use num_rational::BigRational; -use num_traits::{ToPrimitive, Zero}; +use num_traits::{Signed, ToPrimitive, Zero}; type GramSchmidtData = (Vec>, Vec, Vec); -pub(crate) fn solve( - problem: &ClosestVectorProblem, -) -> Result, SolveError> { +pub(crate) fn solve(problem: &ClosestVectorProblem) -> Result, SolveError> { let n = problem.num_basis_vectors(); if n == 0 { return Ok(Vec::new()); @@ -21,28 +20,20 @@ pub(crate) fn solve( .map(|column| { column .iter() - .map(|&entry| { - crate::types::i64_to_exact_f64(entry)?; - Ok(BigRational::from_integer(entry.into())) - }) - .collect::, SolveError>>() + .map(|&entry| BigRational::from_integer(entry.into())) + .collect() }) - .collect::, _>>()?; + .collect::>>(); let target = problem .target() .iter() - .map(|coordinate| { - let value = coordinate.to_f64().map_err(SolveError::Evaluation)?; - BigRational::from_float(value).ok_or_else(|| { - SolveError::NonFiniteResult("converting a CVP target to an exact rational".into()) - }) - }) - .collect::, _>>()?; + .map(|&v| BigRational::from_integer(v.into())) + .collect::>(); let (mu, norms, alpha) = gram_schmidt(&basis, &target); let mut best_squared = (0..n).map(|i| &norms[i] * &alpha[i] * &alpha[i]).sum(); - let mut coefficients = vec![0_i64; n]; + let mut coefficients = vec![BigInt::zero(); n]; let mut best = coefficients.clone(); enumerate( n - 1, @@ -53,8 +44,13 @@ pub(crate) fn solve( &mut coefficients, &mut best, &mut best_squared, - )?; - Ok(best) + ); + best.into_iter() + .map(|v| { + v.to_i64() + .ok_or_else(|| SolveError::IntegerOverflow("returning a CVP coefficient".into())) + }) + .collect() } fn gram_schmidt(basis: &[Vec], target: &[BigRational]) -> GramSchmidtData { @@ -102,33 +98,28 @@ fn enumerate( mu: &[Vec], norms: &[BigRational], alpha: &[BigRational], - coefficients: &mut [i64], - best: &mut Vec, + coefficients: &mut [BigInt], + best: &mut [BigInt], best_squared: &mut BigRational, -) -> Result<(), SolveError> { +) { if partial_squared >= *best_squared { - return Ok(()); + return; } let mut center = alpha[level].clone(); for later in (level + 1)..coefficients.len() { - center -= &mu[later][level] * BigRational::from_integer(coefficients[later].into()); + center -= &mu[later][level] * BigRational::from_integer(coefficients[later].clone()); } - let mut candidate = - center.round().to_integer().to_i64().ok_or_else(|| { - SolveError::IntegerOverflow("rounding a CVP enumeration center".into()) - })?; - crate::types::i64_to_exact_f64(candidate)?; - let nearest = BigRational::from_integer(candidate.into()); - let mut step = if center > nearest { 1_i64 } else { -1 }; + let mut candidate = center.round().to_integer(); + let nearest = BigRational::from_integer(candidate.clone()); + let mut step = BigInt::from(if center > nearest { 1 } else { -1 }); // Visit the nearest integer, then alternate sides in increasing distance. // The first descent tries the nearest-plane candidate; every subsequent // branch uses the improved incumbent rather than a fixed initial interval. loop { - coefficients[level] = candidate; - crate::types::i64_to_exact_f64(candidate)?; - let delta = BigRational::from_integer(candidate.into()) - ¢er; + coefficients[level] = candidate.clone(); + let delta = BigRational::from_integer(candidate.clone()) - ¢er; let next_squared = &partial_squared + &norms[level] * &delta * δ if next_squared >= *best_squared { break; @@ -147,16 +138,14 @@ fn enumerate( coefficients, best, best_squared, - )?; + ); if partial_squared >= *best_squared { break; } // Differences +1,-2,+3,... (or -1,+2,-3,...) alternate around the center. - // Exact f64 coefficient transport keeps these i64 updates below 2^55. - candidate += step; - step = -step - step.signum(); + candidate += &step; + step = -&step - step.signum(); } - Ok(()) } #[cfg(test)] diff --git a/src/solvers/customized/minimum_decision_tree.rs b/src/solvers/customized/minimum_decision_tree.rs index 7422ab1dc..7302195bd 100644 --- a/src/solvers/customized/minimum_decision_tree.rs +++ b/src/solvers/customized/minimum_decision_tree.rs @@ -1,12 +1,23 @@ //! Exact minimum decision tree solver using dynamic programming over object subsets. use crate::models::misc::MinimumDecisionTree; +use crate::solvers::SolveError; -pub(crate) fn solve(problem: &MinimumDecisionTree) -> Option> { +pub(crate) fn solve(problem: &MinimumDecisionTree) -> Result, SolveError> { let n = problem.num_objects(); - let full = (1usize << n) - 1; - let mut costs = vec![usize::MAX; 1usize << n]; - let mut choices = vec![problem.num_tests(); 1usize << n]; + if n >= usize::BITS as usize { + return Err(SolveError::IntegerOverflow( + "indexing object subsets with a usize mask".into(), + )); + } + let states = 1usize << n; + let full = states - 1; + let mut costs = Vec::new(); + costs.try_reserve_exact(states)?; + costs.resize(states, usize::MAX); + let mut choices = Vec::new(); + choices.try_reserve_exact(states)?; + choices.resize(states, problem.num_tests()); for object in 0..n { costs[1 << object] = 0; } @@ -37,9 +48,11 @@ pub(crate) fn solve(problem: &MinimumDecisionTree) -> Option> { } let slots = (1usize << (n - 1)) - 1; - let mut solution = vec![problem.num_tests(); slots]; + let mut solution = Vec::new(); + solution.try_reserve_exact(slots)?; + solution.resize(slots, problem.num_tests()); write_tree(problem, full, 0, &choices, &mut solution); - Some(solution) + Ok(solution) } fn write_tree( diff --git a/src/solvers/customized/shortest_common_superstring.rs b/src/solvers/customized/shortest_common_superstring.rs index bcca60f3b..e3f5a2ea4 100644 --- a/src/solvers/customized/shortest_common_superstring.rs +++ b/src/solvers/customized/shortest_common_superstring.rs @@ -1,8 +1,9 @@ //! Exact shortest common superstring solver using subset dynamic programming. use crate::models::misc::ShortestCommonSuperstring; +use crate::solvers::SolveError; -pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Option>> { +pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Result>, SolveError> { let mut strings = problem.strings().to_vec(); strings.sort(); strings.dedup(); @@ -18,17 +19,31 @@ pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Option>; (1usize << n) * n]; + if n >= usize::BITS as usize { + return Err(SolveError::IntegerOverflow( + "indexing string subsets with a usize mask".into(), + )); + } + let states = 1usize << n; + let cells = states.checked_mul(n).ok_or_else(|| { + SolveError::IntegerOverflow("sizing the superstring dynamic-programming table".into()) + })?; + let mut dp = Vec::>>::new(); + dp.try_reserve_exact(cells)?; + dp.resize(cells, None); for (i, string) in strings.iter().enumerate() { dp[(1 << i) * n + i] = Some(string.clone()); } - for mask in 1usize..(1usize << n) { + for mask in 1usize..states { for last in 0..n { let Some(prefix) = dp[mask * n + last].clone() else { continue; @@ -51,14 +66,14 @@ pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Option>(); + solution.extend(shortest.into_iter().map(Some)); solution.resize(problem.max_length(), None); - Some(solution) + Ok(solution) } fn contains(haystack: &[usize], needle: &[usize]) -> bool { diff --git a/src/solvers/customized/solver.rs b/src/solvers/customized/solver.rs index 020fd82bc..480ff20e9 100644 --- a/src/solvers/customized/solver.rs +++ b/src/solvers/customized/solver.rs @@ -70,12 +70,12 @@ register_customized_solver!( register_customized_solver!(GroupingBySwapping, "symbol-block-order", |problem| Ok( super::grouping_by_swapping::solve(problem) )); -register_customized_solver!(ShortestCommonSuperstring, "subset-dp", |problem| Ok( - super::shortest_common_superstring::solve(problem) -)); -register_customized_solver!(MinimumDecisionTree, "subset-dp", |problem| Ok( - super::minimum_decision_tree::solve(problem) -)); +register_customized_solver!(ShortestCommonSuperstring, "subset-dp", |problem| { + super::shortest_common_superstring::solve(problem).map(Some) +}); +register_customized_solver!(MinimumDecisionTree, "subset-dp", |problem| { + super::minimum_decision_tree::solve(problem).map(Some) +}); register_customized_solver!( MinimumCostCirculation, "negative-cycle-canceling", @@ -93,16 +93,47 @@ register_customized_solver!( ); register_customized_solver!( - crate::models::algebraic::ClosestVectorProblem, + crate::models::algebraic::ClosestVectorProblem, "cvp-sphere-enumeration", |problem| super::closest_vector_problem::solve(problem).map(Some) ); + register_customized_solver!( - crate::models::algebraic::ClosestVectorProblem, - "cvp-sphere-enumeration", - |problem| super::closest_vector_problem::solve(problem).map(Some) + crate::models::graph::KColoring, + "bipartite-coloring", + |problem| Ok(solve_two_coloring(problem)) ); +/// Two-color every connected component in O(vertices + edges) time. +fn solve_two_coloring( + problem: &crate::models::graph::KColoring, +) -> Option> { + use crate::topology::Graph; + + let graph = problem.graph(); + // Colors 0 and 1 are assigned; 2 marks an unvisited vertex. + let mut colors = vec![2; graph.num_vertices()]; + let mut stack = Vec::new(); + for root in 0..colors.len() { + if colors[root] != 2 { + continue; + } + colors[root] = 0; + stack.push(root); + while let Some(u) = stack.pop() { + for v in graph.neighbors(u) { + if colors[v] == 2 { + colors[v] = 1 - colors[u]; + stack.push(v); + } else if colors[v] == colors[u] { + return None; + } + } + } + } + Some(colors) +} + /// Solve MinimumCardinalityKey: find a minimal key with smallest cardinality. /// /// Uses iterative deepening by cardinality to guarantee the first solution diff --git a/src/solvers/decision_search.rs b/src/solvers/decision_search.rs index 7c38fe6ec..eb5cdbcc3 100644 --- a/src/solvers/decision_search.rs +++ b/src/solvers/decision_search.rs @@ -27,17 +27,26 @@ where P::Solution: 'static, { if lower > upper { - return Ok(None); + return Err(crate::solvers::SolveError::InvalidSearchInterval { lower, upper }); } if !is_satisfiable(&Decision::new(problem.clone(), upper))? { + if upper != i64::MAX && is_satisfiable(&Decision::new(problem.clone(), i64::MAX))? { + return Err(crate::solvers::SolveError::OptimumOutsideSearchInterval { lower, upper }); + } return Ok(None); } + if let Some(bound) = lower.checked_sub(1) { + if is_satisfiable(&Decision::new(problem.clone(), bound))? { + return Err(crate::solvers::SolveError::OptimumOutsideSearchInterval { lower, upper }); + } + } let mut lo = lower; let mut hi = upper; while lo < hi { - let mid = lo + (hi - lo) / 2; + let mid = i64::try_from((i128::from(lo) + i128::from(hi)).div_euclid(2)) + .expect("midpoint lies within the i64 interval"); if is_satisfiable(&Decision::new(problem.clone(), mid))? { hi = mid; } else { @@ -58,17 +67,26 @@ where P::Solution: 'static, { if lower > upper { - return Ok(None); + return Err(crate::solvers::SolveError::InvalidSearchInterval { lower, upper }); } if !is_satisfiable(&Decision::new(problem.clone(), lower))? { + if lower != i64::MIN && is_satisfiable(&Decision::new(problem.clone(), i64::MIN))? { + return Err(crate::solvers::SolveError::OptimumOutsideSearchInterval { lower, upper }); + } return Ok(None); } + if let Some(bound) = upper.checked_add(1) { + if is_satisfiable(&Decision::new(problem.clone(), bound))? { + return Err(crate::solvers::SolveError::OptimumOutsideSearchInterval { lower, upper }); + } + } let mut lo = lower; let mut hi = upper; while lo < hi { - let mid = lo + (hi - lo + 1) / 2; + let mid = i64::try_from((i128::from(lo) + i128::from(hi)).div_euclid(2) + 1) + .expect("midpoint lies within the i64 interval"); if is_satisfiable(&Decision::new(problem.clone(), mid))? { lo = mid; } else { @@ -122,6 +140,9 @@ impl DecisionSearchValue for Max { } /// Recover an optimization value by querying the problem's decision wrapper. +/// +/// Uses the brute-force reference solver. Returns `None` only for infeasibility; +/// an invalid interval or an optimum outside `[lower, upper]` is an error. pub fn solve_via_decision

( problem: &P, lower: i64, diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index a0e39a160..9d2d8dde9 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -23,6 +23,12 @@ pub use ilp::{ILPSolveError, ILPSolver}; /// Failure while solving a valid problem instance. #[derive(Debug, thiserror::Error)] pub enum SolveError { + #[error("cannot allocate solver storage: {0}")] + Allocation(#[from] std::collections::TryReserveError), + #[error("invalid decision-search interval [{lower}, {upper}]")] + InvalidSearchInterval { lower: i64, upper: i64 }, + #[error("optimum lies outside decision-search interval [{lower}, {upper}]")] + OptimumOutsideSearchInterval { lower: i64, upper: i64 }, #[error("configuration evaluation failed: {0}")] Evaluation(#[from] crate::traits::EvaluationError), #[error("aggregate combination failed: {0}")] diff --git a/src/unit_tests/graph_models.rs b/src/unit_tests/graph_models.rs index 675a9c487..1467ca792 100644 --- a/src/unit_tests/graph_models.rs +++ b/src/unit_tests/graph_models.rs @@ -12,7 +12,7 @@ use crate::solvers::BruteForceProblem as _; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, Min}; -use crate::variant::{K1, K2, K3, K4}; +use crate::variant::{K2, K3, KN}; // ============================================================================= // Independent Set Tests @@ -600,7 +600,7 @@ mod kcoloring { #[test] fn test_empty_graph() { - let problem = KColoring::::new(SimpleGraph::new(3, vec![])); + let problem = KColoring::::with_k(SimpleGraph::new(3, vec![]), 1); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(&problem).unwrap(); @@ -611,10 +611,10 @@ mod kcoloring { #[test] fn test_complete_graph_k4() { // K4 needs 4 colors - let problem = KColoring::::new(SimpleGraph::new( + let problem = KColoring::::with_k( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), 4, - vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], - )); + ); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(&problem).unwrap(); diff --git a/src/unit_tests/models/algebraic/closest_vector_problem.rs b/src/unit_tests/models/algebraic/closest_vector_problem.rs index c78712962..ec162c905 100644 --- a/src/unit_tests/models/algebraic/closest_vector_problem.rs +++ b/src/unit_tests/models/algebraic/closest_vector_problem.rs @@ -3,34 +3,20 @@ use crate::traits::Problem; use crate::types::Min; #[test] -fn test_cvp_constructs_integer_and_real_targets() { +fn test_cvp_constructs_integer_targets() { let integer = ClosestVectorProblem::new(vec![vec![2, 0, 0], vec![1, 2, 0]], vec![3_i64, 3, 1]).unwrap(); assert_eq!(integer.num_basis_vectors(), 2); assert_eq!(integer.ambient_dimension(), 3); assert_eq!(integer.target(), &[3, 3, 1]); - assert_eq!( - ClosestVectorProblem::::variant(), - vec![("target", "i64")] - ); - - let real = ClosestVectorProblem::new(vec![vec![2, 0, 0], vec![1, 2, 0]], vec![2.5, 1.25, -0.5]) - .unwrap(); - assert_eq!(real.target(), &[2.5, 1.25, -0.5]); - assert_eq!( - ClosestVectorProblem::::variant(), - vec![("target", "f64")] - ); + assert_eq!(ClosestVectorProblem::variant(), vec![("target", "i64")]); } #[test] fn test_cvp_evaluates_without_coefficient_bounds() { let problem = ClosestVectorProblem::new(vec![vec![2, 0, 0], vec![1, 2, 0]], vec![3_i64, 3, 1]).unwrap(); - assert_eq!( - problem.evaluate(&vec![1, 1]).unwrap(), - Min(Some(2.0_f64.sqrt())) - ); + assert_eq!(problem.evaluate(&vec![1, 1]).unwrap(), Min(Some(2))); assert!(problem.evaluate(&vec![11, -12]).unwrap().0.is_some()); assert!(matches!( problem.evaluate(&vec![1]), @@ -48,89 +34,94 @@ fn test_cvp_rejects_invalid_basis() { } #[test] -fn test_cvp_reports_rank_arithmetic_overflow() { - let error = - ClosestVectorProblem::new(vec![vec![i64::MAX, 1], vec![1, i64::MAX]], vec![0_i64, 0]) - .unwrap_err(); - assert!(matches!(error, ConstructionError::IntegerOverflow(_))); -} - -#[test] -fn test_cvp_rejects_non_finite_real_target() { - assert!(matches!( - ClosestVectorProblem::new(vec![vec![1_i64]], vec![f64::NAN]), - Err(ConstructionError::NonFiniteFloat(_)) - )); +fn test_cvp_rank_uses_exact_elimination() { + let m = i64::MAX; + for basis in [ + vec![vec![m, 1], vec![1, m]], + // Large products cancel to determinant -1. + vec![vec![m, m - 1], vec![m - 1, m - 2]], + ] { + let problem = ClosestVectorProblem::new(basis, vec![0_i64, 0]).unwrap(); + assert_eq!(problem.independent_rows().unwrap(), vec![0, 1]); + let json = serde_json::to_string(&problem).unwrap(); + let decoded: ClosestVectorProblem = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.basis(), problem.basis()); + } assert!(matches!( - ClosestVectorProblem::new(vec![vec![1_i64]], vec![f64::INFINITY]), - Err(ConstructionError::NonFiniteFloat(_)) + ClosestVectorProblem::new(vec![vec![m, m], vec![m, m]], vec![0_i64, 0]), + Err(ConstructionError::Conversion(_)) )); } #[test] -fn test_cvp_reports_exact_to_float_boundary() { +fn test_cvp_rank_selects_independent_rows_after_pivoting() { let problem = ClosestVectorProblem::new( - vec![vec![crate::types::MAX_EXACT_F64_INTEGER + 1]], - vec![0_i64], + vec![vec![0, 2, 4, 0], vec![0, 0, 0, 3], vec![0, 0, 5, 0]], + vec![0_i64; 4], ) .unwrap(); - assert!(matches!( - problem.evaluate(&vec![1]), - Err(crate::traits::EvaluationError::InexactFloatConversion(_)) - )); + assert_eq!(problem.independent_rows().unwrap(), vec![1, 3, 2]); +} + +#[test] +fn test_cvp_evaluation_uses_checked_integer_arithmetic() { + let large = crate::types::MAX_EXACT_F64_INTEGER + 1; + let problem = ClosestVectorProblem::new(vec![vec![1]], vec![large]).unwrap(); + assert_eq!(problem.evaluate(&vec![large - 2]).unwrap(), Min(Some(4))); + for (basis, target, solution) in [ + (vec![vec![i64::MAX]], vec![0], vec![2]), + (vec![vec![1]], vec![i64::MIN], vec![0]), + (vec![], vec![3_037_000_500], vec![]), + (vec![], vec![3_037_000_499, 3_037_000_499], vec![]), + (vec![vec![1, 0], vec![1, 1]], vec![0, 0], vec![i64::MAX, 1]), + ] { + let problem = ClosestVectorProblem::new(basis, target).unwrap(); + assert!(matches!( + problem.evaluate(&solution), + Err(EvaluationError::IntegerOverflow(_)) + )); + } } #[test] -fn test_cvp_serialization_round_trips_both_targets() { +fn test_cvp_serialization_round_trip() { let integer = ClosestVectorProblem::new(vec![vec![1_i64]], vec![2_i64]).unwrap(); let json = serde_json::to_string(&integer).unwrap(); assert!(!json.contains("bounds")); - let decoded: ClosestVectorProblem = serde_json::from_str(&json).unwrap(); + let decoded: ClosestVectorProblem = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.basis(), integer.basis()); assert_eq!(decoded.target(), integer.target()); - - let real = ClosestVectorProblem::new(vec![vec![1_i64]], vec![2.5]).unwrap(); - let json = serde_json::to_string(&real).unwrap(); - let decoded: ClosestVectorProblem = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.target(), real.target()); } #[test] fn test_cvp_create_specs_have_no_bounds() { - let integer = ClosestVectorProblem::::try_from(ClosestVectorProblemI64CreateSpec { + let integer = ClosestVectorProblem::try_from(ClosestVectorProblemCreateSpec { basis: vec![vec![1]], target: vec![2], }) .unwrap(); assert_eq!(integer.target(), &[2]); - - let real = ClosestVectorProblem::::try_from(ClosestVectorProblemF64CreateSpec { - basis: vec![vec![1]], - target: vec![2.5], - }) - .unwrap(); - assert_eq!(real.target(), &[2.5]); } #[test] -fn test_cvp_registers_both_target_variants() { +fn test_cvp_registers_only_integer_target_variant() { let mut variants = crate::registry::variant_entries() .into_iter() - .filter(|entry| entry.name == ClosestVectorProblem::::NAME) + .filter(|entry| entry.name == ClosestVectorProblem::NAME) .map(|entry| entry.variant_map()) .collect::>(); variants.sort(); assert_eq!( variants, - vec![ - std::collections::BTreeMap::from([("target".into(), "f64".into())]), - std::collections::BTreeMap::from([("target".into(), "i64".into())]), - ] + vec![std::collections::BTreeMap::from([( + "target".into(), + "i64".into() + )]),] ); } #[test] fn test_cvp_empty_basis_is_valid() { let problem = ClosestVectorProblem::new(Vec::new(), vec![3_i64, 4]).unwrap(); - assert_eq!(problem.evaluate(&Vec::new()).unwrap(), Min(Some(5.0))); + assert_eq!(problem.evaluate(&Vec::new()).unwrap(), Min(Some(25))); } diff --git a/src/unit_tests/models/graph/kcoloring.rs b/src/unit_tests/models/graph/kcoloring.rs index 588c5721e..3b63a6cc3 100644 --- a/src/unit_tests/models/graph/kcoloring.rs +++ b/src/unit_tests/models/graph/kcoloring.rs @@ -1,6 +1,33 @@ use super::*; use crate::solvers::BruteForceProblem as _; +#[test] +fn test_kcoloring_catalog_keeps_runtime_two_and_three_color_variants() { + let mut variants = crate::registry::variant_entries() + .into_iter() + .filter(|entry| entry.name == "KColoring") + .map(|entry| entry.variant_map()["k"].clone()) + .collect::>(); + variants.sort(); + assert_eq!(variants, ["K2", "K3", "KN"]); +} + +#[test] +fn test_kcoloring_runtime_supports_other_color_counts() { + for k in [1, 4, 5] { + let graph = SimpleGraph::new( + k, + (0..k) + .flat_map(|u| (u + 1..k).map(move |v| (u, v))) + .collect(), + ); + let problem = KColoring::::with_k(graph, k); + let solution = BruteForce::new().solve(&problem).unwrap().unwrap(); + assert!(problem.evaluate(&solution).unwrap().0); + assert_eq!(problem.num_colors(), k); + } +} + #[test] fn create_specs_separate_runtime_and_fixed_color_counts() { let runtime = KColoring::::try_from(RuntimeKColoringCreateSpec { @@ -34,7 +61,7 @@ fn fixed_and_runtime_variants_report_num_colors_parameter() { } use crate::solvers::BruteForce; use crate::topology::SimpleGraph; -use crate::variant::{K1, K2, K3, K4}; +use crate::variant::{K2, K3, KN}; include!("../../jl_helpers.rs"); #[test] @@ -134,7 +161,7 @@ fn test_is_valid_coloring_wrong_len() { fn test_empty_graph() { use crate::traits::Problem; - let problem = KColoring::::new(SimpleGraph::new(3, vec![])); + let problem = KColoring::::with_k(SimpleGraph::new(3, vec![]), 1); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(&problem).unwrap(); @@ -150,10 +177,10 @@ fn test_complete_graph_k4() { use crate::traits::Problem; // K4 needs 4 colors - let problem = KColoring::::new(SimpleGraph::new( + let problem = KColoring::::with_k( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), 4, - vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], - )); + ); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(&problem).unwrap(); @@ -269,11 +296,8 @@ fn fixed_color_counts_survive_all_serialization_paths() { assert!(serde_json::from_value::>(data.clone()).is_err()); } } - check::(); check::(); check::(); - check::(); - check::(); } #[test] diff --git a/src/unit_tests/models/graph/monochromatic_triangle.rs b/src/unit_tests/models/graph/monochromatic_triangle.rs index 54f7a56f8..e694b632c 100644 --- a/src/unit_tests/models/graph/monochromatic_triangle.rs +++ b/src/unit_tests/models/graph/monochromatic_triangle.rs @@ -123,3 +123,30 @@ fn test_monochromatic_triangle_serialization() { assert_eq!(deserialized.num_edges(), 6); assert_eq!(deserialized.triangles().len(), 4); } + +#[test] +fn test_monochromatic_triangle_deserialization_rebuilds_derived_triangles() { + // Triangle 0-1-2 with a pendant edge 2-3. + let problem = + MonochromaticTriangle::new(SimpleGraph::new(4, vec![(0, 1), (0, 2), (1, 2), (2, 3)])); + let valid = serde_json::to_value(&problem).unwrap(); + + let mut corrupted = valid.clone(); + corrupted["triangles"] = serde_json::json!([[99, 0, 1]]); + corrupted["edge_list"] = serde_json::json!([]); + let graph_only = serde_json::json!({ "graph": valid["graph"] }); + + for json in [valid.clone(), corrupted, graph_only] { + let restored: MonochromaticTriangle = serde_json::from_value(json).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), valid); + assert_eq!(restored.triangles(), &[[0, 1, 2]]); + assert_eq!( + restored.evaluate(&vec![true, true, true, false]).unwrap(), + crate::types::Or(false) + ); + assert_eq!( + restored.evaluate(&vec![true, false, true, true]).unwrap(), + crate::types::Or(true) + ); + } +} diff --git a/src/unit_tests/models/graph/steiner_tree.rs b/src/unit_tests/models/graph/steiner_tree.rs index 92d4d7a29..261231588 100644 --- a/src/unit_tests/models/graph/steiner_tree.rs +++ b/src/unit_tests/models/graph/steiner_tree.rs @@ -1,4 +1,16 @@ use super::*; + +#[test] +fn test_single_terminal_allows_empty_tree_and_negative_branches() { + let json = serde_json::json!({ + "graph": {"num_vertices": 2, "edges": [[0, 1]]}, + "edge_weights": [-2], "terminals": [0] + }); + let problem: SteinerTree = serde_json::from_value(json).unwrap(); + assert_eq!(problem.evaluate(&vec![false]).unwrap(), Min(Some(0))); + let solution = BruteForce::new().solve(&problem).unwrap().unwrap(); + assert_eq!(problem.evaluate(&solution).unwrap(), Min(Some(-2))); +} use crate::solvers::BruteForceProblem as _; #[test] @@ -176,10 +188,10 @@ fn test_steiner_tree_edge_weights_and_set_weights() { } #[test] -#[should_panic(expected = "at least 2 terminals required")] -fn test_steiner_tree_rejects_single_terminal() { +#[should_panic(expected = "at least one terminal required")] +fn test_steiner_tree_rejects_no_terminals() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let _ = SteinerTree::new(graph, vec![1, 1], vec![0]); + let _ = SteinerTree::new(graph, vec![1, 1], vec![]); } #[test] @@ -198,12 +210,12 @@ fn test_steiner_tree_rejects_wrong_weight_count() { #[test] fn test_steiner_tree_deserialization_rejects_invalid_invariants() { - let one_terminal = serde_json::json!({ + let no_terminals = serde_json::json!({ "graph": {"num_vertices": 2, "edges": [[0, 1]]}, "edge_weights": [1], - "terminals": [0] + "terminals": [] }); - assert!(serde_json::from_value::>(one_terminal).is_err()); + assert!(serde_json::from_value::>(no_terminals).is_err()); let wrong_weights = serde_json::json!({ "graph": {"num_vertices": 2, "edges": [[0, 1]]}, diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index eb909bf25..7d3db1786 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -435,7 +435,7 @@ fn unit_construction_preserves_model_validation() { let graph = json!({"num_vertices":3,"edges":[[0,1],[1,2]]}); for (name, data) in [ ("MaximumCoKPlex", json!({"graph":graph,"k":0})), - ("SteinerTree", json!({"graph":graph,"terminals":[0]})), + ("SteinerTree", json!({"graph":graph,"terminals":[]})), ("SteinerTree", json!({"graph":graph,"terminals":[0,0]})), ("SteinerTree", json!({"graph":graph,"terminals":[0,3]})), ( diff --git a/src/unit_tests/rules/closestvectorproblem_casts.rs b/src/unit_tests/rules/closestvectorproblem_casts.rs deleted file mode 100644 index efd57957b..000000000 --- a/src/unit_tests/rules/closestvectorproblem_casts.rs +++ /dev/null @@ -1,29 +0,0 @@ -use super::*; -use crate::rules::{ReduceTo, ReductionError, ReductionGraph, ReductionResult}; -use crate::types::MAX_EXACT_F64_INTEGER; - -#[test] -fn test_closestvectorproblem_i64_to_f64_closed_loop() { - let source = ClosestVectorProblem::new(vec![vec![2, 0], vec![1, 2]], vec![3_i64, 2]).unwrap(); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - - assert_eq!(reduction.target_problem().basis(), source.basis()); - assert_eq!(reduction.target_problem().target(), &[3.0, 2.0]); - assert_eq!(reduction.extract_solution(&vec![1, 1]).unwrap(), vec![1, 1]); -} - -#[test] -fn test_closestvectorproblem_i64_to_f64_rejects_inexact_target() { - let source = ClosestVectorProblem::new(vec![vec![1]], vec![MAX_EXACT_F64_INTEGER + 1]).unwrap(); - - assert!(matches!( - ReduceTo::>::reduce_to(&source), - Err(ReductionError::InexactFloatConversion { .. }) - )); -} - -#[test] -fn test_closestvectorproblem_numeric_variants_are_connected() { - assert!(ReductionGraph::new() - .has_direct_reduction::, ClosestVectorProblem>()); -} diff --git a/src/unit_tests/rules/closestvectorproblem_qubo.rs b/src/unit_tests/rules/closestvectorproblem_qubo.rs index aa4afb7b3..c26561578 100644 --- a/src/unit_tests/rules/closestvectorproblem_qubo.rs +++ b/src/unit_tests/rules/closestvectorproblem_qubo.rs @@ -2,7 +2,7 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; -fn canonical_cvp() -> ClosestVectorProblem { +fn canonical_cvp() -> ClosestVectorProblem { ClosestVectorProblem::new(vec![vec![2, 0], vec![1, 2]], vec![3_i64, 2]).unwrap() } @@ -59,7 +59,7 @@ fn test_closestvectorproblem_to_qubo_twelve_dimensional_identity() { } let solution = reduction.extract_solution(&bits).unwrap(); assert_eq!(solution, vec![1; size]); - assert_eq!(source.evaluate(&solution).unwrap().0, Some(0.0)); + assert_eq!(source.evaluate(&solution).unwrap().0, Some(0)); } #[test] @@ -73,10 +73,46 @@ fn test_closestvectorproblem_to_qubo_closed_loop() { let source_solution = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(source_solution, vec![1, 1]); - assert_eq!(source.evaluate(&source_solution).unwrap().0, Some(0.0)); + assert_eq!(source.evaluate(&source_solution).unwrap().0, Some(0)); assert_eq!(reduction.target_problem().num_vars(), 11); } +#[test] +fn test_closestvectorproblem_to_qubo_preserves_squared_distance_up_to_constant() { + for (basis, target) in [ + (vec![vec![2, 0]], vec![1, 1]), + (vec![vec![2, 0], vec![1, 2]], vec![1, -1]), + (vec![], vec![3, 4]), + ] { + let source = ClosestVectorProblem::new(basis, target).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let qubo = reduction.target_problem(); + let zero = vec![false; qubo.num_vars()]; + let constant = source + .evaluate(&reduction.extract_solution(&zero).unwrap()) + .unwrap() + .unwrap(); + for mask in 0..1usize << qubo.num_vars() { + let bits = (0..qubo.num_vars()).map(|i| mask & (1 << i) != 0).collect(); + let witness = reduction.extract_solution(&bits).unwrap(); + assert_eq!( + source.evaluate(&witness).unwrap().unwrap(), + qubo.evaluate(&bits).unwrap().unwrap() + constant + ); + } + let optimum = BruteForce::new().solve(qubo).unwrap().unwrap(); + let witness = reduction.extract_solution(&optimum).unwrap(); + let direct = crate::solvers::customized::closest_vector_problem::solve(&source).unwrap(); + assert_eq!( + source.evaluate(&witness).unwrap(), + source.evaluate(&direct).unwrap() + ); + assert!(reduction + .extract_solution(&vec![false; qubo.num_vars() + 1]) + .is_err()); + } +} + #[test] fn test_closestvectorproblem_to_qubo_coefficients() { let reduction = ReduceTo::>::reduce_to(&canonical_cvp()).unwrap(); diff --git a/src/unit_tests/rules/coloring_ilp.rs b/src/unit_tests/rules/coloring_ilp.rs index 238d2cbaf..045fd3b51 100644 --- a/src/unit_tests/rules/coloring_ilp.rs +++ b/src/unit_tests/rules/coloring_ilp.rs @@ -1,7 +1,7 @@ use super::*; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; -use crate::variant::{K1, K2, K3, K4, KN}; +use crate::variant::{K2, K3, KN}; #[test] fn test_reduction_creates_valid_ilp() { @@ -47,7 +47,7 @@ fn test_reduction_path_graph() { #[test] fn runtime_color_count_controls_exact_ilp_parameters() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - for colors in [2, 3, 5] { + for colors in [1, 2, 3, 4, 5] { let problem = KColoring::::with_k(graph.clone(), colors); let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); let target = reduction.target_problem(); @@ -167,7 +167,7 @@ fn test_ilp_structure() { #[test] fn test_empty_graph() { // Graph with no edges: any coloring is valid - let problem = KColoring::::new(SimpleGraph::new(3, vec![])); + let problem = KColoring::::with_k(SimpleGraph::new(3, vec![]), 1); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); @@ -184,10 +184,10 @@ fn test_empty_graph() { #[test] fn test_complete_graph_k4() { // K4 needs 4 colors - let problem = KColoring::::new(SimpleGraph::new( + let problem = KColoring::::with_k( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), 4, - vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], - )); + ); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); @@ -256,7 +256,7 @@ fn test_reduction_closed_loop() { #[test] fn test_single_vertex() { // Single vertex graph: always 1-colorable - let problem = KColoring::::new(SimpleGraph::new(1, vec![])); + let problem = KColoring::::with_k(SimpleGraph::new(1, vec![]), 1); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs index ef8a2d1fb..876c5e16c 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -5,12 +5,54 @@ use crate::models::graph::{MinimumFeedbackArcSet, MinimumVertexCover}; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; -#[cfg(feature = "example-db")] use crate::solvers::BruteForce; use crate::topology::{Graph, SimpleGraph}; -#[cfg(feature = "example-db")] use crate::traits::Problem; +#[test] +fn test_signed_weights_preserve_all_target_optima() { + for weights in [vec![-10, 1, 1], vec![-3, -2, -1], vec![0, 0, 0]] { + let source = MinimumVertexCover::new(SimpleGraph::new(3, vec![(1, 2)]), weights); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let expected = source + .evaluate(&BruteForce::new().solve(&source).unwrap().unwrap()) + .unwrap(); + for mask in 0..32 { + let config: Vec = (0..5).map(|bit| mask & (1 << bit) != 0).collect(); + if reduction.target_problem().evaluate(&config).unwrap() == expected { + let recovered = reduction.extract_solution(&config).unwrap(); + assert_eq!(source.evaluate(&recovered).unwrap(), expected); + } + } + let optimum = BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .unwrap(); + assert_eq!( + reduction.target_problem().evaluate(&optimum).unwrap(), + expected + ); + } +} + +#[test] +fn test_extraction_rejects_uncovered_edges_and_penalty_overflow() { + let source = MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1_i64; 2]); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + assert!(reduction + .target_problem() + .evaluate(&vec![false, false, true, true]) + .unwrap() + .is_valid()); + assert!(reduction + .extract_solution(&vec![false, false, true, true]) + .is_err()); + for weights in [vec![i64::MAX, 0], vec![i64::MAX, 1]] { + let source = MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), weights); + assert!(ReduceTo::>::reduce_to(&source).is_err()); + } +} + fn triangle_source() -> MinimumVertexCover { // Triangle: 0-1-2-0, unit weights; MVC = 2 MinimumVertexCover::new( diff --git a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs index e544144d0..eb1cf4e5a 100644 --- a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs @@ -25,7 +25,97 @@ use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; -/// Canonical issue-#1027 instance: path 0 - 1 - 2 with c=(10,10), p=(5,1,5), +#[test] +fn test_low_prizes_do_not_bypass_component_costs() { + for (prizes, beta, omega, expected) in [ + (vec![1, 2], 1, 5, 3), + (vec![1, 2], 0, 5, 0), + (vec![1, 2], 1, 0, 0), + (vec![0, 2], 1, 5, 2), + ] { + let source = + PrizeCollectingSteinerForest::new(SimpleGraph::path(2), prizes, vec![0], beta, omega) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let offset = (omega + 1) * source.num_vertices_with_prize() as i64; + let trees = BruteForce::new() + .find_all_witnesses(reduction.target_problem()) + .unwrap(); + assert!(!trees.is_empty()); + for tree in trees { + assert_eq!( + reduction.target_problem().evaluate(&tree).unwrap(), + Min(Some(expected + offset)) + ); + let forest = reduction.extract_solution(&tree).unwrap(); + assert_eq!(source.evaluate(&forest).unwrap(), Min(Some(expected))); + } + assert!(reduction + .extract_solution(&vec![false; reduction.target_problem().num_edges()]) + .is_err()); + } +} + +#[test] +fn test_gadget_cost_overflow_is_reported() { + for (prize, beta, omega) in [(1, 1, i64::MAX), (i64::MAX, 2, 0), (i64::MAX, 1, 0)] { + let source = PrizeCollectingSteinerForest::new( + SimpleGraph::empty(1), + vec![prize], + vec![], + beta, + omega, + ) + .unwrap(); + assert!(matches!( + ReduceTo::>::reduce_to(&source), + Err(crate::rules::ReductionError::IntegerOverflow { .. }) + )); + } +} + +#[test] +fn test_zero_prize_forests_preserve_empty_optimum() { + for n in [0, 1, 2] { + let source = PrizeCollectingSteinerForest::new( + SimpleGraph::empty(n), + vec![0; n], + vec![], + 1, + i64::MAX, + ) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let tree = vec![false; n]; + assert_eq!( + reduction.target_problem().evaluate(&tree).unwrap(), + Min(Some(0)) + ); + let forest = reduction.extract_solution(&tree).unwrap(); + assert_eq!(source.evaluate(&forest).unwrap(), Min(Some(0))); + } +} + +#[test] +fn test_zero_prize_forest_through_steiner_tree_ilp() { + for n in [0, 2] { + let source = + PrizeCollectingSteinerForest::new(SimpleGraph::empty(n), vec![0; n], vec![], 1, 1) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let ilp = + ReduceTo::>::reduce_to(reduction.target_problem()) + .unwrap(); + let solution = crate::solvers::ILPSolver::new() + .solve(ilp.target_problem()) + .unwrap(); + let tree = ilp.extract_solution(&solution).unwrap(); + let forest = reduction.extract_solution(&tree).unwrap(); + assert_eq!(source.evaluate(&forest).unwrap(), Min(Some(0))); + } +} + +/// Canonical instance: path 0 - 1 - 2 with c=(10,10), p=(5,1,5), /// beta = 1, omega = 1. The PCSF optimum drops vertex 1 because paying /// `beta * p(1) = 1` is cheaper than paying any incident edge (cost 10). fn canonical_problem() -> PrizeCollectingSteinerForest { @@ -53,14 +143,14 @@ fn test_prizecollectingsteinerforest_to_steinertree_canonical_closed_loop() { "PCSF -> SteinerTree canonical closed loop", ); - // Numeric sanity: both optima must agree, and equal 3 on this instance. + // Each of the three gadget terminals contributes an offset of omega + 1 = 2. let target = reduction.target_problem(); let source_opt_solution = BruteForce::new().solve(&source).unwrap().unwrap(); let source_opt = source.evaluate(&source_opt_solution).unwrap(); let target_opt_solution = BruteForce::new().solve(target).unwrap().unwrap(); let target_opt = target.evaluate(&target_opt_solution).unwrap(); assert_eq!(source_opt, Min(Some(3))); - assert_eq!(target_opt, Min(Some(3))); + assert_eq!(target_opt, Min(Some(9))); } #[test] @@ -148,17 +238,9 @@ fn test_prizecollectingsteinerforest_to_steinertree_all_prizes() { let target_opt_solution = BruteForce::new().solve(target).unwrap().unwrap(); let target_opt = target.evaluate(&target_opt_solution).unwrap(); assert_eq!(source_opt, Min(Some(3))); - assert_eq!(target_opt, Min(Some(3))); + assert_eq!(target_opt, Min(Some(9))); } -/// No vertex carries a positive prize, so no gadget terminals are added. -/// Only the artificial root remains as a terminal, but SteinerTree requires -/// at least two terminals — so this corner case is covered by size-contract -/// inspection plus a degenerate single-vertex source case that still has -/// the construction proceed when `omega = 0`. We skip the SteinerTree -/// instantiation when `k = 0` (which would produce a single-terminal -/// SteinerTree); the closed-loop check uses a near-empty case where one -/// vertex has prize 0 and one has a positive prize. #[test] fn test_prizecollectingsteinerforest_to_steinertree_mixed_zero_prize() { // Two-vertex path with one prize-zero vertex. diff --git a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs index f6f4b2f9a..4e08c58c5 100644 --- a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs +++ b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs @@ -5,7 +5,7 @@ use crate::traits::Problem; #[test] fn test_subsetsum_to_closestvectorproblem_closed_loop() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); let target_solution = crate::solvers::customized::closest_vector_problem::solve(reduction.target_problem()) .unwrap(); @@ -18,32 +18,29 @@ fn test_subsetsum_to_closestvectorproblem_closed_loop() { .evaluate(&target_solution) .unwrap() .0, - Some(2.0) + Some(4) ); } #[test] fn test_subsetsum_to_closestvectorproblem_structure() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); let target = reduction.target_problem(); let expected: serde_json::Value = serde_json::json!({"basis": [[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1], [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1], [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2]], "target": [0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1]}); assert_eq!(serde_json::to_value(target).unwrap(), expected); - assert_eq!( - ClosestVectorProblem::::variant(), - vec![("target", "i64")] - ); + assert_eq!(ClosestVectorProblem::variant(), vec![("target", "i64")]); } #[test] fn test_subsetsum_to_closestvectorproblem_binary_minimizers() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); let target = reduction.target_problem(); for solution in [vec![1, 0, 0, 1, 0, 0, 0], vec![1, 1, 1, 0, 1, 1, 1]] { - assert_eq!(target.evaluate(&solution).unwrap().0, Some(2.0)); + assert_eq!(target.evaluate(&solution).unwrap().0, Some(4)); assert!( source .evaluate(&reduction.extract_solution(&solution).unwrap()) @@ -56,7 +53,7 @@ fn test_subsetsum_to_closestvectorproblem_binary_minimizers() { #[test] fn test_subsetsum_to_closestvectorproblem_unsatisfiable_instance() { let source = SubsetSum::new(vec![2u32, 4, 6], 5u32); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); let solution = crate::solvers::customized::closest_vector_problem::solve(reduction.target_problem()) .unwrap(); @@ -66,7 +63,7 @@ fn test_subsetsum_to_closestvectorproblem_unsatisfiable_instance() { .evaluate(&solution) .unwrap() .unwrap() - > (source.num_elements() as f64).sqrt() + > i64::try_from(source.num_elements()).unwrap() ); } @@ -75,12 +72,12 @@ fn test_subsetsum_to_closestvectorproblem_large_integers_and_unit_pivots() { use num_bigint::BigUint; let size = BigUint::from(1u32) << 70usize; let source = SubsetSum::new(vec![size.clone()], size); - let result = ReduceTo::>::reduce_to(&source).unwrap(); + let result = ReduceTo::::reduce_to(&source).unwrap(); let mut witness = vec![0; result.target_problem().num_basis_vectors()]; witness[0] = 1; assert_eq!( result.target_problem().evaluate(&witness).unwrap(), - Min(Some(1.0)) + Min(Some(1)) ); assert_eq!(result.extract_solution(&witness).unwrap(), vec![true]); assert!(result @@ -91,7 +88,7 @@ fn test_subsetsum_to_closestvectorproblem_large_integers_and_unit_pivots() { .all(|&x| (-2..=1).contains(&x))); let source = SubsetSum::new(vec![1u32; 40], 20u32); - let result = ReduceTo::>::reduce_to(&source).unwrap(); + let result = ReduceTo::::reduce_to(&source).unwrap(); let mut witness = vec![0; result.target_problem().num_basis_vectors()]; witness[..20].fill(1); witness[40..].copy_from_slice(&[1, 2, 5, 10]); @@ -114,7 +111,7 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { (vec![2, 4], 5), ] { let source = SubsetSum::new(sizes, target_sum); - let result = ReduceTo::>::reduce_to(&source).unwrap(); + let result = ReduceTo::::reduce_to(&source).unwrap(); let target = result.target_problem(); assert!(std::ptr::eq( target, @@ -132,7 +129,7 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { }) .collect(); let value = target.evaluate(&config).unwrap(); - let certificate = value == Min(Some(result.target_distance)); + let certificate = value == Min(Some(result.target_squared_distance)); assert_eq!( crate::rules::AggregateReductionResult::extract_value(&result, value), Or(certificate) diff --git a/src/unit_tests/solvers/customized/closest_vector_problem.rs b/src/unit_tests/solvers/customized/closest_vector_problem.rs index fe570f3f2..38b1668b3 100644 --- a/src/unit_tests/solvers/customized/closest_vector_problem.rs +++ b/src/unit_tests/solvers/customized/closest_vector_problem.rs @@ -5,12 +5,9 @@ use crate::traits::Problem; use std::collections::BTreeMap; #[test] -fn test_cvp_solver_handles_integer_and_real_targets() { +fn test_cvp_solver_handles_integer_targets() { let integer = ClosestVectorProblem::new(vec![vec![1]], vec![12_i64]).unwrap(); assert_eq!(solve(&integer).unwrap(), vec![12]); - - let real = ClosestVectorProblem::new(vec![vec![1]], vec![0.6]).unwrap(); - assert_eq!(solve(&real).unwrap(), vec![1]); } #[test] @@ -23,7 +20,7 @@ fn test_cvp_solver_handles_nonorthogonal_rectangular_and_negative_coefficients() #[test] fn test_cvp_solver_keeps_zero_on_tie_and_handles_empty_basis() { - let tied = ClosestVectorProblem::new(vec![vec![1]], vec![0.5]).unwrap(); + let tied = ClosestVectorProblem::new(vec![vec![2]], vec![1]).unwrap(); assert_eq!(solve(&tied).unwrap(), vec![0]); let empty = ClosestVectorProblem::new(Vec::new(), vec![1_i64, 2]).unwrap(); @@ -31,37 +28,29 @@ fn test_cvp_solver_keeps_zero_on_tie_and_handles_empty_basis() { } #[test] -fn test_cvp_solver_reports_inexact_integer_conversion() { - let problem = ClosestVectorProblem::new( - vec![vec![crate::types::MAX_EXACT_F64_INTEGER + 1]], - vec![0_i64], - ) - .unwrap(); - assert!(matches!( - solve(&problem), - Err(crate::solvers::SolveError::InexactFloatConversion(_)) - )); +fn test_cvp_solver_handles_full_integer_range() { + for target in [i64::MIN, i64::MAX] { + let problem = ClosestVectorProblem::new(vec![vec![1]], vec![target]).unwrap(); + assert_eq!(solve(&problem).unwrap(), vec![target]); + assert_eq!(problem.evaluate(&vec![target]).unwrap().0, Some(0)); + } + let problem = ClosestVectorProblem::new(vec![vec![i64::MAX]], vec![i64::MAX]).unwrap(); + assert_eq!(solve(&problem).unwrap(), vec![1]); +} - let out_of_range = ClosestVectorProblem::new(vec![vec![1]], vec![1e20]).unwrap(); +#[test] +fn test_cvp_solver_reports_unrepresentable_coefficient() { + let problem = ClosestVectorProblem::new(vec![vec![-1]], vec![i64::MIN]).unwrap(); assert!(matches!( - solve(&out_of_range), + solve(&problem), Err(SolveError::IntegerOverflow(_)) )); - let inexact = ClosestVectorProblem::new( - vec![vec![1]], - vec![crate::types::MAX_EXACT_F64_INTEGER as f64 + 2.0], - ) - .unwrap(); - assert!(matches!( - solve(&inexact), - Err(SolveError::InexactFloatConversion(_)) - )); } #[test] fn test_cvp_solver_is_registered_without_brute_force() { let key = ExactProblemKey::new( - ClosestVectorProblem::::NAME, + ClosestVectorProblem::NAME, BTreeMap::from([("target".to_string(), "i64".to_string())]), ); let capabilities = solver_capabilities(&key).unwrap(); @@ -82,38 +71,37 @@ fn test_cvp_solver_handles_large_translated_targets() { ClosestVectorProblem::new(vec![vec![2, 0], vec![1, 2]], vec![3 * target, 2 * target]) .unwrap(); assert_eq!(solve(&rectangular).unwrap(), vec![target, target]); - - let fractional = - ClosestVectorProblem::new(vec![vec![2, 0]], vec![2.0 * target as f64 + 0.6, 3.0]) - .unwrap(); - assert_eq!(solve(&fractional).unwrap(), vec![target]); } } #[test] fn test_cvp_nearest_first_matches_exhaustive_small_lattices() { // For these triangular bases, the zero witness bounds the projected optimal distance - // by sqrt(8). Thus |y coefficient| <= 4 and |x coefficient| <= 12. + // by sqrt(32). Thus |y coefficient| <= 4 and |x coefficient| <= 12. for diagonal in 1..=3_i64 { for skew in -2..=2_i64 { for tx in -4..=4 { for ty in -4..=4 { let problem = ClosestVectorProblem::new( - vec![vec![diagonal, 0, 0], vec![skew, 1, 0]], - vec![tx as f64 / 2.0, ty as f64 / 2.0, 1.0], + vec![vec![2 * diagonal, 0, 0], vec![2 * skew, 2, 0]], + vec![tx, ty, 1], ) .unwrap(); let actual = solve(&problem).unwrap(); let distance = |x: i64, y: i64| { - let dx = (diagonal * x + skew * y) as f64 - tx as f64 / 2.0; - let dy = y as f64 - ty as f64 / 2.0; - dx * dx + dy * dy + 1.0 + let dx = 2 * (diagonal * x + skew * y) - tx; + let dy = 2 * y - ty; + dx * dx + dy * dy + 1 }; let expected = (-12..=12) .flat_map(|x| (-4..=4).map(move |y| distance(x, y))) - .fold(f64::INFINITY, f64::min); - assert!((distance(actual[0], actual[1]) - expected).abs() < 1e-9, - "diagonal={diagonal}, skew={skew}, target=({tx}/2,{ty}/2), solution={actual:?}"); + .min() + .unwrap(); + assert_eq!( + distance(actual[0], actual[1]), + expected, + "diagonal={diagonal}, skew={skew}, target=({tx},{ty}), solution={actual:?}" + ); } } } @@ -122,7 +110,8 @@ fn test_cvp_nearest_first_matches_exhaustive_small_lattices() { #[test] fn test_cvp_enumeration_improves_the_nearest_plane_candidate() { - let problem = ClosestVectorProblem::new(vec![vec![2, 0], vec![1, 1]], vec![0.9, 0.49]).unwrap(); + let problem = + ClosestVectorProblem::new(vec![vec![200, 0], vec![100, 100]], vec![90, 49]).unwrap(); // Nearest-plane rounding yields [0, 0]; the adjacent branch is closer. assert_eq!(solve(&problem).unwrap(), vec![0, 1]); } @@ -132,16 +121,10 @@ fn test_cvp_pruning_preserves_exact_large_translation_optimum() { for coefficient in [-100_000_000_000_000_i64, 100_000_000_000_000] { let basis = vec![vec![3, 1], vec![2, 1]]; let target = vec![5 * coefficient, 2 * coefficient]; - let integer = ClosestVectorProblem::new(basis.clone(), target.clone()).unwrap(); - let real = ClosestVectorProblem::new( - basis, - target.into_iter().map(|value| value as f64).collect(), - ) - .unwrap(); + let integer = ClosestVectorProblem::new(basis, target).unwrap(); let expected = vec![coefficient, coefficient]; assert_eq!(solve(&integer).unwrap(), expected); - assert_eq!(solve(&real).unwrap(), expected); - assert_eq!(integer.evaluate(&expected).unwrap().0, Some(0.0)); + assert_eq!(integer.evaluate(&expected).unwrap().0, Some(0)); } } diff --git a/src/unit_tests/solvers/customized/minimum_decision_tree.rs b/src/unit_tests/solvers/customized/minimum_decision_tree.rs index 6905c543e..4dc5bcf36 100644 --- a/src/unit_tests/solvers/customized/minimum_decision_tree.rs +++ b/src/unit_tests/solvers/customized/minimum_decision_tree.rs @@ -2,6 +2,23 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_subset_dp_rejects_unrepresentable_state_space() { + for n in [usize::BITS as usize, usize::BITS as usize - 1] { + let tests = (n.ilog2() + 1) as usize; + let matrix = (0..tests) + .map(|bit| (0..n).map(|object| object & (1 << bit) != 0).collect()) + .collect(); + let problem = MinimumDecisionTree::new(matrix, n, tests); + let error = solve(&problem).unwrap_err(); + if n == usize::BITS as usize { + assert!(matches!(error, SolveError::IntegerOverflow(_))); + } else { + assert!(matches!(error, SolveError::Allocation(_))); + } + } +} + #[test] fn test_subset_dp_minimum_decision_tree_matches_brute_force() { let rows = [ diff --git a/src/unit_tests/solvers/customized/shortest_common_superstring.rs b/src/unit_tests/solvers/customized/shortest_common_superstring.rs index a331c02f7..03daac7ed 100644 --- a/src/unit_tests/solvers/customized/shortest_common_superstring.rs +++ b/src/unit_tests/solvers/customized/shortest_common_superstring.rs @@ -2,6 +2,24 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +#[test] +fn test_subset_dp_rejects_unrepresentable_state_space() { + for count in [ + usize::BITS as usize, + usize::BITS as usize - 1, + usize::BITS as usize - 6, + ] { + let problem = + ShortestCommonSuperstring::new(count, (0..count).map(|symbol| vec![symbol]).collect()); + let error = solve(&problem).unwrap_err(); + if count >= usize::BITS as usize - 1 { + assert!(matches!(error, SolveError::IntegerOverflow(_))); + } else { + assert!(matches!(error, SolveError::Allocation(_))); + } + } +} + #[test] fn test_subset_dp_shortest_common_superstring_matches_brute_force() { let candidates = [vec![], vec![0], vec![1], vec![0, 0], vec![0, 1], vec![1, 0]]; diff --git a/src/unit_tests/solvers/customized/solver.rs b/src/unit_tests/solvers/customized/solver.rs index 85294c3e6..7d9ebabde 100644 --- a/src/unit_tests/solvers/customized/solver.rs +++ b/src/unit_tests/solvers/customized/solver.rs @@ -53,6 +53,46 @@ fn all_simple_graphs(num_vertices: usize) -> impl Iterator { }) } +#[test] +fn test_customized_two_coloring_matches_brute_force() { + use crate::models::graph::KColoring; + use crate::variant::K2; + + for n in 0..=5 { + for graph in all_simple_graphs(n) { + let problem = KColoring::::new(graph); + let actual = CustomizedTestSolver::new().solve_dyn(&problem); + let expected = crate::solvers::BruteForce::new().solve(&problem).unwrap(); + assert_eq!(actual.is_some(), expected.is_some()); + if let Some(solution) = actual { + assert!(problem.evaluate(&solution).unwrap().0); + } + } + } +} + +#[test] +fn test_customized_two_coloring_handles_loops_parallel_edges_and_long_paths() { + use crate::models::graph::KColoring; + use crate::variant::K2; + + for (graph, feasible) in [ + (SimpleGraph::new(2, vec![(0, 1), (0, 1)]), true), + (SimpleGraph::new(3, vec![(2, 2)]), false), + ( + SimpleGraph::new(10_000, (0..9_999).map(|u| (u, u + 1)).collect()), + true, + ), + ] { + let problem = KColoring::::new(graph); + let solution = CustomizedTestSolver::new().solve_dyn(&problem); + assert_eq!(solution.is_some(), feasible); + if let Some(solution) = solution { + assert!(problem.evaluate(&solution).unwrap().0); + } + } +} + fn exact_partial_feedback_edge_set_feasible( graph: &SimpleGraph, budget: usize, diff --git a/src/unit_tests/solvers/decision_search.rs b/src/unit_tests/solvers/decision_search.rs index 1a56f00de..5c6a02f75 100644 --- a/src/unit_tests/solvers/decision_search.rs +++ b/src/unit_tests/solvers/decision_search.rs @@ -1,78 +1,137 @@ use super::*; use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; -use crate::solvers::BruteForce; +use crate::solvers::SolveError; use crate::topology::SimpleGraph; -use crate::types::{Max, Min}; -#[test] -fn test_decision_search_min() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MinimumVertexCover::new(graph, vec![1i64; 3]); - - assert_eq!(solve_via_decision(&problem, 0, 3).unwrap(), Some(1)); +#[derive(Clone, serde::Serialize, serde::Deserialize)] +struct FixedObjective(V); + +impl Problem for FixedObjective { + const NAME: &'static str = "FixedObjective"; + type Solution = Vec; + type Value = V; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 0)]) + } + fn variant() -> Vec<(&'static str, &'static str)> { + vec![( + "objective", + std::any::type_name::().rsplit("::").next().unwrap(), + )] + } + fn evaluate(&self, _: &Self::Solution) -> Result { + Ok(self.0.clone()) + } } -#[test] -fn test_decision_search_max() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MaximumIndependentSet::new(graph, vec![1i64; 3]); - - assert_eq!(solve_via_decision(&problem, 0, 3).unwrap(), Some(2)); +impl DecisionProblemMeta for FixedObjective { + const DECISION_NAME: &'static str = "DecisionFixedObjective"; } -#[test] -fn test_decision_search_matches_brute_force() { - let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); - let problem = MinimumVertexCover::new(graph, vec![1i64; 5]); - - let solution = BruteForce::new().solve(&problem).unwrap().unwrap(); - let brute_force_value = problem.evaluate(&solution).unwrap(); +impl crate::solvers::BruteForceProblem for FixedObjective { + fn dimensions(&self) -> Vec { + vec![] + } +} - assert_eq!( - solve_via_decision(&problem, 0, 5).unwrap(), - brute_force_value.size().copied() - ); +crate::register_brute_force! { + Decision>>, + Decision>>, } -#[test] -fn test_decision_search_min_returns_none_when_upper_bound_is_too_small() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MinimumVertexCover::new(graph, vec![1i64; 3]); +crate::declare_variants! { + default Decision>> => "1", + Decision>> => "1", +} - assert_eq!(solve_via_decision(&problem, 0, 0).unwrap(), None); +inventory::submit! { + crate::registry::ProblemSchemaEntry { + name: "DecisionFixedObjective", + display_name: "Fixed Objective Decision Test Problem", + aliases: &[], + dimensions: &[crate::registry::VariantDimension::new( + "objective", + "Min", + &["Min", "Max"], + )], + category: crate::registry::ProblemCategory::Algebraic, + module_path: module_path!(), + description: "Fixed objective for decision-search boundary tests", + fields: &[], + } } #[test] -fn test_decision_search_max_returns_none_when_interval_is_above_optimum() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MaximumIndependentSet::new(graph, vec![1i64; 3]); - - assert_eq!(solve_via_decision(&problem, 3, 4).unwrap(), None); +fn test_decision_search_matches_optimum_at_integer_boundaries() { + for weight in [i64::MIN, i64::MIN + 1, -3, -1, 0, 1, i64::MAX - 1, i64::MAX] { + let min = MinimumVertexCover::new(SimpleGraph::new(1, vec![(0, 0)]), vec![weight]); + let max = MaximumIndependentSet::new(SimpleGraph::empty(1), vec![weight]); + let maximum = weight.max(0); + for (lower, upper) in [(i64::MIN, i64::MAX), (weight, weight)] { + assert_eq!( + solve_via_decision(&min, lower, upper).unwrap(), + Some(weight) + ); + } + for (lower, upper) in [(i64::MIN, i64::MAX), (maximum, maximum)] { + assert_eq!( + solve_via_decision(&max, lower, upper).unwrap(), + Some(maximum) + ); + } + } } #[test] -fn test_decision_search_invalid_interval_returns_none() { +fn test_decision_search_rejects_invalid_or_excluding_intervals() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let min_problem = MinimumVertexCover::new(graph.clone(), vec![1i64; 3]); - let max_problem = MaximumIndependentSet::new(graph, vec![1i64; 3]); - - assert_eq!(solve_via_decision(&min_problem, 2, 1).unwrap(), None); - assert_eq!(solve_via_decision(&max_problem, 2, 1).unwrap(), None); + let min = MinimumVertexCover::new(graph.clone(), vec![1_i64; 3]); + let max = MaximumIndependentSet::new(graph, vec![1_i64; 3]); + assert_eq!(solve_via_decision(&min, 0, 3).unwrap(), Some(1)); + assert_eq!(solve_via_decision(&max, 0, 3).unwrap(), Some(2)); + for result in [ + solve_via_decision(&min, 0, 0), + solve_via_decision(&min, 2, 3), + solve_via_decision(&max, 0, 1), + solve_via_decision(&max, 3, 4), + ] { + assert!(matches!( + result, + Err(SolveError::OptimumOutsideSearchInterval { .. }) + )); + } + for result in [ + solve_via_decision(&min, 2, 1), + solve_via_decision(&max, 2, 1), + ] { + assert!(matches!( + result, + Err(SolveError::InvalidSearchInterval { lower: 2, upper: 1 }) + )); + } } #[test] -fn test_decision_search_preserves_value_direction() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let min_problem = MinimumVertexCover::new(graph.clone(), vec![1i64; 3]); - let max_problem = MaximumIndependentSet::new(graph, vec![1i64; 3]); - - let min_solution = BruteForce::new().solve(&min_problem).unwrap().unwrap(); - let max_solution = BruteForce::new().solve(&max_problem).unwrap().unwrap(); - let min_value = min_problem.evaluate(&min_solution).unwrap(); - let max_value = max_problem.evaluate(&max_solution).unwrap(); - - assert_eq!(min_value, Min(Some(1))); - assert_eq!(max_value, Max(Some(2))); - assert_eq!(solve_via_decision(&min_problem, 0, 3).unwrap(), Some(1)); - assert_eq!(solve_via_decision(&max_problem, 0, 3).unwrap(), Some(2)); +fn test_decision_search_infeasibility_and_evaluation_failure() { + for (lower, upper) in [(0, 3), (i64::MIN, i64::MAX)] { + assert_eq!( + solve_via_decision(&FixedObjective(Min(None)), lower, upper).unwrap(), + None + ); + assert_eq!( + solve_via_decision(&FixedObjective(Max(None)), lower, upper).unwrap(), + None + ); + } + let min = MinimumVertexCover::new(SimpleGraph::empty(2), vec![i64::MAX; 2]); + let max = MaximumIndependentSet::new(SimpleGraph::empty(2), vec![i64::MIN; 2]); + for result in [ + solve_via_decision(&min, -3, -1), + solve_via_decision(&max, 1, 3), + ] { + assert!(matches!(result, Err(SolveError::Evaluation(_)))); + } } From e3ef9cc7f93d42698571bfc28496e6c8d531a901 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 19 Sep 2026 13:52:50 +0800 Subject: [PATCH 06/44] Fix model validation, numeric bounds, and reduction contracts --- docs/paper/reductions.typ | 16 ++-- problemreductions-macros/src/lib.rs | 17 ++++ src/models/algebraic/minimum_matrix_cover.rs | 5 +- .../algebraic/simultaneous_incongruences.rs | 2 +- .../graph/prize_collecting_steiner_forest.rs | 10 ++ src/models/graph/steiner_tree.rs | 2 +- ...onsistency_of_database_frequency_tables.rs | 28 ++++++ src/models/misc/kth_largest_m_tuple.rs | 13 ++- src/models/misc/maximum_likelihood_ranking.rs | 61 ++++++++---- src/models/misc/minimum_decision_tree.rs | 11 +++ .../misc/precedence_constrained_scheduling.rs | 7 +- src/rules/highlyconnecteddeletion_ilp.rs | 21 +++-- src/rules/maximumsetpacking_ilp.rs | 2 +- ...mumdiscreteplanarinversekinematics_qubo.rs | 30 ++++-- src/solvers/ilp/solver.rs | 14 ++- src/truth_table.rs | 50 ++++++---- .../models/algebraic/minimum_matrix_cover.rs | 8 ++ .../algebraic/simultaneous_incongruences.rs | 6 ++ .../graph/prize_collecting_steiner_forest.rs | 37 ++++++++ src/unit_tests/models/graph/steiner_tree.rs | 17 ++++ ...onsistency_of_database_frequency_tables.rs | 39 ++++++++ .../models/misc/kth_largest_m_tuple.rs | 23 ++++- .../models/misc/maximum_likelihood_ranking.rs | 17 ++++ .../models/misc/minimum_decision_tree.rs | 16 ++++ .../misc/precedence_constrained_scheduling.rs | 12 +++ .../rules/highlyconnecteddeletion_ilp.rs | 15 +++ src/unit_tests/rules/maximumsetpacking_ilp.rs | 19 ++++ ...mumdiscreteplanarinversekinematics_qubo.rs | 94 ++++++++++++++++++- src/unit_tests/solvers/ilp/solver.rs | 24 +++++ src/unit_tests/truth_table.rs | 31 ++++++ 30 files changed, 574 insertions(+), 73 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 143cc6907..3033f42b2 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -3280,11 +3280,13 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| let steiner-verts = tree-verts.filter(v => not terminals.contains(v)) [ #problem-def("SteinerTree")[ - Given an undirected graph $G = (V, E)$ with edge weights $w: E -> RR_(>= 0)$ and a nonempty set of terminal vertices $T subset.eq V$, find a tree $S = (V_S, E_S)$ in $G$ such that $T subset.eq V_S$, minimizing $sum_(e in E_S) w(e)$. Vertices in $V_S backslash T$ are called _Steiner vertices_. For a single terminal, the tree consisting of that vertex and no edges is feasible. + Given an undirected graph $G = (V, E)$ with integer edge weights $w: E -> ZZ$ and a nonempty set of terminal vertices $T subset.eq V$, find a tree $S = (V_S, E_S)$ in $G$ such that $T subset.eq V_S$, minimizing $sum_(e in E_S) w(e)$. Vertices in $V_S backslash T$ are called _Steiner vertices_. For a single terminal, the tree consisting of that vertex and no edges is feasible, but negative-weight branches can improve its cost. ][ One of Karp's 21 NP-complete problems @karp1972, foundational in network design with applications in telecommunications backbone routing, VLSI chip interconnect, pipeline planning, and phylogenetic tree construction. When $T = V$, the problem reduces to the minimum spanning tree (polynomial). The NP-hardness arises from choosing which Steiner vertices to include. - The best known exact algorithm runs in $O^*(3^(|T|) dot n + 2^(|T|) dot n^2)$ time via Dreyfus--Wagner dynamic programming over terminal subsets @dreyfuswagner1971. Byrka _et al._ achieved a $ln(4) + epsilon approx 1.39$-approximation @byrka2013; the classic 2-approximation uses the minimum spanning tree of the terminal distance graph. + For signed weights, enumerate the $2^(n - |T|)$ subsets of nonterminal vertices and compute a minimum spanning tree on each connected induced subgraph. Every feasible tree occurs within one such vertex set, and replacing it by a minimum spanning tree cannot increase its cost. This gives an exact $O(2^(n - |T|) n^2)$ bound.#footnote[This bound follows from the enumeration argument; no claim of best-known complexity for signed weights is made.] + + For nonnegative weights, Dreyfus--Wagner dynamic programming over terminal subsets runs in $O(3^(|T|) dot n + 2^(|T|) dot n^2)$ time @dreyfuswagner1971. The following approximation guarantees also require nonnegative weights: Byrka _et al._ achieved a $ln(4) + epsilon approx 1.39$-approximation @byrka2013; the classic 2-approximation uses the minimum spanning tree of the terminal distance graph. // Find the unique direct terminal-terminal edge (both endpoints in T, not in the optimal tree) #let terminal-set = terminals @@ -12576,18 +12578,20 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Construction._ For each link $j in {1, dots, n}$ and sample index $a in {0, dots, m_j - 1}$, introduce a binary variable $y_(j,a) in {0,1}$ with the intended meaning "$y_(j,a) = 1$ iff link $j$ chooses orientation $phi_(j,a)$." Define $ c_(j,a) = l_j cos phi_(j,a), quad s_(j,a) = l_j sin phi_(j,a). $ Let - $ P = 1 + (sum_(j,a) |c_(j,a)| + |g_x|)^2 + (sum_(j,a) |s_(j,a)| + |g_y|)^2. $ + $ B = (sum_(j,a) |c_(j,a)| + |g_x|)^2 + (sum_(j,a) |s_(j,a)| + |g_y|)^2, quad P = 2(1 + B). $ The QUBO objective is the sum of three terms: $ H = underbrace((sum_(j,a) c_(j,a) y_(j,a) - g_x)^2 + (sum_(j,a) s_(j,a) y_(j,a) - g_y)^2)_"position error" + underbrace(P sum_(j=1)^n (sum_(a=0)^(m_j - 1) y_(j,a) - 1)^2)_"one-hot" + underbrace(P sum_(j=2)^n sum_((a,b) in.not A_j) y_(j-1,a) y_(j,b))_"forbidden pairs". $ - Expanding with $y_(j,a)^2 = y_(j,a)$ gives the upper-triangular QUBO matrix. As usual, the additive constant $g_x^2 + g_y^2$ is dropped. + Expanding with $y_(j,a)^2 = y_(j,a)$ gives the upper-triangular QUBO matrix. The stored energy is $E = H - C$, where $C = g_x^2 + g_y^2 + n P$ includes the constants from the position error and all one-hot penalties. + + _Correctness._ In exact arithmetic, ($arrow.r.double$) any feasible source configuration maps to a one-hot assignment whose penalties vanish, so $H$ equals its squared distance and is at most $B$. ($arrow.l.double$) A non-one-hot block contributes at least $P$; a one-hot assignment containing a forbidden pair also contributes at least $P$. Since the position error and every penalty are nonnegative, such assignments have $H >= P > B$. Thus, whenever the source is feasible, every target minimizer is feasible, and minimizing $E = H - C$ among these assignments minimizes the source squared distance. An infeasible source has no penalty-zero assignment, although its unconstrained QUBO still has a minimizer. - _Correctness._ ($arrow.r.double$) Any feasible inverse-kinematics configuration $a_1, dots, a_n$ maps to the one-hot assignment with $y_(j,a_j) = 1$ and all other selectors $0$. Every one-hot penalty vanishes, every consecutive pair lies in the relevant admissible set, and the remaining QUBO objective equals the squared end-effector distance up to the dropped additive constant. ($arrow.l.double$) If some link is not one-hot, then $(sum_a y_(j,a) - 1)^2 >= 1$, so the assignment pays at least $P$. If every link is one-hot but some consecutive pair is forbidden, then exactly one forbidden-pair monomial is active at that junction, again contributing at least $P$. By definition of $P$, every decoded source configuration has squared distance at most $P - 1$, while the dropped-constant geometric term is bounded below by $-(g_x^2 + g_y^2)$. Therefore every violating assignment has strictly larger energy than every feasible source assignment. Among the penalty-zero assignments, minimizing $H$ is exactly minimizing the source squared distance. + _Solution extraction._ Validate the target configuration, require exactly one active selector per link, and reject any decoded consecutive pair outside its admissible set. Otherwise return the selected sample indices. Extraction failure is an error, not a certificate that the source is infeasible. - _Solution extraction._ For each link block $j$, read the unique active selector $y_(j,a) = 1$ and output its sample index $a$. If the decoded index vector violates an admissible-pair constraint, the source evaluator rejects it with `Min(None)`. + _Numerical scope._ The implementation uses finite `f64` arithmetic and rejects a non-finite penalty or matrix coefficient. The proportional penalty gap avoids relying on a unit increment at large magnitudes, but rounding of the expanded objective can still merge close objective values. The exact-arithmetic correspondence above is not a guarantee of identical optimizer sets under floating-point evaluation. ] #let mwc_qubo = load-example("MinimumMultiwayCut", "QUBO") diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 5d7126796..6d1676992 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -401,6 +401,8 @@ fn extract_type_name(ty: &Type) -> Option { Some(ident) } + // Forwarded macro_rules! type fragments have an invisible group. + Type::Group(group) => extract_type_name(&group.elem), _ => None, } } @@ -1029,6 +1031,21 @@ mod tests { ); } + #[test] + fn extract_type_name_unwraps_forwarded_type_fragments() { + let inner: Type = parse_str("MinimumVertexCover").unwrap(); + let group = Type::Group(syn::TypeGroup { + attrs: Vec::new(), + group_token: Default::default(), + elem: Box::new(inner), + }); + let ty: Type = syn::parse_quote!(Decision<#group>); + assert_eq!( + extract_type_name(&ty).as_deref(), + Some("DecisionMinimumVertexCover") + ); + } + #[test] fn declare_variants_accepts_single_default() { let input: DeclareVariantsInput = syn::parse_quote! { diff --git a/src/models/algebraic/minimum_matrix_cover.rs b/src/models/algebraic/minimum_matrix_cover.rs index f637f08dd..c23a9b642 100644 --- a/src/models/algebraic/minimum_matrix_cover.rs +++ b/src/models/algebraic/minimum_matrix_cover.rs @@ -72,7 +72,7 @@ impl MinimumMatrixCover { /// /// # Panics /// - /// Panics if the matrix is not square or has inconsistent row lengths. + /// Panics if the matrix is not square or contains a negative entry. pub fn new(matrix: Vec>) -> Self { Self::try_new(matrix).unwrap_or_else(|error| panic!("{error}")) } @@ -87,6 +87,9 @@ impl MinimumMatrixCover { ) .into()); } + if row.iter().any(|&entry| entry < 0) { + return Err(format!("matrix row {i} contains a negative entry").into()); + } } Ok(Self { matrix }) } diff --git a/src/models/algebraic/simultaneous_incongruences.rs b/src/models/algebraic/simultaneous_incongruences.rs index 968d8deda..c2b3bb330 100644 --- a/src/models/algebraic/simultaneous_incongruences.rs +++ b/src/models/algebraic/simultaneous_incongruences.rs @@ -141,7 +141,7 @@ impl Problem for SimultaneousIncongruences { fn evaluate(&self, solution: &Self::Solution) -> Result { Ok({ // x is a solution iff x % bᵢ ≠ aᵢ % bᵢ for every pair. - Or(self.pairs.iter().all(|&(a, b)| solution % b != a % b)) + Or(*solution >= 0 && self.pairs.iter().all(|&(a, b)| solution % b != a % b)) }) } } diff --git a/src/models/graph/prize_collecting_steiner_forest.rs b/src/models/graph/prize_collecting_steiner_forest.rs index 1503614e1..5020daea2 100644 --- a/src/models/graph/prize_collecting_steiner_forest.rs +++ b/src/models/graph/prize_collecting_steiner_forest.rs @@ -229,6 +229,16 @@ impl PrizeCollectingSteinerForest { } beta.validate_element("beta")?; omega.validate_element("omega")?; + if vertex_prizes + .iter() + .chain(&edge_costs) + .chain([&beta, &omega]) + .any(|value| value.to_sum() < W::Sum::zero()) + { + return Err(ConstructionError::InvalidInput( + "vertex prizes, edge costs, beta, and omega must be nonnegative".into(), + )); + } Ok(Self { graph, vertex_prizes, diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index 94285a464..27714a427 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -348,7 +348,7 @@ impl TryFrom for SteinerTree { } crate::declare_variants! { - default SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeCreateSpec random, + default SteinerTree => "2^num_vertices * 0.5^num_terminals * num_vertices^2" create SteinerTreeCreateSpec random, SteinerTree => "3^num_terminals * num_vertices + 2^num_terminals * num_vertices^2" create SteinerTreeOneCreateSpec, } diff --git a/src/models/misc/consistency_of_database_frequency_tables.rs b/src/models/misc/consistency_of_database_frequency_tables.rs index 82eff4f6b..653aab308 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -167,6 +167,33 @@ fn validate_cdft_create( ); } } + domains + .iter() + .try_fold(1usize, |product, &size| product.checked_mul(size)) + .ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing the domain-size product".into(), + ) + })?; + domains + .iter() + .try_fold(0usize, |sum, &size| sum.checked_add(size)) + .and_then(|sum| num_objects.checked_mul(sum)) + .ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing assignment indicators".into(), + ) + })?; + tables + .iter() + .flat_map(|table| table.counts()) + .try_fold(0usize, |sum, row| sum.checked_add(row.len())) + .and_then(|cells| num_objects.checked_mul(cells)) + .ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing auxiliary frequency indicators".into(), + ) + })?; let mut pairs = BTreeSet::new(); for table in tables { let a = table.attribute_a(); @@ -228,6 +255,7 @@ fn validate_cdft_create( impl ConsistencyOfDatabaseFrequencyTables { /// Create a new consistency-of-database-frequency-tables instance. + /// Domain and encoding counts must fit in `usize`. pub fn new( num_objects: usize, attribute_domains: Vec, diff --git a/src/models/misc/kth_largest_m_tuple.rs b/src/models/misc/kth_largest_m_tuple.rs index 8f6137c42..91d2150fb 100644 --- a/src/models/misc/kth_largest_m_tuple.rs +++ b/src/models/misc/kth_largest_m_tuple.rs @@ -90,6 +90,13 @@ impl KthLargestMTuple { if sets.iter().any(|s| s.is_empty()) { return Err("Every set must be non-empty".to_string().into()); } + sets.iter() + .try_fold(1usize, |total, set| total.checked_mul(set.len())) + .ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing the total tuple count".into(), + ) + })?; if sets.iter().flatten().any(|&size| size <= 0) { return Err("All sizes must be positive (> 0)".to_string().into()); } @@ -103,6 +110,7 @@ impl KthLargestMTuple { } /// Try to create a new KthLargestMTuple instance. + /// The total tuple count must fit in `usize`. pub fn try_new( sets: Vec>, k: i64, @@ -143,10 +151,7 @@ impl KthLargestMTuple { /// Returns the total number of m-tuples (product of set sizes). pub fn total_tuples(&self) -> usize { - self.sets - .iter() - .try_fold(1usize, |total, set| total.checked_mul(set.len())) - .expect("KthLargestMTuple total tuple count exceeds usize") + self.sets.iter().map(Vec::len).product() } fn has_at_least_k_qualifying_tuples(&self) -> Result { diff --git a/src/models/misc/maximum_likelihood_ranking.rs b/src/models/misc/maximum_likelihood_ranking.rs index 6483aa1a8..092922633 100644 --- a/src/models/misc/maximum_likelihood_ranking.rs +++ b/src/models/misc/maximum_likelihood_ranking.rs @@ -55,10 +55,24 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximumLikelihoodRankingData")] pub struct MaximumLikelihoodRanking { matrix: Vec>, } +#[derive(Deserialize)] +struct MaximumLikelihoodRankingData { + matrix: Vec>, +} + +impl TryFrom for MaximumLikelihoodRanking { + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumLikelihoodRankingData) -> Result { + Self::try_new(data.matrix) + } +} + impl MaximumLikelihoodRanking { /// Create a new MaximumLikelihoodRanking instance. /// @@ -67,37 +81,48 @@ impl MaximumLikelihoodRanking { /// or if the pairwise sums `a_ij + a_ji` are not the same constant for /// all `i != j`. pub fn new(matrix: Vec>) -> Self { + Self::try_new(matrix).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(matrix: Vec>) -> Result { let n = matrix.len(); for (i, row) in matrix.iter().enumerate() { - assert_eq!( - row.len(), - n, - "matrix must be square: row {i} has length {} but expected {n}", - row.len() - ); - assert_eq!( - row[i], 0, - "diagonal entries must be zero: matrix[{i}][{i}] = {}", - row[i] - ); + if row.len() != n { + return Err(format!( + "matrix must be square: row {i} has length {} but expected {n}", + row.len() + ) + .into()); + } + if row[i] != 0 { + return Err(format!( + "diagonal entries must be zero: matrix[{i}][{i}] = {}", + row[i] + ) + .into()); + } } let mut comparison_count = None; for (i, row) in matrix.iter().enumerate() { for (j, &entry) in row.iter().enumerate().skip(i + 1) { - let pair_sum = entry + matrix[j][i]; + let pair_sum = entry.checked_add(matrix[j][i]).ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "computing the pairwise comparison count".into(), + ) + })?; match comparison_count { None => comparison_count = Some(pair_sum), - Some(expected) => assert_eq!( - pair_sum, - expected, - "all off-diagonal pairs must have the same comparison count: matrix[{i}][{j}] + matrix[{j}][{i}] = {pair_sum}, expected {expected}" - ), + Some(expected) => { + if pair_sum != expected { + return Err(format!("all off-diagonal pairs must have the same comparison count: matrix[{i}][{j}] + matrix[{j}][{i}] = {pair_sum}, expected {expected}").into()); + } + } } } } - Self { matrix } + Ok(Self { matrix }) } /// Returns the comparison matrix. diff --git a/src/models/misc/minimum_decision_tree.rs b/src/models/misc/minimum_decision_tree.rs index cd82f0dfe..3149a8b89 100644 --- a/src/models/misc/minimum_decision_tree.rs +++ b/src/models/misc/minimum_decision_tree.rs @@ -83,6 +83,7 @@ impl MinimumDecisionTree { /// /// # Panics /// - If num_objects < 2 or num_tests < 1 + /// - If the flattened tree slot count cannot fit in usize /// - If test_matrix dimensions don't match /// - If tests don't distinguish all object pairs pub fn new(test_matrix: Vec>, num_objects: usize, num_tests: usize) -> Self { @@ -97,6 +98,11 @@ impl MinimumDecisionTree { if num_objects < 2 { return Err("Need at least 2 objects".into()); } + if num_objects > usize::BITS as usize { + return Err(crate::registry::ConstructionError::IntegerOverflow( + "representing the decision-tree witness slots".into(), + )); + } if num_tests == 0 { return Err("Need at least 1 test".into()); } @@ -216,6 +222,11 @@ impl Problem for MinimumDecisionTree { "decision-tree encoding length does not match the instance".into(), )); } + if config.iter().any(|&test| test > self.num_tests) { + return Err(crate::traits::EvaluationError::InvalidConfiguration( + "decision-tree encoding contains an out-of-range test".into(), + )); + } Min(self.simulate(config)?) }) } diff --git a/src/models/misc/precedence_constrained_scheduling.rs b/src/models/misc/precedence_constrained_scheduling.rs index e351d5d4a..bf17309ae 100644 --- a/src/models/misc/precedence_constrained_scheduling.rs +++ b/src/models/misc/precedence_constrained_scheduling.rs @@ -207,10 +207,11 @@ impl Problem for PrecedenceConstrainedScheduling { )); } // Check processor capacity: at most num_processors tasks per time slot - let mut slot_count = vec![0usize; deadline]; + let mut slot_count = std::collections::BTreeMap::new(); for &slot in config { - slot_count[slot] += 1; - if slot_count[slot] > self.num_processors { + let count = slot_count.entry(slot).or_insert(0usize); + *count += 1; + if *count > self.num_processors { return Ok(crate::types::Or(false)); } } diff --git a/src/rules/highlyconnecteddeletion_ilp.rs b/src/rules/highlyconnecteddeletion_ilp.rs index 1fb2c0b47..7d5d9f556 100644 --- a/src/rules/highlyconnecteddeletion_ilp.rs +++ b/src/rules/highlyconnecteddeletion_ilp.rs @@ -117,13 +117,16 @@ fn vertex_count(clusters: &[Vec]) -> usize { /// Order: all `n` singletons first (subset ids `1, 2, 4, ...`), then larger /// feasible clusters listed by ascending bitmask of their vertex set. This /// gives a stable variable layout; tests pin the singleton prefix. -fn enumerate_feasible_clusters(graph: &SimpleGraph) -> Vec> { +fn enumerate_feasible_clusters( + graph: &SimpleGraph, +) -> Result>, crate::rules::ReductionError> { let n = graph.num_vertices(); - debug_assert!( - n < 64, - "enumerate_feasible_clusters requires n < 64 due to u64 subset mask; got n={}", - n - ); + if n >= u64::BITS as usize { + return Err(crate::rules::ReductionError::integer_overflow::< + HighlyConnectedDeletion, + ILP, + >("enumerating vertex subsets with a u64 mask")); + } let mut clusters: Vec> = Vec::new(); // Singletons first. @@ -132,7 +135,7 @@ fn enumerate_feasible_clusters(graph: &SimpleGraph) -> Vec> { } if n < 3 { - return clusters; + return Ok(clusters); } // Larger feasible clusters by ascending subset bitmask. @@ -147,7 +150,7 @@ fn enumerate_feasible_clusters(graph: &SimpleGraph) -> Vec> { } } - clusters + Ok(clusters) } #[reduction( @@ -165,7 +168,7 @@ impl ReduceTo> for HighlyConnectedDeletion { fn reduce_to(&self) -> Result { let graph = self.graph(); let n = graph.num_vertices(); - let clusters = enumerate_feasible_clusters(graph); + let clusters = enumerate_feasible_clusters(graph)?; let num_vars = clusters.len(); // Partition constraints: for every vertex v, sum_{S : v in S} x_S = 1. diff --git a/src/rules/maximumsetpacking_ilp.rs b/src/rules/maximumsetpacking_ilp.rs index 82766a60e..b2195358a 100644 --- a/src/rules/maximumsetpacking_ilp.rs +++ b/src/rules/maximumsetpacking_ilp.rs @@ -40,7 +40,7 @@ impl ReductionResult for ReductionSPToILP { } #[reduction( - transform = exact { + transform = upper_bound { num_vars = "num_sets", num_constraints = "universe_size", }, diff --git a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs index a00026804..6753e3d39 100644 --- a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -29,6 +29,7 @@ pub struct ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { target: QUBO, block_offsets: Vec, block_sizes: Vec, + allowed_pairs: Vec>, } impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { @@ -45,7 +46,8 @@ impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { ) -> crate::rules::ExtractionResult<::Solution> { crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - self.block_offsets + let config: Vec = self + .block_offsets .iter() .zip(&self.block_sizes) .enumerate() @@ -64,7 +66,15 @@ impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { ))), } }) - .collect() + .collect::>()?; + for (junction, (pair, allowed)) in config.windows(2).zip(&self.allowed_pairs).enumerate() { + if !allowed.contains(&(pair[0], pair[1])) { + return Err(crate::rules::ExtractionError::invalid(format!( + "junction {junction} has a forbidden orientation pair" + ))); + } + } + Ok(config) } } @@ -90,12 +100,19 @@ impl ReduceTo> for MinimumDiscretePlanarInverseKinematics { } // A violation contributes at least one full penalty unit. This bound - // exceeds the largest possible squared distance of any decoded source - // configuration, so every QUBO minimizer for a feasible source - // instance is one-hot and pair-feasible. + // exceeds the largest possible squared distance in exact arithmetic. + // A proportional gap avoids rounding B + 1 back to B at large scales; + // the floating-point objective still has finite-precision limitations. let sum_abs_x: f64 = x_coeffs.iter().map(|coeff| coeff.abs()).sum(); let sum_abs_y: f64 = y_coeffs.iter().map(|coeff| coeff.abs()).sum(); - let penalty = 1.0 + (sum_abs_x + gx.abs()).powi(2) + (sum_abs_y + gy.abs()).powi(2); + let distance_bound = (sum_abs_x + gx.abs()).powi(2) + (sum_abs_y + gy.abs()).powi(2); + let penalty = 2.0 * (1.0 + distance_bound); + if !penalty.is_finite() { + return Err(crate::rules::ReductionError::non_finite_result::< + Self, + QUBO, + >("computing the inverse-kinematics penalty")); + } let mut matrix = vec![vec![0.0; total_vars]; total_vars]; let mut add_upper = |i: usize, j: usize, value: f64| { @@ -164,6 +181,7 @@ impl ReduceTo> for MinimumDiscretePlanarInverseKinematics { })?, block_offsets, block_sizes, + allowed_pairs: self.allowed_pairs().to_vec(), }) } } diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index e5325a29f..1475592f1 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -127,13 +127,25 @@ impl ILPSolver { .lookup(&key) .ilp .ok_or_else(|| ILPSolveError::MissingPipeline(key.label()))?; - pipeline.solve_typed(problem, self) + let solution = pipeline.solve_typed(problem, self)?; + problem + .evaluate(&solution) + .map_err(|error| ILPSolveError::InvalidSolution(error.to_string()))?; + Ok(solution) } fn solve_backend(&self, problem: &ILP) -> Result, ILPSolveError> where V: VariableDomain, { + if self + .time_limit + .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) + { + return Err(ILPSolveError::BackendFailure( + "time limit must be finite and nonnegative".into(), + )); + } self.solve_with_objective(problem, problem.objective()) } diff --git a/src/truth_table.rs b/src/truth_table.rs index 479b02983..517ab6f66 100644 --- a/src/truth_table.rs +++ b/src/truth_table.rs @@ -45,33 +45,51 @@ impl<'de> Deserialize<'de> for TruthTable { D: serde::Deserializer<'de>, { let serde_repr = TruthTableSerde::deserialize(deserializer)?; - Ok(TruthTable { - num_inputs: serde_repr.num_inputs, - outputs: serde_repr.outputs.into_iter().collect(), - }) + TruthTable::try_from_outputs(serde_repr.num_inputs, serde_repr.outputs) + .map_err(serde::de::Error::custom) } } impl TruthTable { + fn row_count(num_inputs: usize) -> Result { + u32::try_from(num_inputs) + .ok() + .and_then(|shift| 1usize.checked_shl(shift)) + .filter(|&rows| rows <= BitSlice::::MAX_BITS) + .ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing truth-table rows".into(), + ) + }) + } + /// Create a truth table from a vector of boolean outputs. /// /// The outputs vector must have exactly 2^num_inputs elements. /// Index i corresponds to the input where the j-th bit represents variable j. pub fn from_outputs(num_inputs: usize, outputs: Vec) -> Self { - let expected_len = 1 << num_inputs; - assert_eq!( - outputs.len(), - expected_len, - "outputs length must be 2^num_inputs = {}, got {}", - expected_len, - outputs.len() - ); + Self::try_from_outputs(num_inputs, outputs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_from_outputs( + num_inputs: usize, + outputs: Vec, + ) -> Result { + let expected_len = Self::row_count(num_inputs)?; + if outputs.len() != expected_len { + return Err(format!( + "outputs length must be 2^num_inputs = {}, got {}", + expected_len, + outputs.len() + ) + .into()); + } let bits: BitVec = outputs.into_iter().collect(); - Self { + Ok(Self { num_inputs, outputs: bits, - } + }) } /// Create a truth table from a function. @@ -81,7 +99,7 @@ impl TruthTable { where F: Fn(&[bool]) -> bool, { - let num_rows = 1 << num_inputs; + let num_rows = Self::row_count(num_inputs).unwrap_or_else(|error| panic!("{error}")); let mut outputs = BitVec::with_capacity(num_rows); for i in 0..num_rows { @@ -102,7 +120,7 @@ impl TruthTable { /// Get the number of rows (2^num_inputs). pub fn num_rows(&self) -> usize { - 1 << self.num_inputs + self.outputs.len() } /// Evaluate the truth table for a given input. diff --git a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs index 220c70f71..b91c293db 100644 --- a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs +++ b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs @@ -182,3 +182,11 @@ fn test_minimum_matrix_cover_canonical_example_spec() { serde_json::json!([false, true, true, false]) ); } +#[test] +fn test_minimum_matrix_cover_rejects_negative_entries() { + assert!( + serde_json::from_value::(serde_json::json!({"matrix": [[-1]]})) + .is_err() + ); + assert!(std::panic::catch_unwind(|| MinimumMatrixCover::new(vec![vec![-1]])).is_err()); +} diff --git a/src/unit_tests/models/algebraic/simultaneous_incongruences.rs b/src/unit_tests/models/algebraic/simultaneous_incongruences.rs index 0506d3829..bd906aef0 100644 --- a/src/unit_tests/models/algebraic/simultaneous_incongruences.rs +++ b/src/unit_tests/models/algebraic/simultaneous_incongruences.rs @@ -131,3 +131,9 @@ fn test_simultaneous_incongruences_paper_example() { let witness = solver.solve(&p).unwrap().unwrap(); assert_eq!(p.evaluate(&witness).unwrap(), Or(true)); } +#[test] +fn test_simultaneous_incongruences_rejects_negative_witness() { + let problem = SimultaneousIncongruences::new(vec![(1, 2)]).unwrap(); + assert_eq!(problem.evaluate(&-1).unwrap(), Or(false)); + assert_eq!(problem.evaluate(&0).unwrap(), Or(true)); +} diff --git a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs index e5a801e5a..fa4c06e45 100644 --- a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs +++ b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs @@ -246,3 +246,40 @@ fn create_specs_default_prizes_and_costs_to_one() { assert!(!PrizeCollectingSteinerForestI64CreateSpec::inputs()[2].required); assert!(!PrizeCollectingSteinerForestI64CreateSpec::inputs()[3].required); } +#[test] +fn test_prize_collecting_steiner_forest_rejects_negative_inputs() { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + for values in [[-1, 0, 0, 0], [0, -1, 0, 0], [0, 0, -1, 0], [0, 0, 0, -1]] { + let [prize, cost, beta, omega] = values; + assert!(PrizeCollectingSteinerForest::new( + graph.clone(), + vec![prize, 0], + vec![cost], + beta, + omega + ) + .is_err()); + assert!(PrizeCollectingSteinerForest::new( + graph.clone(), + vec![prize as f64, 0.0], + vec![cost as f64], + beta as f64, + omega as f64 + ) + .is_err()); + } + let valid = serde_json::to_value(canonical_problem()).unwrap(); + for (field, value) in [ + ("vertex_prizes", serde_json::json!([-1, 2, 5])), + ("edge_costs", serde_json::json!([-1, 6])), + ("beta", serde_json::json!(-1)), + ("omega", serde_json::json!(-1)), + ] { + let mut invalid = valid.clone(); + invalid[field] = value; + assert!( + serde_json::from_value::>(invalid) + .is_err() + ); + } +} diff --git a/src/unit_tests/models/graph/steiner_tree.rs b/src/unit_tests/models/graph/steiner_tree.rs index 261231588..ec2706725 100644 --- a/src/unit_tests/models/graph/steiner_tree.rs +++ b/src/unit_tests/models/graph/steiner_tree.rs @@ -1,5 +1,22 @@ use super::*; +#[test] +fn signed_complexity_counts_nonterminal_subsets() { + let problem = SteinerTree::new(SimpleGraph::new(2, vec![(0, 1)]), vec![-2i64], vec![0]); + let entry = inventory::iter::() + .find(|entry| { + entry.name == "SteinerTree" + && (entry.variant_fn)() + .iter() + .any(|(key, value)| *key == "weight" && *value == "i64") + }) + .unwrap(); + assert_eq!( + (entry.complexity_eval_fn)(&problem as &dyn std::any::Any), + 8.0 + ); +} + #[test] fn test_single_terminal_allows_empty_tree_and_negative_branches() { let json = serde_json::json!({ diff --git a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs index eabdcf4a9..ba1f19c42 100644 --- a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs @@ -1,5 +1,44 @@ use super::*; +#[test] +fn domain_and_encoding_counts_must_fit_usize() { + for (objects, domains, tables) in [ + (1, vec![2; usize::BITS as usize], vec![]), + (0, vec![usize::MAX, 1], vec![]), + (usize::MAX, vec![2], vec![]), + ( + usize::MAX / 6 + 1, + vec![3, 1, 1], + vec![ + FrequencyTable::new(0, 1, vec![vec![0]; 3]), + FrequencyTable::new(0, 2, vec![vec![0]; 3]), + ], + ), + ] { + assert!(matches!( + ConsistencyOfDatabaseFrequencyTables::try_new( + objects, + domains.clone(), + tables.clone(), + vec![] + ), + Err(crate::registry::ConstructionError::IntegerOverflow(_)) + )); + assert!(serde_json::from_value::(serde_json::json!({"num_objects": objects, "attribute_domains": domains, "frequency_tables": tables, "known_values": []})).is_err()); + } + let problem = ConsistencyOfDatabaseFrequencyTables::new( + 1, + vec![2; usize::BITS as usize - 1], + vec![], + vec![], + ); + assert_eq!(problem.domain_size_product(), 1usize << (usize::BITS - 1)); + assert_eq!( + problem.num_assignment_indicators(), + 2 * (usize::BITS as usize - 1) + ); +} + #[test] fn test_consistency_of_database_frequency_tables_validates_persisted_input() { let valid = serde_json::to_value(issue_yes_instance()).unwrap(); diff --git a/src/unit_tests/models/misc/kth_largest_m_tuple.rs b/src/unit_tests/models/misc/kth_largest_m_tuple.rs index e7e905157..306be25c8 100644 --- a/src/unit_tests/models/misc/kth_largest_m_tuple.rs +++ b/src/unit_tests/models/misc/kth_largest_m_tuple.rs @@ -1,4 +1,20 @@ use super::*; + +#[test] +fn tuple_count_must_fit_usize() { + let sets = vec![vec![1, 2]; usize::BITS as usize]; + assert!(matches!( + KthLargestMTuple::try_new(sets.clone(), 1, 1), + Err(crate::registry::ConstructionError::IntegerOverflow(_)) + )); + assert!(serde_json::from_value::( + serde_json::json!({"sets": sets, "k": 1, "bound": 1}) + ) + .is_err()); + let problem = + KthLargestMTuple::try_new(vec![vec![1, 2]; usize::BITS as usize - 1], 1, 1).unwrap(); + assert_eq!(problem.total_tuples(), 1usize << (usize::BITS - 1)); +} use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; @@ -169,8 +185,7 @@ fn test_kth_largest_m_tuple_many_singleton_sets_do_not_use_call_stack() { } #[test] -#[should_panic(expected = "total tuple count exceeds usize")] -fn test_kth_largest_m_tuple_total_tuples_overflow_panics() { - let p = KthLargestMTuple::new(vec![vec![1, 2]; usize::BITS as usize], 1, 1); - p.total_tuples(); +#[should_panic(expected = "representing the total tuple count")] +fn constructor_rejects_unrepresentable_tuple_count() { + KthLargestMTuple::new(vec![vec![1, 2]; usize::BITS as usize], 1, 1); } diff --git a/src/unit_tests/models/misc/maximum_likelihood_ranking.rs b/src/unit_tests/models/misc/maximum_likelihood_ranking.rs index 8d06afd26..950ac5000 100644 --- a/src/unit_tests/models/misc/maximum_likelihood_ranking.rs +++ b/src/unit_tests/models/misc/maximum_likelihood_ranking.rs @@ -196,3 +196,20 @@ fn test_maximum_likelihood_ranking_canonical_example() { assert_eq!(spec.optimal_config, serde_json::json!([0, 1, 2, 3])); assert_eq!(spec.optimal_value, serde_json::json!(7)); } +#[test] +fn test_maximum_likelihood_ranking_rejects_invalid_json() { + for matrix in [ + vec![vec![0, 1], vec![1]], + vec![vec![1]], + vec![vec![0, 1, 2], vec![1, 0, 1], vec![2, 1, 0]], + vec![vec![0, i64::MAX], vec![1, 0]], + ] { + assert!(serde_json::from_value::( + serde_json::json!({ "matrix": matrix }) + ) + .is_err()); + } + let problem: MaximumLikelihoodRanking = + serde_json::from_value(serde_json::json!({ "matrix": [[0, i64::MAX], [0, 0]] })).unwrap(); + assert_eq!(problem.comparison_count(), i64::MAX); +} diff --git a/src/unit_tests/models/misc/minimum_decision_tree.rs b/src/unit_tests/models/misc/minimum_decision_tree.rs index 0b3a2dfb2..dd68182ef 100644 --- a/src/unit_tests/models/misc/minimum_decision_tree.rs +++ b/src/unit_tests/models/misc/minimum_decision_tree.rs @@ -149,3 +149,19 @@ fn test_minimum_decision_tree_indistinguishable() { // Two objects with identical test results MinimumDecisionTree::new(vec![vec![true, true]], 2, 1); } +#[test] +fn test_minimum_decision_tree_rejects_unrepresentable_tree_and_invalid_test() { + assert!( + MinimumDecisionTree::try_from(MinimumDecisionTreeCreateSpec { + num_objects: usize::BITS as usize + 1, + num_tests: 1, + test_matrix: vec![], + }) + .is_err() + ); + let problem = MinimumDecisionTree::new(vec![vec![false, true]], 2, 1); + assert!(matches!( + problem.evaluate(&vec![2]), + Err(crate::traits::EvaluationError::InvalidConfiguration(_)) + )); +} diff --git a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs index 582527c2f..963cf74c7 100644 --- a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs +++ b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs @@ -184,3 +184,15 @@ fn create_spec_defaults_precedences_to_empty() { assert!(problem.precedences().is_empty()); assert!(!PrecedenceConstrainedSchedulingCreateSpec::inputs()[3].required); } +#[test] +fn test_precedence_constrained_scheduling_large_deadline() { + let problem = PrecedenceConstrainedScheduling::new(2, 1, 1_000_000_000, vec![(0, 1)]); + assert_eq!( + problem.evaluate(&vec![0, 999_999_999]).unwrap(), + crate::types::Or(true) + ); + assert_eq!( + problem.evaluate(&vec![0, 0]).unwrap(), + crate::types::Or(false) + ); +} diff --git a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs index 2dc13ee79..073077e69 100644 --- a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs +++ b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs @@ -1,4 +1,12 @@ use super::*; + +#[test] +fn two_vertices_reduce_to_singleton_clusters() { + let source = HighlyConnectedDeletion::new(SimpleGraph::new(2, vec![(0, 1)])); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + assert_eq!(reduction.target_problem().num_vars(), 2); + assert_bf_vs_ilp(&source, &reduction); +} use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::HighlyConnectedDeletion; use crate::rules::test_helpers::assert_bf_vs_ilp; @@ -125,3 +133,10 @@ fn test_highlyconnecteddeletion_to_ilp_disconnected_no_cluster() { assert_bf_vs_ilp(&source, &reduction); } +#[test] +fn test_highly_connected_deletion_rejects_mask_overflow() { + let source = HighlyConnectedDeletion::new(SimpleGraph::new(64, vec![])); + assert!( + as ReduceTo>>::reduce_to(&source).is_err() + ); +} diff --git a/src/unit_tests/rules/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index 8a7e838e8..f27ffdb19 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -1,4 +1,23 @@ use super::*; + +#[test] +fn constraint_count_is_only_an_upper_bound() { + let entry = inventory::iter::() + .find(|entry| entry.source_name == "MaximumSetPacking" && entry.target_name == "ILP") + .unwrap(); + assert_eq!( + entry + .parameter_contract() + .unwrap() + .transform() + .unwrap() + .relation(), + crate::parameters::ParameterRelation::UpperBound + ); + let problem = MaximumSetPacking::new(vec![vec![0], vec![1]]); + let reduction: ReductionSPToILP = ReduceTo::>::reduce_to(&problem).unwrap(); + assert_eq!(reduction.target_problem().constraints().len(), 0); +} use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Max; diff --git a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs index fe188e749..0bc0c6efc 100644 --- a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -7,6 +7,30 @@ use std::f64::consts::{FRAC_PI_2, PI}; const EPS: f64 = 1e-9; +#[test] +fn test_large_link_keeps_one_hot_penalty_strict() { + let source = MinimumDiscretePlanarInverseKinematics::new( + vec![134_217_728.0], + (0.0, 0.0), + vec![vec![0.0]], + vec![], + ) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let solutions = BruteForce::new() + .find_all_witnesses(reduction.target_problem()) + .unwrap(); + assert_eq!(solutions, vec![vec![true]]); +} + +#[test] +fn test_extraction_rejects_forbidden_pair() { + let reduction = ReduceTo::>::reduce_to(&worked_example()).unwrap(); + assert!(reduction + .extract_solution(&vec![false, true, true, false]) + .is_err()); +} + fn worked_example() -> MinimumDiscretePlanarInverseKinematics { MinimumDiscretePlanarInverseKinematics::new( vec![2.0, 1.0], @@ -96,8 +120,74 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_empty_allowed_pairs() { assert!(solver.solve(&source).unwrap().is_none()); assert!(!qubo_solutions.is_empty(), "QUBO solver found no solutions"); for target_solution in qubo_solutions { - let extracted = reduction.extract_solution(&target_solution).unwrap(); - assert_eq!(source.evaluate(&extracted).unwrap(), Min(None)); + assert!(reduction.extract_solution(&target_solution).is_err()); + } +} + +#[test] +fn test_extraction_rejects_malformed_selectors() { + let reduction = ReduceTo::>::reduce_to(&worked_example()).unwrap(); + for config in [ + vec![], + vec![false, false, false, true], + vec![true, true, false, true], + ] { + assert!(reduction.extract_solution(&config).is_err()); + } +} + +#[test] +fn test_non_finite_penalty_is_a_reduction_error() { + let source = MinimumDiscretePlanarInverseKinematics::new( + vec![f64::MAX], + (0.0, 0.0), + vec![vec![0.0]], + vec![], + ) + .unwrap(); + assert!(matches!( + ReduceTo::>::reduce_to(&source), + Err(crate::rules::ReductionError::NonFiniteResult { .. }) + )); +} + +#[test] +fn test_all_two_link_pair_relations_preserve_optima() { + let solver = BruteForce::new(); + for mask in 0..16 { + let pairs: Vec<_> = (0..4) + .filter(|bit| mask & (1 << bit) != 0) + .map(|bit| (bit / 2, bit % 2)) + .collect(); + for target in [(0.0, 0.0), (2.0, 1.0), (-1.0, 2.0)] { + let source = MinimumDiscretePlanarInverseKinematics::new( + vec![2.0, 1.0], + target, + vec![vec![0.0, FRAC_PI_2], vec![0.0, FRAC_PI_2]], + vec![pairs.clone()], + ) + .unwrap(); + let optimum = solver + .solve(&source) + .unwrap() + .map(|config| source.evaluate(&config).unwrap().0.unwrap()); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + for bits in solver + .find_all_witnesses(reduction.target_problem()) + .unwrap() + { + let extracted = reduction.extract_solution(&bits); + if let Some(expected) = optimum { + let actual = source.evaluate(&extracted.unwrap()).unwrap().0.unwrap(); + assert!( + (actual - expected).abs() < EPS, + "mask {mask}, target {target:?}" + ); + } else { + assert!(extracted.is_err()); + } + } + } } } diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 5c193553e..54441b328 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -325,3 +325,27 @@ fn test_float_qubo_objective_matches_reference_within_tolerance() { )); } } +#[test] +fn test_ilp_solver_rejects_invalid_time_limits() { + let problem = binary_ilp(0, vec![], vec![], ObjectiveSense::Minimize); + for seconds in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(matches!( + ILPSolver::with_time_limit(seconds).solve(&problem), + Err(ILPSolveError::BackendFailure(message)) if message.contains("time limit") + )); + } +} +#[test] +fn test_ilp_solver_rejects_source_objective_overflow() { + let problem = ILP::::with_variables( + vec![IntegerVariable::new(Some(1025), Some(1025)).unwrap()], + vec![], + vec![(0, crate::types::MAX_EXACT_F64_INTEGER)], + ObjectiveSense::Maximize, + ) + .unwrap(); + assert!(matches!( + ILPSolver::new().solve(&problem), + Err(ILPSolveError::InvalidSolution(_)) + )); +} diff --git a/src/unit_tests/truth_table.rs b/src/unit_tests/truth_table.rs index 1685079ae..dec8b6998 100644 --- a/src/unit_tests/truth_table.rs +++ b/src/unit_tests/truth_table.rs @@ -1,5 +1,36 @@ use super::*; +#[test] +fn persisted_tables_validate_row_counts() { + for (num_inputs, outputs) in [ + (2, vec![false]), + (usize::BITS as usize - 1, vec![]), + (usize::BITS as usize, vec![]), + (usize::MAX, vec![]), + ] { + assert!(serde_json::from_value::( + serde_json::json!({"num_inputs": num_inputs, "outputs": outputs}) + ) + .is_err()); + } + let table: TruthTable = + serde_json::from_value(serde_json::json!({"num_inputs": 0, "outputs": [true]})).unwrap(); + assert_eq!(table.num_rows(), 1); + assert!(table.evaluate(&[])); +} + +#[test] +#[should_panic(expected = "representing truth-table rows")] +fn function_tables_reject_unrepresentable_rows() { + TruthTable::from_function(usize::BITS as usize, |_| false); +} + +#[test] +#[should_panic(expected = "representing truth-table rows")] +fn output_tables_reject_unrepresentable_rows() { + TruthTable::from_outputs(usize::BITS as usize, vec![]); +} + #[test] fn test_and_gate() { let and = TruthTable::and(2); From 7ed850b2e72561fdd3016c393fd71043343d52f3 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 19 Sep 2026 17:33:33 +0800 Subject: [PATCH 07/44] Align reduction result mappings and decision-rule contracts Register value mappings beside their implementations and share constructed results across witness and aggregate recovery. Enforce decision thresholds, correct reduction edge cases, and verify typed errors and multi-step mappings. --- .claude/CLAUDE.md | 2 + docs/src/design.md | 37 +- problemreductions-cli/src/dispatch.rs | 71 +++- problemreductions-macros/src/lib.rs | 153 +++++--- src/lib.rs | 5 +- src/models/graph/acyclic_partition.rs | 16 +- .../graph/partition_into_paths_of_length_2.rs | 29 +- .../undirected_two_commodity_integral_flow.rs | 4 + .../numerical_matching_with_target_sums.rs | 12 +- .../set/rooted_tree_storage_assignment.rs | 4 +- src/rules/acyclicpartition_ilp.rs | 51 ++- .../balancedcompletebipartitesubgraph_ilp.rs | 20 +- src/rules/biconnectivityaugmentation_ilp.rs | 12 + .../boundedcomponentspanningforest_ilp.rs | 20 +- src/rules/circuit_ilp.rs | 12 + src/rules/circuit_sat.rs | 20 +- src/rules/circuit_spinglass.rs | 2 +- src/rules/clustering_ilp.rs | 20 +- src/rules/coloring_ilp.rs | 23 +- src/rules/coloring_qubo.rs | 3 +- src/rules/consecutiveblockminimization_ilp.rs | 23 +- .../consecutiveonesmatrixaugmentation_ilp.rs | 20 +- src/rules/consecutiveonessubmatrix_ilp.rs | 20 +- ...onsistencyofdatabasefrequencytables_ilp.rs | 20 +- ...ximumindependentset_integralflowbundles.rs | 12 + ...imumdominatingset_minimumsummulticenter.rs | 2 +- ...nminimumdominatingset_minmaxmulticenter.rs | 2 +- ...onminimumvertexcover_hamiltoniancircuit.rs | 2 +- src/rules/directedhamiltonianpath_ilp.rs | 24 +- .../directedtwocommodityintegralflow_ilp.rs | 29 +- src/rules/disjointconnectingpaths_ilp.rs | 23 +- src/rules/eulerianpath_ilp.rs | 20 +- ...tcoverby3sets_algebraicequationsovergf2.rs | 23 +- ...overby3sets_boundeddiameterspanningtree.rs | 12 + src/rules/exactcoverby3sets_ilp.rs | 20 +- .../exactcoverby3sets_maximumsetpacking.rs | 28 +- .../exactcoverby3sets_minimumaxiomset.rs | 30 +- ...verby3sets_minimumfaultdetectiontestset.rs | 47 ++- .../exactcoverby3sets_staffscheduling.rs | 20 +- src/rules/exactcoverby3sets_subsetproduct.rs | 20 +- src/rules/factoring_circuit.rs | 12 + src/rules/factoring_ilp.rs | 23 +- src/rules/feasibleregisterassignment_ilp.rs | 20 +- src/rules/flowshopscheduling_ilp.rs | 54 ++- src/rules/graph.rs | 16 +- ...oniancircuit_biconnectivityaugmentation.rs | 14 + ...niancircuit_bottlenecktravelingsalesman.rs | 24 +- .../hamiltoniancircuit_hamiltonianpath.rs | 20 +- .../hamiltoniancircuit_longestcircuit.rs | 2 +- .../hamiltoniancircuit_quadraticassignment.rs | 2 +- src/rules/hamiltoniancircuit_ruralpostman.rs | 27 +- src/rules/hamiltoniancircuit_stackercrane.rs | 2 +- ...ncircuit_strongconnectivityaugmentation.rs | 34 +- .../hamiltoniancircuit_travelingsalesman.rs | 26 +- ...onianpath_degreeconstrainedspanningtree.rs | 22 +- src/rules/hamiltonianpath_ilp.rs | 26 +- .../hamiltonianpath_isomorphicspanningtree.rs | 20 +- ...onianpathbetweentwovertices_longestpath.rs | 2 +- src/rules/ilp_qubo.rs | 2 +- src/rules/integralflowbundles_ilp.rs | 20 +- src/rules/integralflowhomologousarcs_ilp.rs | 20 +- src/rules/integralflowwithmultipliers_ilp.rs | 20 +- src/rules/isomorphicspanningtree_ilp.rs | 20 +- ...lique_balancedcompletebipartitesubgraph.rs | 33 +- src/rules/kclique_conjunctivebooleanquery.rs | 24 +- src/rules/kclique_ilp.rs | 20 +- src/rules/kclique_subgraphisomorphism.rs | 20 +- src/rules/kcoloring_bicliquecover.rs | 14 + src/rules/kcoloring_casts.rs | 5 +- src/rules/kcoloring_clustering.rs | 32 +- src/rules/kcoloring_partitionintocliques.rs | 51 ++- ...kcoloring_twodimensionalconsecutivesets.rs | 12 + src/rules/ksatisfiability_acyclicpartition.rs | 14 + src/rules/ksatisfiability_bicliquecover.rs | 12 + src/rules/ksatisfiability_casts.rs | 9 +- src/rules/ksatisfiability_cyclicordering.rs | 14 + ...tisfiability_decisionminimumvertexcover.rs | 12 + ...bility_directedtwocommodityintegralflow.rs | 22 +- ...tisfiability_feasibleregisterassignment.rs | 14 + src/rules/ksatisfiability_kclique.rs | 14 + src/rules/ksatisfiability_kernel.rs | 14 + .../ksatisfiability_minimumvertexcover.rs | 37 +- .../ksatisfiability_monochromatictriangle.rs | 22 +- ...satisfiability_oneinthreesatisfiability.rs | 14 + .../ksatisfiability_preemptivescheduling.rs | 2 +- .../ksatisfiability_quadraticcongruences.rs | 14 + ...fiability_quadraticdiophantineequations.rs | 22 +- src/rules/ksatisfiability_qubo.rs | 4 +- .../ksatisfiability_registersufficiency.rs | 12 + ...atisfiability_simultaneousincongruences.rs | 22 +- src/rules/ksatisfiability_subsetsum.rs | 22 +- src/rules/ksatisfiability_timetabledesign.rs | 30 +- src/rules/maximumindependentset_casts.rs | 57 ++- src/rules/maximumsetpacking_casts.rs | 5 +- ...nimumvertexcover_comparativecontainment.rs | 12 + src/rules/mod.rs | 6 +- src/rules/monochromatictriangle_ilp.rs | 20 +- src/rules/multiplechoicebranching_ilp.rs | 20 +- src/rules/multiprocessorscheduling_ilp.rs | 20 +- src/rules/naesatisfiability_ilp.rs | 20 +- src/rules/naesatisfiability_maxcut.rs | 2 +- ...fiability_partitionintoperfectmatchings.rs | 2 +- src/rules/naesatisfiability_setsplitting.rs | 20 +- ...atching_numericalmatchingwithtargetsums.rs | 20 +- .../numericalmatchingwithtargetsums_ilp.rs | 22 +- ...ement_consecutiveonesmatrixaugmentation.rs | 14 + src/rules/partition_binpacking.rs | 27 +- .../partition_cosineproductintegration.rs | 20 +- .../partition_integralflowwithmultipliers.rs | 24 +- src/rules/partition_knapsack.rs | 24 +- .../partition_multiprocessorscheduling.rs | 20 +- src/rules/partition_openshopscheduling.rs | 2 +- src/rules/partition_productionplanning.rs | 20 +- ...ion_sequencingtominimizetardytaskweight.rs | 2 +- src/rules/partition_subsetsum.rs | 22 +- src/rules/partition_sumofsquarespartition.rs | 39 ++- src/rules/partitionintocliques_ilp.rs | 20 +- ...ionintocliques_minimumcoveringbycliques.rs | 2 +- ...flength2_boundedcomponentspanningforest.rs | 24 +- src/rules/partitionintopathsoflength2_ilp.rs | 30 +- src/rules/partitionintotriangles_ilp.rs | 20 +- src/rules/pathconstrainednetworkflow_ilp.rs | 22 +- .../precedenceconstrainedscheduling_ilp.rs | 20 +- .../rectilinearpicturecompression_ilp.rs | 20 +- src/rules/registersufficiency_ilp.rs | 20 +- src/rules/registry.rs | 61 +++- .../resourceconstrainedscheduling_ilp.rs | 20 +- ...arrangement_rootedtreestorageassignment.rs | 62 ++-- src/rules/rootedtreestorageassignment_ilp.rs | 42 ++- src/rules/sat_circuitsat.rs | 20 +- src/rules/sat_coloring.rs | 81 ++--- src/rules/sat_ksat.rs | 46 ++- src/rules/sat_maximumindependentset.rs | 2 +- src/rules/sat_minimumdominatingset.rs | 2 +- ...tisfiability_integralflowhomologousarcs.rs | 26 +- .../satisfiability_maximum2satisfiability.rs | 2 +- src/rules/satisfiability_naesatisfiability.rs | 2 +- src/rules/satisfiability_nontautology.rs | 20 +- .../schedulingwithindividualdeadlines_ilp.rs | 20 +- ...quencingtominimizeweightedtardiness_ilp.rs | 23 +- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 23 +- src/rules/sequencingwithinintervals_ilp.rs | 20 +- ...uencingwithreleasetimesanddeadlines_ilp.rs | 23 +- src/rules/setsplitting_betweenness.rs | 20 +- src/rules/setsplitting_ilp.rs | 20 +- src/rules/sparsematrixcompression_ilp.rs | 22 +- src/rules/stringtostringcorrection_ilp.rs | 22 +- .../strongconnectivityaugmentation_ilp.rs | 24 +- src/rules/subgraphisomorphism_ilp.rs | 23 +- src/rules/subsetsum_closestvectorproblem.rs | 2 +- .../subsetsum_integerexpressionmembership.rs | 20 +- src/rules/subsetsum_partition.rs | 22 +- src/rules/threedimensionalmatching_ilp.rs | 20 +- ...mensionalmatching_minimumweightdecoding.rs | 40 ++- ...threedimensionalmatching_threepartition.rs | 12 + ...partition_resourceconstrainedscheduling.rs | 20 +- ..._sequencingwithreleasetimesanddeadlines.rs | 20 +- src/rules/timetabledesign_ilp.rs | 24 +- src/rules/traits.rs | 7 + src/rules/travelingsalesman_qubo.rs | 2 +- src/rules/undirectedflowlowerbounds_ilp.rs | 32 +- .../undirectedtwocommodityintegralflow_ilp.rs | 29 +- src/unit_tests/example_db.rs | 77 +++++ src/unit_tests/reduction_graph.rs | 6 +- src/unit_tests/rules/acyclicpartition_ilp.rs | 34 +- src/unit_tests/rules/aggregate_contracts.rs | 327 ++++++++++++++++++ src/unit_tests/rules/bicliquecover_bmf.rs | 3 +- .../directedtwocommodityintegralflow_ilp.rs | 23 ++ ...tcoverby3sets_algebraicequationsovergf2.rs | 4 +- .../exactcoverby3sets_maximumsetpacking.rs | 10 +- .../exactcoverby3sets_minimumaxiomset.rs | 12 +- ...verby3sets_minimumfaultdetectiontestset.rs | 10 +- .../exactcoverby3sets_staffscheduling.rs | 5 +- .../rules/exactcoverby3sets_subsetproduct.rs | 4 +- src/unit_tests/rules/factoring_ilp.rs | 2 +- .../rules/flowshopscheduling_ilp.rs | 21 ++ src/unit_tests/rules/graph.rs | 4 +- src/unit_tests/rules/maximumsetpacking_ilp.rs | 3 +- ...fiability_partitionintoperfectmatchings.rs | 4 +- .../rules/naesatisfiability_setsplitting.rs | 4 +- src/unit_tests/rules/partition_binpacking.rs | 10 +- src/unit_tests/rules/partition_knapsack.rs | 10 +- src/unit_tests/rules/partition_subsetsum.rs | 6 +- .../rules/partition_sumofsquarespartition.rs | 42 +-- src/unit_tests/rules/registry.rs | 95 +++++ ...tisfiability_integralflowhomologousarcs.rs | 14 + .../subsetsum_integerexpressionmembership.rs | 9 +- ...mensionalmatching_minimumweightdecoding.rs | 15 +- ...threedimensionalmatching_threepartition.rs | 2 +- .../rules/travelingsalesman_qubo.rs | 2 +- .../rules/undirectedflowlowerbounds_ilp.rs | 26 ++ .../undirectedtwocommodityintegralflow_ilp.rs | 33 +- 192 files changed, 3663 insertions(+), 526 deletions(-) create mode 100644 src/unit_tests/rules/aggregate_contracts.rs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 47c94bc60..81d6a68f6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -166,8 +166,10 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `BruteForce::solve()` returns `Result, SolveError>`; `None` means exhaustive search proved infeasibility - `BruteForce::find_all_witnesses()` is a reference-testing helper for collecting every optimal or satisfying solution - `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows. Neither requires a rule-category tag. When both are registered, completed-result recovery borrows both mappings from the same constructed reduction. +- Register a completed-value mapping with `#[aggregate_reduction]` on its concrete `AggregateReductionResult` implementation. Generic implementations use `register_aggregate_reduction!(ResultType)` for each concrete result type. These register implementations, not rule categories. Read resolved edges through `reduction_entries()`, not raw inventory entries. - Reduction chains expose solution and aggregate-value mappings, not solver outcomes. CLI execution coordinates those mappings when recovering a completed exact target result; callers must establish optimality or infeasibility. A missing mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. - Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. +- Decision-equivalence rules map completed `Or` values identically. Decision-to-optimization rules own their feasibility/threshold map; reject target configurations that do not certify YES instead of returning an invalid source witness. Optimization rules decode optimal witnesses and evaluate the source; register a value map only when mathematically defined. Counting and universal rules map completed folds without witnesses. Follow [result mappings](../docs/src/design.md#result-mappings); no mandatory rule-category tags. - Decode only the reduction's defined mathematical mapping. Reject malformed structure with `ExtractionError`; never panic, truncate, clamp, invent defaults, or add recovery branches. Explicit mathematical alternatives and sentinels are allowed. Test successful decoding and every rejected representation. - CLI-facing dynamic formatting uses aggregate wrapper names directly (for example `Max(2)`, `Min(None)`, `Or(true)`, or `Sum(56)`) - Graph types: SimpleGraph, PlanarGraph, BipartiteGraph, UnitDiskGraph, KingsSubgraph, TriangularSubgraph diff --git a/docs/src/design.md b/docs/src/design.md index cff593427..66ae45c73 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -266,8 +266,9 @@ fallible boundary, not a recovery mechanism: 1. In every direct extractor, call `validate_target_solution()` once before indexing or decoding. Composed extractors delegate this check. -2. Validate any structure required by the inverse mapping, such as exactly-one - blocks, permutations, paths, flows, or schedules. +2. For decision sources, reject an infeasible target value or a failed + rule-owned feasibility threshold. Validate structure required by the inverse mapping, such as + exactly-one blocks, permutations, paths, flows, or schedules. 3. Apply the reduction's mathematical inverse once and return a source configuration with the required length and domains. 4. Return `ExtractionError` when a precondition is not satisfied. @@ -305,7 +306,37 @@ impl ReduceTo> ## Reduction Graph -`ReductionGraph::new()` iterates all registered `ReductionEntry` items (via `inventory`) and builds a variant-level directed graph: +### Result mappings + +Rules follow mathematical contracts, without mandatory category tags: + +| Reduction | Completed-result workflow | Example | +| --- | --- | --- | +| Decision → decision | Map `Or` identically; decode a witness only for YES. | SAT → 3-SAT | +| Optimization → optimization | Decode an optimal target witness and evaluate it on the source. Register `extract_value` only when the rule supplies an objective map. | MinimumVertexCover → MaximumIndependentSet | +| Decision → optimization | Apply the rule's threshold or feasibility map to the exact target optimum. Decode only when it yields YES; return NO without a witness otherwise. | HamiltonianCircuit → TravelingSalesman: optimum cost equals the number of vertices | +| Counting | Fold all target evaluations, then map the total with `extract_value`; no representative witness. Witness equivalence alone does not preserve counts. | Parsimonious circuit → formula encoding with uniquely determined auxiliary values | +| Universal | Fold with `And`, then apply the rule's aggregate map; no representative witness. | Renaming the variables of a universally quantified formula | + +Use `ReduceTo` and `ReductionResult::extract_solution` for witnesses. +When the same construction also maps completed values, implement +`AggregateReductionResult` on its result with `#[aggregate_reduction]`. +The attribute registers the implementation, not a rule category; the implementation +owns the mathematical map. Use `register_aggregate_reduction!(ResultType)` to +register concrete instances of generic implementations, including +`VariantReductionResult`. Both mappings +belong to the same graph edge and share its constructed result. Aggregate-only +rules use `ReduceToAggregate`. + +Reverse a multi-step chain one edge at a time. A NO result continues through +explicit value maps, not through a fabricated invalid witness. Missing maps, +failed extraction, and solver errors are errors, never NO. A numerical ILP +optimum missing a decision threshold is unresolved, not an exact negative +certificate. These rules do not change `Problem`, `SolutionAggregate`, or +solver return types. + +`ReductionGraph::new()` reads `reduction_entries()`, which joins each construction +with its registered result mappings, and builds a variant-level directed graph: - **Nodes** are unique `(problem_name, variant)` pairs — e.g., `("MaximumIndependentSet", {graph: "KingsSubgraph", weight: "i64"})`. - **Edges** come from explicit `#[reduction]` registrations, including diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 4dbabf106..d1a24e118 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -721,6 +721,36 @@ mod tests { .is_err()); } + #[test] + fn decision_chain_carries_no_through_identity_and_threshold_maps() { + use problemreductions::models::{ + formula::{CNFClause, KSatisfiability, Satisfiability}, + MinimumVertexCover, + }; + use problemreductions::variant::K3; + for second in [1, -1] { + let source = Satisfiability::new( + 1, + vec![CNFClause::new(vec![1; 3]), CNFClause::new(vec![second; 3])], + ); + let replay = replay( + &source, + vec![ + problem_step::>(), + problem_step::>(), + ], + ); + let result = replay.solve(SolverRequest::BruteForce).unwrap(); + assert_eq!( + matches!(result.source_outcome, SolveOutcome::Optimal { .. }), + second == 1 + ); + if second == -1 { + assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); + } + } + } + #[test] fn aggregate_only_bundle_executes_and_recovers_without_witnesses() { use problemreductions::rules::{ReductionMode, ReductionPath, ReductionStep}; @@ -755,7 +785,7 @@ mod tests { } #[test] - fn bundle_rejects_infeasible_extracted_witness() { + fn decision_bundle_recovers_yes_and_no_without_invalid_witnesses() { for (clauses, feasible) in [ (vec![vec![1, 1, 1], vec![-1, -1, -1]], false), (vec![vec![1, 1, 1], vec![1, 1, 1]], true), @@ -790,15 +820,15 @@ mod tests { BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Witness) .unwrap(); assert!(replay.extract_value(serde_json::json!(1)).is_err()); + let aggregate = + BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Aggregate) + .unwrap(); assert!(BundleReplay::prepare( &bundle, - problemreductions::rules::ReductionMode::Aggregate + problemreductions::rules::ReductionMode::Turing ) .is_err()); - for mode in [ - problemreductions::rules::ReductionMode::Aggregate, - problemreductions::rules::ReductionMode::Turing, - ] { + { let source = ProblemJson { problem_type: bundle.source.problem_type.clone(), variant: bundle.source.variant.clone(), @@ -814,20 +844,29 @@ mod tests { }) .collect(), }; - assert!(crate::commands::reduce::execute_route(source, route, mode).is_err()); + assert!(crate::commands::reduce::execute_route( + source, + route, + problemreductions::rules::ReductionMode::Aggregate + ) + .is_ok()); } - let result = replay.solve(SolverRequest::BruteForce); + let result = replay.solve(SolverRequest::BruteForce).unwrap(); + let SolveOutcome::Optimal { solution, .. } = &result.target_outcome else { + panic!("vertex cover always has a feasible target solution") + }; + assert_eq!( + aggregate + .extract_value(replay.target.evaluate_json(solution).unwrap()) + .unwrap(), + serde_json::json!(feasible) + ); if feasible { - assert!(matches!(result.unwrap().source_outcome, + assert!(matches!(result.source_outcome, SolveOutcome::Optimal { evaluation, .. } if evaluation == "Or(true)")); } else { - let error = result.err().unwrap(); - assert!(error - .downcast_ref::() - .is_some()); - assert!(error - .to_string() - .contains("extracted solution is infeasible")); + assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); + assert!(replay.extract(solution).is_err()); } } } diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 6d1676992..11f5b86a8 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -205,8 +205,6 @@ fn option_inner_type(ty: &Type) -> Option<&Type> { /// - `transform = upper_bound { field = expression, ... }` — one rule-level upper bound /// - `transform = unavailable { field = "reason", ... }` — no symbolic parameter transform /// - `unavailable = { field = "reason", ... }` — fields that cannot be propagated -/// - `aggregate = identity` or `aggregate = custom` — register the reduction result's -/// `AggregateReductionResult` implementation alongside its witness extractor /// /// ## Syntax /// ```ignore @@ -227,6 +225,70 @@ pub fn reduction(attr: TokenStream, item: TokenStream) -> TokenStream { } } +/// Register the completed-value mapping implemented by a reduction result. +/// The result must also belong to a registered `ReduceTo` construction. +/// Register concrete instances of generic implementations with `register_aggregate_reduction!`. +#[proc_macro_attribute] +pub fn aggregate_reduction(attr: TokenStream, item: TokenStream) -> TokenStream { + parse_macro_input!(attr as syn::parse::Nothing); + let implementation = parse_macro_input!(item as ItemImpl); + match generate_aggregate_impl(&implementation) { + Ok(tokens) => tokens.into(), + Err(error) => error.to_compile_error().into(), + } +} + +/// Register concrete instances of an existing generic aggregate mapping. +#[proc_macro] +pub fn register_aggregate_reduction(input: TokenStream) -> TokenStream { + let result = parse_macro_input!(input as Type); + generate_aggregate_entry(&result).into() +} + +fn generate_aggregate_impl(implementation: &ItemImpl) -> syn::Result { + if !implementation.trait_.as_ref().is_some_and(|(path, _)| { + path.segments + .last() + .is_some_and(|segment| segment.ident == "AggregateReductionResult") + }) { + return Err(syn::Error::new_spanned( + implementation, + "expected impl AggregateReductionResult", + )); + } + if !implementation.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + implementation, + "register concrete result types with register_aggregate_reduction!", + )); + } + let entry = generate_aggregate_entry(&implementation.self_ty); + Ok(quote! { #implementation #entry }) +} + +fn generate_aggregate_entry(result: &Type) -> TokenStream2 { + let source = quote! { <#result as crate::rules::AggregateReductionResult>::Source }; + let target = quote! { <#result as crate::rules::AggregateReductionResult>::Target }; + quote! { + inventory::submit! { + crate::rules::registry::AggregateMappingEntry { + source_name: <#source as crate::traits::Problem>::NAME, + target_name: <#target as crate::traits::Problem>::NAME, + source_variant_fn: <#source as crate::traits::Problem>::variant, + target_variant_fn: <#target as crate::traits::Problem>::variant, + reduce_fn: |src| { + let src = src.downcast_ref::<#source>().ok_or_else( + crate::rules::ReductionError::source_type_mismatch::<#source, #target>, + )?; + <#source as crate::rules::ReduceTo<#target>>::reduce_to(src) + .map(|result: #result| Box::new(result) as Box) + }, + view_fn: crate::rules::aggregate_view::<#result>, + } + } + } +} + #[derive(Clone)] struct ParsedExpressionField { name: String, @@ -239,7 +301,6 @@ struct ReductionAttrs { relation: Option, fields: Option>, unavailable: Option>, - aggregate: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -255,7 +316,6 @@ impl syn::parse::Parse for ReductionAttrs { relation: None, fields: None, unavailable: None, - aggregate: false, }; while !input.is_empty() { @@ -305,16 +365,6 @@ impl syn::parse::Parse for ReductionAttrs { syn::braced!(content in input); attrs.unavailable = Some(parse_unavailable_fields(&content)?); } - "aggregate" => { - let value: syn::Ident = input.parse()?; - if value != "identity" && value != "custom" { - return Err(syn::Error::new( - value.span(), - "expected `identity` or `custom`", - )); - } - attrs.aggregate = true; - } _ => { return Err(syn::Error::new( ident.span(), @@ -519,26 +569,6 @@ fn generate_reduction_entry( .ok_or_else(|| syn::Error::new_spanned(source_type, "Cannot extract source type name"))?; let target_name = extract_type_name(&target_type) .ok_or_else(|| syn::Error::new_spanned(&target_type, "Cannot extract target type name"))?; - let reduce_aggregate_fn = if attrs.aggregate { - quote! { - Some(|src: &dyn std::any::Any| -> Result, crate::rules::ReductionError> { - let src = src.downcast_ref::<#source_type>().ok_or_else( - crate::rules::ReductionError::source_type_mismatch::<#source_type, #target_type>, - )?; - let result = <#source_type as crate::rules::ReduceTo<#target_type>>::reduce_to(src)?; - Ok(Box::new(result)) - }) - } - } else { - quote! { None } - }; - - let aggregate_view_fn = if attrs.aggregate { - quote! { Some(crate::rules::aggregate_view::<<#source_type as crate::rules::ReduceTo<#target_type>>::Result>) } - } else { - quote! { None } - }; - // Collect generic parameter info from the impl block let type_generics = collect_type_generic_names(&impl_block.generics); @@ -584,11 +614,11 @@ fn generate_reduction_entry( let src = src.downcast_ref::<#source_type>().ok_or_else( crate::rules::ReductionError::source_type_mismatch::<#source_type, #target_type>, )?; - let result = <#source_type as crate::rules::ReduceTo<#target_type>>::reduce_to(src)?; - Ok(Box::new(result)) + <#source_type as crate::rules::ReduceTo<#target_type>>::reduce_to(src) + .map(|result| Box::new(result) as Box) }), - reduce_aggregate_fn: #reduce_aggregate_fn, - aggregate_view_fn: #aggregate_view_fn, + reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, } } @@ -1299,30 +1329,39 @@ mod tests { } #[test] - fn reduction_registers_explicit_aggregate_mapping() { - let implementation: syn::ItemImpl = syn::parse_quote! { - impl ReduceTo for Source {} - }; - for (declaration, enabled) in [ - (quote! {}, false), - (quote! { aggregate = identity, }, true), - (quote! { aggregate = custom, }, true), - ] { - let attrs: ReductionAttrs = syn::parse2(quote! { - #declaration transform = exact { num_vertices = "num_vertices" } - }) - .unwrap(); - let tokens = generate_reduction_entry(&attrs, &implementation) - .unwrap() - .to_string(); - assert_eq!(tokens.contains("reduce_aggregate_fn : Some"), enabled); - } + fn reduction_registers_witness_without_value_mapping() { + let implementation = syn::parse_quote! { impl ReduceTo for Source {} }; + let attrs = syn::parse_quote! { transform = exact { n = n } }; + let tokens = generate_reduction_entry(&attrs, &implementation) + .unwrap() + .to_string(); + assert!(tokens.contains("reduce_aggregate_fn : None")); + assert!(tokens.contains("aggregate_view_fn : None")); assert!(syn::parse2::(quote! { - aggregate = unknown, transform = exact { num_vertices = "num_vertices" } + aggregate = identity, transform = exact { n = n } }) .is_err()); } + #[test] + fn aggregate_registration_uses_the_implemented_result_type() { + let implementation = syn::parse_quote! { impl AggregateReductionResult for Mapping {} }; + let tokens = generate_aggregate_impl(&implementation) + .unwrap() + .to_string(); + assert!(tokens.contains("Mapping as crate :: rules :: AggregateReductionResult")); + assert!(tokens.contains("| result : Mapping |")); + assert!(tokens.contains("aggregate_view :: < Mapping >")); + let generic = syn::parse_quote! { impl AggregateReductionResult for Mapping {} }; + assert!(generate_aggregate_impl(&generic).is_err()); + let tokens = generate_aggregate_entry(&syn::parse_quote!(Mapping)).to_string(); + assert!(tokens.contains("| result : Mapping < i64 > |")); + let wrong = syn::parse_quote! { impl ReductionResult for Mapping {} }; + assert!(generate_aggregate_impl(&wrong).is_err()); + let inherent = syn::parse_quote! { impl Mapping {} }; + assert!(generate_aggregate_impl(&inherent).is_err()); + } + #[test] fn reduction_accepts_explicit_transform_attributes() { let attrs: ReductionAttrs = syn::parse_quote! { diff --git a/src/lib.rs b/src/lib.rs index 69381f493..20387a078 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -127,7 +127,10 @@ pub use types::{ }; // Re-export proc macros for reduction registration and variant declaration -pub use problemreductions_macros::{declare_variants, reduction, register_brute_force, CreateSpec}; +pub use problemreductions_macros::{ + aggregate_reduction, declare_variants, reduction, register_aggregate_reduction, + register_brute_force, CreateSpec, +}; // Re-export inventory so `declare_variants!` can use `$crate::inventory::submit!` pub use inventory; diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 05ed0ff6c..86452c38f 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -318,9 +318,13 @@ fn is_valid_acyclic_partition( vertex_weights[vertex].to_sum(), "summing acyclic partition vertex weights", )?; - if partition_weights[label] > *weight_bound { - return Ok(false); - } + } + if partition_weights + .iter() + .zip(&used_labels) + .any(|(weight, used)| *used && weight > weight_bound) + { + return Ok(false); } let mut dense_label = vec![usize::MAX; num_vertices]; @@ -345,13 +349,11 @@ fn is_valid_acyclic_partition( cost.to_sum(), "summing acyclic partition arc costs", )?; - if total_cost > *cost_bound { - return Ok(false); - } quotient_arcs.insert((dense_label[source_label], dense_label[target_label])); } - Ok(DirectedGraph::new(next_dense, quotient_arcs.into_iter().collect()).is_dag()) + Ok(total_cost <= *cost_bound + && DirectedGraph::new(next_dense, quotient_arcs.into_iter().collect()).is_dag()) } crate::declare_variants! { diff --git a/src/models/graph/partition_into_paths_of_length_2.rs b/src/models/graph/partition_into_paths_of_length_2.rs index c8b2717cc..65bb37f01 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -138,29 +138,24 @@ impl PartitionIntoPathsOfLength2 { return false; } - // Count vertices per group - let mut group_sizes = vec![0usize; q]; - for &g in config { - group_sizes[g] += 1; + let mut groups = vec![Vec::new(); q]; + for (vertex, &group) in config.iter().enumerate() { + groups[group].push(vertex); } // Each group must have exactly 3 vertices - if group_sizes.iter().any(|&s| s != 3) { + if groups.iter().any(|vertices| vertices.len() != 3) { return false; } - // Check each group induces at least 2 edges (single pass over edges) - let mut group_edge_counts = vec![0usize; q]; - for (u, v) in self.graph.edges() { - if config[u] == config[v] { - group_edge_counts[config[u]] += 1; - } - } - if group_edge_counts.iter().any(|&c| c < 2) { - return false; - } - - true + // Count distinct pairs: loops and parallel edges cannot form a path. + groups.iter().all(|vertices| { + let [a, b, c] = [vertices[0], vertices[1], vertices[2]]; + usize::from(self.graph.has_edge(a, b)) + + usize::from(self.graph.has_edge(a, c)) + + usize::from(self.graph.has_edge(b, c)) + >= 2 + }) } } diff --git a/src/models/graph/undirected_two_commodity_integral_flow.rs b/src/models/graph/undirected_two_commodity_integral_flow.rs index 684fa4892..f06144eb4 100644 --- a/src/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/models/graph/undirected_two_commodity_integral_flow.rs @@ -291,6 +291,10 @@ impl UndirectedTwoCommodityIntegralFlow { ) -> Result, crate::traits::EvaluationError> { let mut balance = 0_i64; for (edge_index, (u, v)) in self.graph.edges().into_iter().enumerate() { + // A self-loop has equal incoming and outgoing flow at its vertex. + if u == v { + continue; + } let Some(flows) = self.edge_flows(config, edge_index) else { return Ok(None); }; diff --git a/src/models/misc/numerical_matching_with_target_sums.rs b/src/models/misc/numerical_matching_with_target_sums.rs index bccdc2f7c..8b0a2166c 100644 --- a/src/models/misc/numerical_matching_with_target_sums.rs +++ b/src/models/misc/numerical_matching_with_target_sums.rs @@ -153,17 +153,21 @@ impl Problem for NumericalMatchingWithTargetSums { // Check config is valid permutation of 0..m let mut used = vec![false; m]; for &idx in config { - if idx >= m || used[idx] { + if used[idx] { return Ok(Or(false)); } used[idx] = true; } // Compute pair sums and compare multisets - let mut pair_sums: Vec = (0..m) - .map(|i| self.sizes_x[i] + self.sizes_y[config[i]]) + let mut pair_sums: Vec = (0..m) + .map(|i| i128::from(self.sizes_x[i]) + i128::from(self.sizes_y[config[i]])) + .collect(); + let mut sorted_targets: Vec<_> = self + .targets + .iter() + .map(|&value| i128::from(value)) .collect(); - let mut sorted_targets = self.targets.clone(); pair_sums.sort(); sorted_targets.sort(); pair_sums == sorted_targets diff --git a/src/models/set/rooted_tree_storage_assignment.rs b/src/models/set/rooted_tree_storage_assignment.rs index 1593976d0..510e25f89 100644 --- a/src/models/set/rooted_tree_storage_assignment.rs +++ b/src/models/set/rooted_tree_storage_assignment.rs @@ -200,7 +200,7 @@ impl Problem for RootedTreeStorageAssignment { )); } if self.universe_size == 0 { - return Ok(crate::types::Or(self.subsets.is_empty())); + return Ok(crate::types::Or(self.subsets.is_empty() && self.bound >= 0)); } let Some(depth) = Self::analyze_tree(config) else { @@ -227,7 +227,7 @@ impl Problem for RootedTreeStorageAssignment { } } - true + total_cost <= self.bound }) }) } diff --git a/src/rules/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index df8b781e2..921db9a78 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -29,16 +29,34 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionAcyclicPartitionToILP { + type Source = AcyclicPartition; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { - num_vars = "num_vertices * num_vertices + num_arcs * num_vertices + num_arcs", - num_constraints = "2 * num_vertices + 3 * num_arcs * num_vertices + 2 * num_arcs + 1", + num_vars = "num_vertices * num_vertices + num_arcs * num_vertices + num_arcs + num_vertices", + num_constraints = "num_vertices^2 + 4 * num_vertices + 3 * num_arcs * num_vertices + 2 * num_arcs + 1", }, unavailable = { num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", @@ -59,7 +77,8 @@ impl ReduceTo> for AcyclicPartition { let x_idx = |v: usize, c: usize| -> usize { v * n + c }; let s_idx = |t: usize, c: usize| -> usize { n * n + t * n + c }; let y_idx = |t: usize| -> usize { n * n + m * n + t }; - let num_vars = n * n + m * n + m; + let used_idx = |c: usize| -> usize { n * n + m * n + m + c }; + let num_vars = n * n + m * n + m + n; let mut constraints = Vec::new(); let vertex_weights = self.vertex_weights(); let arc_costs = self.arc_costs(); @@ -72,14 +91,32 @@ impl ReduceTo> for AcyclicPartition { constraints.push(LinearConstraint::eq(terms, 1)); } - // 2) Weight bound: Σ_v w_v * x_{v,c} ≤ B for each class c + // 2) Only occupied classes must meet the weight bound, which can be negative. for c in 0..n { - let terms: Vec<(usize, i64)> = vertex_weights + constraints.push(LinearConstraint::le(vec![(used_idx(c), 1)], 1)); + let mut occupied = vec![(used_idx(c), -1)]; + for v in 0..n { + constraints.push(LinearConstraint::le( + vec![(x_idx(v, c), 1), (used_idx(c), -1)], + 0, + )); + occupied.push((x_idx(v, c), 1)); + } + constraints.push(LinearConstraint::ge(occupied, 0)); + let mut terms: Vec<(usize, i64)> = vertex_weights .iter() .enumerate() .map(|(vertex, &weight)| (x_idx(vertex, c), weight)) .collect(); - constraints.push(LinearConstraint::le(terms, weight_bound)); + terms.push(( + used_idx(c), + weight_bound.checked_neg().ok_or_else(|| { + crate::rules::ReductionError::integer_overflow::>( + "negating the partition weight bound", + ) + })?, + )); + constraints.push(LinearConstraint::le(terms, 0)); } // 3) McCormick: s_{t,c} = x_{u_t,c} * x_{v_t,c} diff --git a/src/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/rules/balancedcompletebipartitesubgraph_ilp.rs index 8c220ba38..565dbdce5 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -28,7 +28,13 @@ impl ReductionResult for ReductionBCBSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution[..self.num_vertices] .iter() @@ -37,6 +43,18 @@ impl ReductionResult for ReductionBCBSToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionBCBSToILP { + type Source = BalancedCompleteBipartiteSubgraph; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_vertices", diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index 6fc7dd3bc..761a95ce4 100644 --- a/src/rules/biconnectivityaugmentation_ilp.rs +++ b/src/rules/biconnectivityaugmentation_ilp.rs @@ -74,6 +74,18 @@ impl ReductionResult for ReductionBiconnAugToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionBiconnAugToILP { + type Source = BiconnectivityAugmentation; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_potential_edges + 2 * num_vertices * (num_vertices + 1) * (num_edges + num_potential_edges)", diff --git a/src/rules/boundedcomponentspanningforest_ilp.rs b/src/rules/boundedcomponentspanningforest_ilp.rs index 96c32f915..21f1b350e 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -31,12 +31,30 @@ impl ReductionResult for ReductionBCSFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode_rows(target_solution, self.n, self.k, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionBCSFToILP { + type Source = BoundedComponentSpanningForest; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "3 * num_vertices * max_components + 2 * max_components + 2 * num_edges * max_components", diff --git a/src/rules/circuit_ilp.rs b/src/rules/circuit_ilp.rs index 6fa63d452..785fafb20 100644 --- a/src/rules/circuit_ilp.rs +++ b/src/rules/circuit_ilp.rs @@ -193,6 +193,18 @@ impl ILPBuilder { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionCircuitToILP { + type Source = CircuitSAT; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_variables + 2 * num_expression_nodes", diff --git a/src/rules/circuit_sat.rs b/src/rules/circuit_sat.rs index 34811903a..8a7fca926 100644 --- a/src/rules/circuit_sat.rs +++ b/src/rules/circuit_sat.rs @@ -293,12 +293,30 @@ impl ReductionResult for ReductionCircuitSATToSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(target_solution[..self.source_var_count].to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionCircuitSATToSAT { + type Source = CircuitSAT; + type Target = Satisfiability; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = unavailable { num_vars = "the exact Tseitin variable count is specific to this reduction and is not a CircuitSAT parameter", diff --git a/src/rules/circuit_spinglass.rs b/src/rules/circuit_spinglass.rs index 92ee614d4..b9f97a94b 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -246,6 +246,7 @@ impl ReductionResult for ReductionCircuitToSG { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionCircuitToSG { type Source = CircuitSAT; type Target = SpinGlass; @@ -491,7 +492,6 @@ fn process_assignment( } #[reduction( - aggregate = custom, transform = upper_bound { num_spins = "num_variables + 3 * num_expression_nodes", num_interactions = "6 * num_expression_nodes + num_assignment_outputs", diff --git a/src/rules/clustering_ilp.rs b/src/rules/clustering_ilp.rs index bb3149ae0..d9df792c6 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -30,7 +30,13 @@ impl ReductionResult for ReductionClusteringToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -41,6 +47,18 @@ impl ReductionResult for ReductionClusteringToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionClusteringToILP { + type Source = Clustering; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_elements * num_clusters", diff --git a/src/rules/coloring_ilp.rs b/src/rules/coloring_ilp.rs index 85102550e..65f06b31e 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -48,7 +48,13 @@ where &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode_rows(target_solution, self.num_vertices, self.num_colors, 0) } @@ -99,6 +105,21 @@ fn reduce_kcoloring_to_ilp( }) } +crate::register_aggregate_reduction!(ReductionKColoringToILP); + +impl crate::rules::AggregateReductionResult + for ReductionKColoringToILP +{ + type Source = KColoring; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + // Register only the KN variant in the reduction graph #[reduction( transform = exact { diff --git a/src/rules/coloring_qubo.rs b/src/rules/coloring_qubo.rs index c29893620..5433e7b47 100644 --- a/src/rules/coloring_qubo.rs +++ b/src/rules/coloring_qubo.rs @@ -64,6 +64,8 @@ impl ReductionResult for ReductionKColoringToQUBO { } } +crate::register_aggregate_reduction!(ReductionKColoringToQUBO); + impl crate::rules::AggregateReductionResult for ReductionKColoringToQUBO { type Source = KColoring; type Target = QUBO; @@ -183,7 +185,6 @@ fn reduce_kcoloring_to_qubo( // Register only the KN variant in the reduction graph #[reduction( - aggregate = custom, transform = exact { num_vars = "num_vertices * num_colors", } diff --git a/src/rules/consecutiveblockminimization_ilp.rs b/src/rules/consecutiveblockminimization_ilp.rs index ca25d2c2f..30f6ce0ce 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -28,12 +28,30 @@ impl ReductionResult for ReductionCBMToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionCBMToILP { + type Source = ConsecutiveBlockMinimization; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_cols * num_cols + num_rows * num_cols + num_rows * num_cols", @@ -84,6 +102,9 @@ impl ReduceTo> for ConsecutiveBlockMinimization { // Block-start indicators for r in 0..m { + if n == 0 { + break; + } // b_{r,0} = a_{r,0} let b_idx = b_offset + r * n; let a_idx = a_offset + r * n; diff --git a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs index 0941ff0a7..44827dae4 100644 --- a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -29,12 +29,30 @@ impl ReductionResult for ReductionCOMAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionCOMAToILP { + type Source = ConsecutiveOnesMatrixAugmentation; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_cols * num_cols + 5 * num_rows * num_cols", diff --git a/src/rules/consecutiveonessubmatrix_ilp.rs b/src/rules/consecutiveonessubmatrix_ilp.rs index d279e0913..c1187d90a 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -26,7 +26,13 @@ impl ReductionResult for ReductionCOSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ // Output the selection bits s_c (first num_cols variables) @@ -38,6 +44,18 @@ impl ReductionResult for ReductionCOSToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionCOSToILP { + type Source = ConsecutiveOnesSubmatrix; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_cols + num_cols * bound + 5 * num_rows * bound", diff --git a/src/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/rules/consistencyofdatabasefrequencytables_ilp.rs index 71710293c..b927cef48 100644 --- a/src/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -95,7 +95,13 @@ impl ReductionResult for ReductionCDFTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let mut source_solution = Vec::with_capacity(self.source.num_assignment_variables()); @@ -127,6 +133,18 @@ impl ReductionResult for ReductionCDFTToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionCDFTToILP { + type Source = ConsistencyOfDatabaseFrequencyTables; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_objects * total_domain_size + num_objects * num_frequency_cells", diff --git a/src/rules/decisionmaximumindependentset_integralflowbundles.rs b/src/rules/decisionmaximumindependentset_integralflowbundles.rs index 7fdeab6b4..f0d631387 100644 --- a/src/rules/decisionmaximumindependentset_integralflowbundles.rs +++ b/src/rules/decisionmaximumindependentset_integralflowbundles.rs @@ -76,6 +76,18 @@ fn flow_requirement(n: usize, bound: i64) -> Result>; + type Target = IntegralFlowBundles; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_vertices = "num_vertices + 3", diff --git a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 60d57a4cb..3ee7ab92c 100644 --- a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -44,6 +44,7 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMultic } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { @@ -60,7 +61,6 @@ impl crate::rules::AggregateReductionResult } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "num_vertices + 2", num_edges = "num_edges" } )] impl ReduceTo> diff --git a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs index 5bcb1bac1..331a0d29c 100644 --- a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -41,6 +41,7 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticente } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { @@ -57,7 +58,6 @@ impl crate::rules::AggregateReductionResult } #[reduction( - aggregate = custom, transform = exact { num_vertices = "num_vertices + 2", num_edges = "num_edges", diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 0ae1b1c88..a836ac821 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -294,6 +294,7 @@ fn insert_edge(edges: &mut BTreeSet<(usize, usize)>, a: usize, b: usize) { edges.insert(edge); } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { @@ -310,7 +311,6 @@ impl crate::rules::AggregateReductionResult } #[reduction( - aggregate = identity, transform = unavailable { num_vertices = "the construction size depends on the decision threshold, which is not a problem parameter", num_edges = "the construction size depends on the decision threshold, which is not a problem parameter", diff --git a/src/rules/directedhamiltonianpath_ilp.rs b/src/rules/directedhamiltonianpath_ilp.rs index af6cd0de9..a5a2c877c 100644 --- a/src/rules/directedhamiltonianpath_ilp.rs +++ b/src/rules/directedhamiltonianpath_ilp.rs @@ -34,7 +34,13 @@ impl ReductionResult for ReductionDirectedHamiltonianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let n = self.num_vertices; @@ -45,10 +51,22 @@ impl ReductionResult for ReductionDirectedHamiltonianPathToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionDirectedHamiltonianPathToILP { + type Source = DirectedHamiltonianPath; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vars = "num_vertices^2", - num_constraints = "3 * num_vertices + (num_vertices - 1) * (num_vertices^2 - num_arcs)", + num_constraints = "3 * num_vertices + num_vertices^3", }, unavailable = { num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", diff --git a/src/rules/directedtwocommodityintegralflow_ilp.rs b/src/rules/directedtwocommodityintegralflow_ilp.rs index 6e644d21f..e5579ce1b 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -41,12 +41,30 @@ impl ReductionResult for ReductionD2CIFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(&target_solution[..2 * self.num_arcs]) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionD2CIFToILP { + type Source = DirectedTwoCommodityIntegralFlow; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "2 * num_arcs", @@ -101,7 +119,8 @@ impl ReduceTo> for DirectedTwoCommodityIntegralFlow { if let Some(terms) = &mut terms_c2 { terms.push((f2(a), -1)); } - } else if vertex == v { + } + if vertex == v { // Arc enters vertex: incoming if let Some(terms) = &mut terms_c1 { terms.push((f1(a), 1)); @@ -126,7 +145,8 @@ impl ReduceTo> for DirectedTwoCommodityIntegralFlow { for (a, &(u, v)) in arcs.iter().enumerate() { if v == sink_1 { sink1_terms.push((f1(a), 1)); - } else if u == sink_1 { + } + if u == sink_1 { sink1_terms.push((f1(a), -1)); } } @@ -138,7 +158,8 @@ impl ReduceTo> for DirectedTwoCommodityIntegralFlow { for (a, &(u, v)) in arcs.iter().enumerate() { if v == sink_2 { sink2_terms.push((f2(a), 1)); - } else if u == sink_2 { + } + if u == sink_2 { sink2_terms.push((f2(a), -1)); } } diff --git a/src/rules/disjointconnectingpaths_ilp.rs b/src/rules/disjointconnectingpaths_ilp.rs index 5d1269c13..0fc8ae6d0 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -40,7 +40,13 @@ impl ReductionResult for ReductionDCPToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } let mut result = vec![false; self.edges.len()]; for (k, &(source, sink)) in self.terminal_pairs.iter().enumerate() { @@ -85,6 +91,18 @@ impl ReductionResult for ReductionDCPToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionDCPToILP { + type Source = DisjointConnectingPaths; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_pairs * 2 * num_edges", @@ -122,6 +140,9 @@ impl ReduceTo> for DisjointConnectingPaths { // Build adjacency index: for each vertex, which edges are incident let mut vertex_edges: Vec> = vec![Vec::new(); n]; for (e, &(u, v)) in edges.iter().enumerate() { + if u == v { + continue; + } vertex_edges[u].push(e); vertex_edges[v].push(e); } diff --git a/src/rules/eulerianpath_ilp.rs b/src/rules/eulerianpath_ilp.rs index ce8ec318b..1aa646d24 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -74,7 +74,13 @@ impl ReductionResult for ReductionEulerianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let m = self.num_arcs; @@ -139,6 +145,18 @@ fn compatible_pairs(arcs: &[(usize, usize)]) -> Vec<(usize, usize)> { pairs } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionEulerianPathToILP { + type Source = EulerianPath; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "3 * num_arcs + num_arcs * num_arcs", diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs index ace09c92b..0946e026b 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -22,13 +22,32 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(target_solution.to_vec()) } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { + type Source = ExactCoverBy3Sets; + type Target = AlgebraicEquationsOverGF2; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + +#[reduction( + transform = upper_bound { num_variables = "num_sets", num_equations = "universe_size + 9 * num_sets^2", })] diff --git a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index f87ba0c2a..92b1ca71d 100644 --- a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -116,6 +116,18 @@ impl ReductionResult for ReductionX3CToBoundedDiameterSpanningTree { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionX3CToBoundedDiameterSpanningTree { + type Source = ExactCoverBy3Sets; + type Target = BoundedDiameterSpanningTree; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "num_subsets + universe_size + 3", diff --git a/src/rules/exactcoverby3sets_ilp.rs b/src/rules/exactcoverby3sets_ilp.rs index b42f93864..06ccac157 100644 --- a/src/rules/exactcoverby3sets_ilp.rs +++ b/src/rules/exactcoverby3sets_ilp.rs @@ -25,12 +25,30 @@ impl ReductionResult for ReductionX3CToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution.iter().map(|&value| value == 1).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionX3CToILP { + type Source = ExactCoverBy3Sets; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_subsets", diff --git a/src/rules/exactcoverby3sets_maximumsetpacking.rs b/src/rules/exactcoverby3sets_maximumsetpacking.rs index 60536abc0..57350fb32 100644 --- a/src/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/rules/exactcoverby3sets_maximumsetpacking.rs @@ -14,6 +14,7 @@ use crate::types::One; #[derive(Debug, Clone)] pub struct ReductionXC3SToMaximumSetPacking { target: MaximumSetPacking, + source_universe_size: usize, } impl ReductionResult for ReductionXC3SToMaximumSetPacking { @@ -33,12 +34,36 @@ impl ReductionResult for ReductionXC3SToMaximumSetPacking { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } Ok(target_solution.to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionXC3SToMaximumSetPacking { + type Source = ExactCoverBy3Sets; + type Target = MaximumSetPacking; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Max) -> crate::types::Or { + crate::types::Or( + value + .0 + .is_some_and(|count| i128::from(count) == self.source_universe_size as i128 / 3), + ) + } +} + #[reduction( transform = exact { num_sets = "num_subsets", @@ -59,6 +84,7 @@ impl ReduceTo> for ExactCoverBy3Sets { Ok(ReductionXC3SToMaximumSetPacking { target: MaximumSetPacking::::new(sets), + source_universe_size: self.universe_size(), }) } } diff --git a/src/rules/exactcoverby3sets_minimumaxiomset.rs b/src/rules/exactcoverby3sets_minimumaxiomset.rs index 5c9827351..32fb10094 100644 --- a/src/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/rules/exactcoverby3sets_minimumaxiomset.rs @@ -27,13 +27,19 @@ impl ReductionResult for ReductionXC3SToMinimumAxiomSet { /// Extract the chosen source subsets from the set-sentence coordinates. /// /// For YES-instances, every optimal target witness of value q consists only of - /// q set-sentences, which form an exact cover. For NO-instances, the extracted - /// vector may be non-satisfying, which is expected for an `Or -> Min` rule. + /// q set-sentences, which form an exact cover. Witnesses outside this bound + /// are rejected; the completed optimum maps to YES/NO via `extract_value`. fn extract_solution( &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } Ok({ let set_offset = self.source_universe_size; @@ -44,6 +50,24 @@ impl ReductionResult for ReductionXC3SToMinimumAxiomSet { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionXC3SToMinimumAxiomSet { + type Source = ExactCoverBy3Sets; + type Target = MinimumAxiomSet; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or( + value + .0 + .is_some_and(|count| i128::from(count) == self.source_universe_size as i128 / 3), + ) + } +} + #[reduction( transform = exact { num_sentences = "universe_size + num_subsets", diff --git a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index eb2846373..27f496f7f 100644 --- a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -14,6 +14,7 @@ use crate::rules::traits::{ReduceTo, ReductionResult}; #[derive(Debug, Clone)] pub struct ReductionXC3SToMinimumFaultDetectionTestSet { target: MinimumFaultDetectionTestSet, + source_universe_size: usize, } impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { @@ -28,17 +29,44 @@ impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } + if self.source_universe_size == 0 { + return Ok(vec![]); + } Ok(target_solution.iter().map(|row| row[0]).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { + type Source = ExactCoverBy3Sets; + type Target = MinimumFaultDetectionTestSet; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or( + value + .0 + .is_some_and(|count| i128::from(count) == self.source_universe_size as i128 / 3), + ) + } +} + #[reduction( - transform = exact { - num_vertices = "num_subsets + universe_size + 1", - num_arcs = "3 * num_subsets + universe_size", - num_inputs = "num_subsets", + transform = upper_bound { + num_vertices = "num_subsets + universe_size + 2", + num_arcs = "3 * num_subsets + universe_size + 1", + num_inputs = "num_subsets + 1", num_outputs = "1", })] impl ReduceTo for ExactCoverBy3Sets { @@ -46,6 +74,14 @@ impl ReduceTo for ExactCoverBy3Sets { fn reduce_to(&self) -> Result { let num_inputs = self.num_subsets(); + if num_inputs == 0 { + // The target requires an input and an output. With no internal + // vertices its optimum is zero, matching q only for an empty universe. + return Ok(ReductionXC3SToMinimumFaultDetectionTestSet { + target: MinimumFaultDetectionTestSet::new(2, vec![(0, 1)], vec![0], vec![1]), + source_universe_size: self.universe_size(), + }); + } let element_offset = num_inputs; let output = element_offset + self.universe_size(); @@ -60,6 +96,7 @@ impl ReduceTo for ExactCoverBy3Sets { } Ok(ReductionXC3SToMinimumFaultDetectionTestSet { + source_universe_size: self.universe_size(), target: MinimumFaultDetectionTestSet::new( output + 1, arcs, diff --git a/src/rules/exactcoverby3sets_staffscheduling.rs b/src/rules/exactcoverby3sets_staffscheduling.rs index 3721b7fb9..744f23af3 100644 --- a/src/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/rules/exactcoverby3sets_staffscheduling.rs @@ -37,12 +37,30 @@ impl ReductionResult for ReductionXC3SToStaffScheduling { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(target_solution.iter().map(|&count| count > 0).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionXC3SToStaffScheduling { + type Source = ExactCoverBy3Sets; + type Target = StaffScheduling; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_periods = "universe_size", diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index 4084f1a40..61906ca0d 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -30,7 +30,13 @@ impl ReductionResult for ReductionX3CToSubsetProduct { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(target_solution.to_vec()) } @@ -58,6 +64,18 @@ fn assigned_primes(universe_size: usize) -> Vec { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionX3CToSubsetProduct { + type Source = ExactCoverBy3Sets; + type Target = SubsetProduct; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_elements = "num_sets", diff --git a/src/rules/factoring_circuit.rs b/src/rules/factoring_circuit.rs index 330406035..21c57590a 100644 --- a/src/rules/factoring_circuit.rs +++ b/src/rules/factoring_circuit.rs @@ -212,6 +212,18 @@ fn build_multiplier_cell( (assignments, ancillas) } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionFactoringToCircuit { + type Source = Factoring; + type Target = CircuitSAT; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_variables = "6 * num_bits_first * num_bits_second + 2 * (num_bits_first + num_bits_second) + 1", diff --git a/src/rules/factoring_ilp.rs b/src/rules/factoring_ilp.rs index eef11666f..b71ef7adb 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -80,7 +80,13 @@ impl ReductionResult for ReductionFactoringToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ // Extract p bits (first factor) @@ -105,7 +111,20 @@ impl ReductionResult for ReductionFactoringToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionFactoringToILP { + type Source = Factoring; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + +#[reduction( + transform = upper_bound { num_vars = "num_bits_first * num_bits_second + 2 * num_bits_first + 2 * num_bits_second + target_bits", num_constraints = "3 * num_bits_first * num_bits_second + 4 * num_bits_first + 4 * num_bits_second + 3 * target_bits + 1", }, diff --git a/src/rules/feasibleregisterassignment_ilp.rs b/src/rules/feasibleregisterassignment_ilp.rs index 69b181c5d..d88a2b783 100644 --- a/src/rules/feasibleregisterassignment_ilp.rs +++ b/src/rules/feasibleregisterassignment_ilp.rs @@ -33,12 +33,30 @@ impl ReductionResult for ReductionFeasibleRegisterAssignmentToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(&target_solution[..self.num_vertices]) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionFeasibleRegisterAssignmentToILP { + type Source = FeasibleRegisterAssignment; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "2 * num_vertices + num_vertices * (num_vertices - 1) / 2", diff --git a/src/rules/flowshopscheduling_ilp.rs b/src/rules/flowshopscheduling_ilp.rs index d5a4d60ee..a35457c13 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -34,12 +34,20 @@ impl ReductionResult for ReductionFSSToILP { &self.target } - /// Extract solution by sorting jobs by final-machine completion time C_{j,m-1}. + /// Sort by the sum of completion times across all machines. A predecessor + /// cannot have a larger sum; ties can reverse only zero-duration jobs, + /// which do not delay the remaining schedule. fn extract_solution( &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let n = self.num_jobs; @@ -47,15 +55,34 @@ impl ReductionResult for ReductionFSSToILP { let c_offset = self.num_order_vars; let mut jobs: Vec = (0..n).collect(); jobs.sort_by_key(|&j| { - let idx = c_offset + j * m + (m - 1); - (target_solution[idx], j) + let start = c_offset + j * m; + ( + target_solution[start..start + m] + .iter() + .map(|&time| i128::from(time)) + .sum::(), + j, + ) }); jobs }) } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionFSSToILP { + type Source = FlowShopScheduling; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + +#[reduction( + transform = upper_bound { num_vars = "num_jobs * (num_jobs - 1) / 2 + num_jobs * num_processors", num_constraints = "num_jobs * (num_jobs - 1) + num_jobs + num_jobs * (num_processors - 1) + num_jobs * (num_jobs - 1) * num_processors + num_jobs", }, @@ -112,7 +139,9 @@ impl ReduceTo> for FlowShopScheduling { // 2. C_{j,0} >= p_{j,0} for all j for (j, p_j) in p.iter().enumerate() { - constraints.push(LinearConstraint::ge(vec![(c_var(j, 0), 1)], p_j[0])); + if let Some(&length) = p_j.first() { + constraints.push(LinearConstraint::ge(vec![(c_var(j, 0), 1)], length)); + } } // 3. Machine chain: C_{j,q+1} >= C_{j,q} + p_{j,q+1} for all j, q in 0..m-1 @@ -128,19 +157,6 @@ impl ReduceTo> for FlowShopScheduling { // 4. Disjunctive: C_{j,q} >= C_{i,q} + p_{j,q} - M*(1 - y_{i,j}) for i != j, all q // For i < j: y_{i,j} is the variable. - // C_{j,q} - C_{i,q} + M*y_{i,j} >= p_{j,q} + M ... wrong - // Actually: C_{j,q} >= C_{i,q} + p_{j,q} - M*(1 - y_{i,j}) - // => C_{j,q} - C_{i,q} + M*y_{i,j} >= p_{j,q} ... when y_{i,j}=0 (i NOT before j): inactive - // when y_{i,j}=1 (i before j): C_{j,q} >= C_{i,q} + p_{j,q} - // Wait, this needs reconsideration. The paper says: - // C_{j,q} >= C_{i,q} + p_{j,q} - M*(1 - y_{i,j}) - // => C_{j,q} - C_{i,q} - M*y_{i,j} >= p_{j,q} - M - // No let me expand directly: - // C_{j,q} - C_{i,q} + M*y_{i,j} >= p_{j,q} + M*(0)... hmm - // - // Let me re-derive: C_{j,q} >= C_{i,q} + p_{j,q} - M*(1 - y_{i,j}) - // = C_{j,q} - C_{i,q} + M*(1 - y_{i,j}) >= p_{j,q} - // = C_{j,q} - C_{i,q} + M - M*y_{i,j} >= p_{j,q} // = C_{j,q} - C_{i,q} - M*y_{i,j} >= p_{j,q} - M for i in 0..n { for (j, p_j) in p.iter().enumerate() { diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 6a28ad182..18d4532ff 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -3,7 +3,7 @@ //! The graph uses variant-level nodes: each node is a unique `(problem_name, variant)` pair. //! Nodes come from `VariantEntry` inventory, and `ReductionEntry` inventory supplies edges. //! -//! Edges come exclusively from `#[reduction]` registrations via `inventory::iter::`. +//! Edges combine registered constructions and their result mappings. //! //! This module implements: //! - Variant-level graph construction from `VariantEntry` and `ReductionEntry` inventory @@ -11,7 +11,7 @@ //! - JSON export for documentation and visualization use crate::rules::registry::{ - AggregateReduceFn, EdgeCapabilities, ParameterContractError, ReduceFn, ReductionEntry, + AggregateReduceFn, EdgeCapabilities, ParameterContractError, ReduceFn, ReductionParameterContract, }; use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; @@ -351,10 +351,10 @@ pub struct NeighborTree { /// Runtime graph of all registered reductions. /// /// Uses variant-level nodes: each node is a unique `(problem_name, variant)` pair. -/// All edges come from `inventory::iter::` registrations. +/// All edges come from the resolved reduction registry. /// /// The graph supports: -/// - Auto-discovery of reductions from `inventory::iter::` +/// - Auto-discovery of registered reductions and result mappings /// - Path finding by problem type or by name pub struct ReductionGraph { /// Graph with node indices as node data, edge weights as ReductionEdgeData. @@ -434,7 +434,7 @@ impl ReductionGraph { } // Phase 2: Build edges from ReductionEntry inventory - for entry in inventory::iter:: { + for entry in crate::rules::registry::reduction_entries() { let source_variant = Self::variant_to_map(&entry.source_variant()); let target_variant = Self::variant_to_map(&entry.target_variant()); @@ -1441,7 +1441,7 @@ impl ReductionGraph { src_variant: &BTreeMap, dst_variant: &BTreeMap, ) -> String { - for entry in inventory::iter:: { + for entry in crate::rules::registry::reduction_entries() { if entry.source_name == src_name && entry.target_name == dst_name { let entry_src = Self::variant_to_map(&entry.source_variant()); let entry_dst = Self::variant_to_map(&entry.target_variant()); @@ -1656,10 +1656,6 @@ impl ReductionGraph { input: &dyn Any, ) -> Result>, crate::rules::ReductionError> { let edge = &self.graph[edge_idx]; - if !Self::edge_supports_mode(edge, ReductionMode::Aggregate) { - return Ok(None); - } - let Some(reduce) = edge.reduce_aggregate_fn else { return Ok(None); }; diff --git a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index 07576fe0e..53e9b6eea 100644 --- a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -117,6 +117,20 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult + for ReductionHamiltonianCircuitToBiconnectivityAugmentation +{ + type Source = HamiltonianCircuit; + type Target = BiconnectivityAugmentation; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "num_vertices + 3", diff --git a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index abe6c3783..e9f03e42c 100644 --- a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -27,12 +27,34 @@ impl ReductionResult for ReductionHamiltonianCircuitToBottleneckTravelingSalesma &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult + for ReductionHamiltonianCircuitToBottleneckTravelingSalesman +{ + type Source = HamiltonianCircuit; + type Target = BottleneckTravelingSalesman; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or(value.0 == Some(1)) + } +} + #[reduction( transform = exact { num_vertices = "num_vertices", diff --git a/src/rules/hamiltoniancircuit_hamiltonianpath.rs b/src/rules/hamiltoniancircuit_hamiltonianpath.rs index 4d15d9227..1abd25bf4 100644 --- a/src/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -40,7 +40,13 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok({ let n = self.num_original_vertices; @@ -78,6 +84,18 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { + type Source = HamiltonianCircuit; + type Target = HamiltonianPath; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "num_vertices + 3", diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs index 34ae58de9..989ba505c 100644 --- a/src/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -39,6 +39,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLongestCircuit { type Source = HamiltonianCircuit; type Target = LongestCircuit; @@ -57,7 +58,6 @@ impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLon } #[reduction( - aggregate = custom, transform = exact { num_vertices = "num_vertices", num_edges = "num_edges", diff --git a/src/rules/hamiltoniancircuit_quadraticassignment.rs b/src/rules/hamiltoniancircuit_quadraticassignment.rs index 4cd1e5264..80574d405 100644 --- a/src/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -42,6 +42,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { type Source = HamiltonianCircuit; type Target = QuadraticAssignment; @@ -56,7 +57,6 @@ impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQua } #[reduction( - aggregate = custom, transform = upper_bound { num_facilities = "num_vertices + 3", num_locations = "num_vertices + 3", diff --git a/src/rules/hamiltoniancircuit_ruralpostman.rs b/src/rules/hamiltoniancircuit_ruralpostman.rs index e1e2d321d..56e70adc9 100644 --- a/src/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -50,7 +50,13 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } Ok({ // The target solution is edge multiplicities. @@ -103,6 +109,25 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToRuralPostman { + type Source = HamiltonianCircuit; + type Target = RuralPostman; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or( + self.n >= 3 + && value + .0 + .is_some_and(|cost| i128::from(cost) == 2 * self.n as i128), + ) + } +} + #[reduction( transform = exact { num_vertices = "2 * num_vertices", diff --git a/src/rules/hamiltoniancircuit_stackercrane.rs b/src/rules/hamiltoniancircuit_stackercrane.rs index bccffa686..5e512b0b0 100644 --- a/src/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -48,6 +48,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToStackerCrane { type Source = HamiltonianCircuit; type Target = StackerCrane; @@ -67,7 +68,6 @@ impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToSta } #[reduction( - aggregate = custom, transform = exact { num_vertices = "2 * num_vertices", num_arcs = "num_vertices", diff --git a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index e2592e77e..d15b7724e 100644 --- a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -31,7 +31,13 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok({ let n = self.n; @@ -74,9 +80,23 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult + for ReductionHamiltonianCircuitToStrongConnectivityAugmentation +{ + type Source = HamiltonianCircuit; + type Target = StrongConnectivityAugmentation; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { - num_vertices = "num_vertices", + transform = upper_bound { + num_vertices = "num_vertices + 2", num_arcs = "0", num_potential_arcs = "num_vertices * (num_vertices - 1)", } @@ -86,6 +106,14 @@ impl ReduceTo> for HamiltonianCircuit Result { let n = self.num_vertices(); + if n < 3 { + return Ok( + ReductionHamiltonianCircuitToStrongConnectivityAugmentation { + target: StrongConnectivityAugmentation::new(DirectedGraph::empty(2), vec![], 0), + n, + }, + ); + } let graph = DirectedGraph::empty(n); // Generate all ordered pairs (u, v) with u != v as candidate arcs. diff --git a/src/rules/hamiltoniancircuit_travelingsalesman.rs b/src/rules/hamiltoniancircuit_travelingsalesman.rs index 8e90e4ac8..a95ddefd9 100644 --- a/src/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -27,12 +27,36 @@ impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { + type Source = HamiltonianCircuit; + type Target = TravelingSalesman; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or( + value + .0 + .is_some_and(|cost| i128::from(cost) == self.target.num_vertices() as i128), + ) + } +} + #[reduction( transform = exact { num_vertices = "num_vertices", diff --git a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index c783517cb..bdf2d2838 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -25,12 +25,32 @@ impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } extract_hamiltonian_order(self.target.graph(), target_solution) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult + for ReductionHamiltonianPathToDegreeConstrainedSpanningTree +{ + type Source = HamiltonianPath; + type Target = DegreeConstrainedSpanningTree; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_vertices = "num_vertices", diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index 378545040..3e155b67f 100644 --- a/src/rules/hamiltonianpath_ilp.rs +++ b/src/rules/hamiltonianpath_ilp.rs @@ -4,7 +4,7 @@ //! - Binary x_{v,p}: vertex v at position p //! - Binary z_{(u,v),p,dir}: linearized product for edge (u,v) at consecutive positions //! - Assignment: each vertex in exactly one position, each position exactly one vertex -//! - Adjacency: exactly one graph edge between consecutive positions +//! - Adjacency: at least one graph edge between consecutive positions use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::HamiltonianPath; @@ -39,12 +39,30 @@ impl ReductionResult for ReductionHamiltonianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode(target_solution, self.num_vertices, self.num_vertices, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianPathToILP { + type Source = HamiltonianPath; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_vertices^2 + 2 * num_edges * num_vertices", @@ -95,14 +113,14 @@ impl ReduceTo> for HamiltonianPath { } } - // Adjacency: for each consecutive position pair p, exactly one edge + // At least one connecting edge; parallel edges may contribute more than one. for p in 0..n_pos { let mut terms = Vec::new(); for e in 0..m { terms.push((z_fwd_idx(e, p), 1)); terms.push((z_rev_idx(e, p), 1)); } - constraints.push(LinearConstraint::eq(terms, 1)); + constraints.push(LinearConstraint::ge(terms, 1)); } // Feasibility: no objective diff --git a/src/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/rules/hamiltonianpath_isomorphicspanningtree.rs index b95b555d4..18ab0b1ea 100644 --- a/src/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -32,12 +32,30 @@ impl ReductionResult for ReductionHPToIST { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(target_solution.to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionHPToIST { + type Source = HamiltonianPath; + type Target = IsomorphicSpanningTree; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_vertices = "num_vertices", diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index 0a9186ee1..ed0857867 100644 --- a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -68,6 +68,7 @@ impl ReductionResult for ReductionHPBTVToLP { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP { type Source = HamiltonianPathBetweenTwoVertices; type Target = LongestPath; @@ -87,7 +88,6 @@ impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP { } #[reduction( - aggregate = custom, transform = exact { num_vertices = "num_vertices", num_edges = "num_edges", diff --git a/src/rules/ilp_qubo.rs b/src/rules/ilp_qubo.rs index fbe885057..283212873 100644 --- a/src/rules/ilp_qubo.rs +++ b/src/rules/ilp_qubo.rs @@ -54,6 +54,7 @@ impl ReductionResult for ReductionILPToQUBO { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionILPToQUBO { type Source = ILP; type Target = QUBO; @@ -79,7 +80,6 @@ impl crate::rules::AggregateReductionResult for ReductionILPToQUBO { } #[reduction( - aggregate = custom, transform = unavailable { num_vars = "the slack-bit count depends on coefficient magnitudes and right-hand sides absent from the registered source parameters vector", } diff --git a/src/rules/integralflowbundles_ilp.rs b/src/rules/integralflowbundles_ilp.rs index d4220c41e..b915db678 100644 --- a/src/rules/integralflowbundles_ilp.rs +++ b/src/rules/integralflowbundles_ilp.rs @@ -27,12 +27,30 @@ impl ReductionResult for ReductionIFBToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(target_solution) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionIFBToILP { + type Source = IntegralFlowBundles; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_arcs", diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 006ecdb56..9aa8fcadb 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -26,12 +26,30 @@ impl ReductionResult for ReductionIFHAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(target_solution) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionIFHAToILP { + type Source = IntegralFlowHomologousArcs; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_arcs", diff --git a/src/rules/integralflowwithmultipliers_ilp.rs b/src/rules/integralflowwithmultipliers_ilp.rs index 6c700a3ba..220bf1229 100644 --- a/src/rules/integralflowwithmultipliers_ilp.rs +++ b/src/rules/integralflowwithmultipliers_ilp.rs @@ -26,12 +26,30 @@ impl ReductionResult for ReductionIFWMToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(target_solution) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionIFWMToILP { + type Source = IntegralFlowWithMultipliers; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_arcs", diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index e2d9e2ec4..726950e25 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -28,12 +28,30 @@ impl ReductionResult for ReductionISTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionISTToILP { + type Source = IsomorphicSpanningTree; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_vertices * num_vertices", diff --git a/src/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/rules/kclique_balancedcompletebipartitesubgraph.rs index b8285dbf6..61a36b76a 100644 --- a/src/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -38,7 +38,13 @@ impl ReductionResult for ReductionKCliqueToBCBS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok({ (0..self.num_original_vertices) @@ -48,8 +54,20 @@ impl ReductionResult for ReductionKCliqueToBCBS { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToBCBS { + type Source = KClique; + type Target = BalancedCompleteBipartiteSubgraph; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { + transform = upper_bound { left_size = "num_vertices + k * (k - 1) / 2", right_size = "num_edges + num_vertices - k", k = "num_vertices + k * (k - 1) / 2 - k", @@ -64,7 +82,16 @@ impl ReduceTo for KClique { fn reduce_to(&self) -> Result { let n = self.num_vertices(); let k = self.k(); - let edges: Vec<(usize, usize)> = self.graph().edges(); + // Clique membership depends on distinct non-loop edges, not multiplicity. + let edges: Vec<(usize, usize)> = self + .graph() + .edges() + .into_iter() + .filter(|&(u, v)| u != v) + .map(|(u, v)| (u.min(v), u.max(v))) + .collect::>() + .into_iter() + .collect(); let m = edges.len(); // C(k, 2) = k*(k-1)/2 — number of edges in a k-clique diff --git a/src/rules/kclique_conjunctivebooleanquery.rs b/src/rules/kclique_conjunctivebooleanquery.rs index e0c66f76b..b3bac6f04 100644 --- a/src/rules/kclique_conjunctivebooleanquery.rs +++ b/src/rules/kclique_conjunctivebooleanquery.rs @@ -38,7 +38,13 @@ impl ReductionResult for ReductionKCliqueToCBQ { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(KClique::::config_from_vertices( self.num_vertices, @@ -47,6 +53,18 @@ impl ReductionResult for ReductionKCliqueToCBQ { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToCBQ { + type Source = KClique; + type Target = ConjunctiveBooleanQuery; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { domain_size = "num_vertices", @@ -65,6 +83,10 @@ impl ReduceTo for KClique { // Build the single binary relation: for each edge {u,v}, include (u,v) and (v,u). let mut tuples = Vec::with_capacity(self.num_edges() * 2); for (u, v) in self.graph().edges() { + // A loop must not let distinct clique variables use the same vertex. + if u == v { + continue; + } tuples.push(vec![u, v]); tuples.push(vec![v, u]); } diff --git a/src/rules/kclique_ilp.rs b/src/rules/kclique_ilp.rs index 1c3e0f962..058fadbda 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -43,12 +43,30 @@ impl ReductionResult for ReductionKCliqueToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution.iter().map(|&value| value == 1).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToILP { + type Source = KClique; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_vertices", diff --git a/src/rules/kclique_subgraphisomorphism.rs b/src/rules/kclique_subgraphisomorphism.rs index f39f27561..4b7b3835e 100644 --- a/src/rules/kclique_subgraphisomorphism.rs +++ b/src/rules/kclique_subgraphisomorphism.rs @@ -38,7 +38,13 @@ impl ReductionResult for ReductionKCliqueToSubIso { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(KClique::::config_from_vertices( self.num_source_vertices, @@ -47,6 +53,18 @@ impl ReductionResult for ReductionKCliqueToSubIso { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToSubIso { + type Source = KClique; + type Target = SubgraphIsomorphism; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_host_vertices = "num_vertices", diff --git a/src/rules/kcoloring_bicliquecover.rs b/src/rules/kcoloring_bicliquecover.rs index 46be9a726..9c9a27606 100644 --- a/src/rules/kcoloring_bicliquecover.rs +++ b/src/rules/kcoloring_bicliquecover.rs @@ -121,6 +121,20 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKColoringToBicliqueCover { + type Source = KColoring; + type Target = BicliqueCover; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or(value.0.is_some()) + } +} + #[reduction( transform = upper_bound { left_size = "2 * num_vertices + 1", diff --git a/src/rules/kcoloring_casts.rs b/src/rules/kcoloring_casts.rs index 15b848cee..0e753cd1c 100644 --- a/src/rules/kcoloring_casts.rs +++ b/src/rules/kcoloring_casts.rs @@ -9,6 +9,9 @@ impl_variant_reduction!( KColoring, => , fields: [num_vertices, num_edges, num_colors], - aggregate: identity, |src| KColoring::with_k(src.graph().clone(), src.num_colors()) ); + +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult, KColoring> +); diff --git a/src/rules/kcoloring_clustering.rs b/src/rules/kcoloring_clustering.rs index 6311eb3af..84fb89954 100644 --- a/src/rules/kcoloring_clustering.rs +++ b/src/rules/kcoloring_clustering.rs @@ -32,7 +32,13 @@ impl ReductionResult for ReductionKColoringToClustering { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } Ok(target_solution[..self.source_num_vertices].to_vec()) } @@ -52,9 +58,21 @@ fn build_distances(graph: &SimpleGraph) -> Vec> { distances } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKColoringToClustering { + type Source = KColoring; + type Target = Clustering; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { - num_elements = "num_vertices", + transform = upper_bound { + num_elements = "num_vertices + 2", num_clusters = "num_colors", } )] @@ -62,6 +80,14 @@ impl ReduceTo for KColoring { type Result = ReductionKColoringToClustering; fn reduce_to(&self) -> Result { + if self.graph().edges().iter().any(|&(u, v)| u == v) { + // A loop is uncolorable. Two separated elements cannot share one + // diameter-zero cluster; the target diagonal remains zero. + return Ok(ReductionKColoringToClustering { + target: Clustering::new(vec![vec![0, 1], vec![1, 0]], 1, 0), + source_num_vertices: self.graph().num_vertices(), + }); + } Ok(ReductionKColoringToClustering { target: Clustering::new(build_distances(self.graph()), self.num_colors(), 0), source_num_vertices: self.graph().num_vertices(), diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs index bb3dd69bd..df14ee2b7 100644 --- a/src/rules/kcoloring_partitionintocliques.rs +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -14,6 +14,7 @@ use crate::variant::KN; #[derive(Debug, Clone)] pub struct ReductionKColoringToPartitionIntoCliques { target: PartitionIntoCliques, + source_num_vertices: usize, } impl ReductionResult for ReductionKColoringToPartitionIntoCliques { @@ -29,27 +30,57 @@ impl ReductionResult for ReductionKColoringToPartitionIntoCliques { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? + .0 + { + return Err(crate::rules::ExtractionError::invalid( + "target witness is not satisfying", + )); + } - Ok(target_solution.to_vec()) + Ok(target_solution[..self.source_num_vertices].to_vec()) + } +} + +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKColoringToPartitionIntoCliques { + type Source = KColoring; + type Target = PartitionIntoCliques; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } #[reduction( - transform = exact { - num_vertices = "num_vertices", - num_edges = "num_vertices * (num_vertices - 1) / 2 - num_edges", + transform = upper_bound { + num_vertices = "num_vertices + 2", + num_edges = "num_vertices * (num_vertices - 1) / 2", } )] impl ReduceTo> for KColoring { type Result = ReductionKColoringToPartitionIntoCliques; fn reduce_to(&self) -> Result { - let target = PartitionIntoCliques::new( - SimpleGraph::new(self.graph().num_vertices(), complement_edges(self.graph())), - self.num_colors(), - ); - Ok(ReductionKColoringToPartitionIntoCliques { target }) + let n = self.graph().num_vertices(); + let target = if self.graph().edges().iter().any(|&(u, v)| u == v) { + // A loop is uncolorable; two isolated vertices do not form one clique. + PartitionIntoCliques::new(SimpleGraph::empty(2), 1) + } else if n == 0 { + // The empty source is colorable; the target requires a nonempty graph. + PartitionIntoCliques::new(SimpleGraph::empty(1), 1) + } else { + PartitionIntoCliques::new( + SimpleGraph::new(n, complement_edges(self.graph())), + self.num_colors().min(n), + ) + }; + Ok(ReductionKColoringToPartitionIntoCliques { + target, + source_num_vertices: n, + }) } } diff --git a/src/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs index da2a2318e..3ae7884ec 100644 --- a/src/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -78,6 +78,18 @@ impl ReductionResult for ReductionKColoringToTDCS { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKColoringToTDCS { + type Source = KColoring; + type Target = TwoDimensionalConsecutiveSets; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { alphabet_size = "num_vertices + num_edges + 3", diff --git a/src/rules/ksatisfiability_acyclicpartition.rs b/src/rules/ksatisfiability_acyclicpartition.rs index 257516807..8aee3c217 100644 --- a/src/rules/ksatisfiability_acyclicpartition.rs +++ b/src/rules/ksatisfiability_acyclicpartition.rs @@ -49,6 +49,20 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToAcyclicPartition { + type Source = KSatisfiability; + type Target = AcyclicPartition; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "(9 * num_clauses^2 + 3 * num_clauses + 6) / 2", diff --git a/src/rules/ksatisfiability_bicliquecover.rs b/src/rules/ksatisfiability_bicliquecover.rs index 5caf355a0..504eb2853 100644 --- a/src/rules/ksatisfiability_bicliquecover.rs +++ b/src/rules/ksatisfiability_bicliquecover.rs @@ -235,6 +235,18 @@ fn free_edge_budget(ell: usize, m: usize) -> Option { // satisfy n <= 4(s+1), M <= m+4(s+1), ell <= s+1, ceil(log2 M) <= M. // Hence each partition is <= 31s+5m+39 and rank <= 14s+2m+22. // The declared coarser bounds also cover the fixed YES and NO targets. +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionKSatisfiabilityToBicliqueCover { + type Source = KSatisfiability; + type Target = BicliqueCover; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or(value.0.is_some()) + } +} + #[reduction( transform = upper_bound { left_size = "32 * num_vars + 8 * num_clauses + 48", diff --git a/src/rules/ksatisfiability_casts.rs b/src/rules/ksatisfiability_casts.rs index 659c4d5b9..898c324fa 100644 --- a/src/rules/ksatisfiability_casts.rs +++ b/src/rules/ksatisfiability_casts.rs @@ -8,7 +8,6 @@ impl_variant_reduction!( KSatisfiability, => , fields: [num_vars, num_clauses, num_literals], - aggregate: identity, |src| KSatisfiability::new_allow_less(src.num_vars(), src.clauses().to_vec()) ); @@ -16,6 +15,12 @@ impl_variant_reduction!( KSatisfiability, => , fields: [num_vars, num_clauses, num_literals], - aggregate: identity, |src| KSatisfiability::new_allow_less(src.num_vars(), src.clauses().to_vec()) ); + +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult, KSatisfiability> +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult, KSatisfiability> +); diff --git a/src/rules/ksatisfiability_cyclicordering.rs b/src/rules/ksatisfiability_cyclicordering.rs index 704a26f66..f7f810c34 100644 --- a/src/rules/ksatisfiability_cyclicordering.rs +++ b/src/rules/ksatisfiability_cyclicordering.rs @@ -162,6 +162,20 @@ fn normalize( }) } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToCyclicOrdering { + type Source = KSatisfiability; + type Target = CyclicOrdering; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_elements = "3 * num_vars + 26 * num_clauses + 3", diff --git a/src/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/rules/ksatisfiability_decisionminimumvertexcover.rs index 3bb329f47..2e5fa8f3b 100644 --- a/src/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -36,6 +36,18 @@ impl ReductionResult for Reduction3SATToDecisionMVC { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToDecisionMVC { + type Source = KSatisfiability; + type Target = Decision>; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_vertices = "2 * num_vars + 3 * num_clauses", diff --git a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs index a9e5d8bcb..c952b41ee 100644 --- a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -175,7 +175,13 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ self.variable_paths @@ -186,6 +192,20 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { + type Source = KSatisfiability; + type Target = DirectedTwoCommodityIntegralFlow; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_vertices = "6 * num_vars + 2 * num_literals + num_clauses + 4", diff --git a/src/rules/ksatisfiability_feasibleregisterassignment.rs b/src/rules/ksatisfiability_feasibleregisterassignment.rs index 23176c5ed..837dbc233 100644 --- a/src/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/rules/ksatisfiability_feasibleregisterassignment.rs @@ -95,6 +95,20 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToFeasibleRegisterAssignment { + type Source = KSatisfiability; + type Target = FeasibleRegisterAssignment; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "2 * num_vars + 12 * num_clauses", diff --git a/src/rules/ksatisfiability_kclique.rs b/src/rules/ksatisfiability_kclique.rs index 19c92416b..7b63b5a2a 100644 --- a/src/rules/ksatisfiability_kclique.rs +++ b/src/rules/ksatisfiability_kclique.rs @@ -54,6 +54,20 @@ impl ReductionResult for Reduction3SATToKClique { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToKClique { + type Source = KSatisfiability; + type Target = KClique; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "3 * num_clauses + 1", diff --git a/src/rules/ksatisfiability_kernel.rs b/src/rules/ksatisfiability_kernel.rs index d426e0025..01a844bb0 100644 --- a/src/rules/ksatisfiability_kernel.rs +++ b/src/rules/ksatisfiability_kernel.rs @@ -48,6 +48,20 @@ impl ReductionResult for Reduction3SatToKernel { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SatToKernel { + type Source = KSatisfiability; + type Target = Kernel; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "2 * num_vars + 3 * num_clauses", diff --git a/src/rules/ksatisfiability_minimumvertexcover.rs b/src/rules/ksatisfiability_minimumvertexcover.rs index 38d133bbe..db5ef064a 100644 --- a/src/rules/ksatisfiability_minimumvertexcover.rs +++ b/src/rules/ksatisfiability_minimumvertexcover.rs @@ -23,6 +23,7 @@ use crate::variant::K3; pub struct Reduction3SATToMVC { target: MinimumVertexCover, source_num_vars: usize, + cover_bound: i64, } impl ReductionResult for Reduction3SATToMVC { @@ -44,7 +45,13 @@ impl ReductionResult for Reduction3SATToMVC { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } Ok({ (0..self.source_num_vars) @@ -57,6 +64,20 @@ impl ReductionResult for Reduction3SATToMVC { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToMVC { + type Source = KSatisfiability; + type Target = MinimumVertexCover; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or(value.0 == Some(self.cover_bound)) + } +} + #[reduction( transform = exact { num_vertices = "2 * num_vars + 3 * num_clauses", @@ -89,7 +110,15 @@ impl ReduceTo> for KSatisfiability { edges.push((base, base + 2)); // Communication edges: connect triangle vertex k to the literal vertex - for (k, &lit) in clause.literals.iter().enumerate() { + for k in 0..3 { + if clause.literals.is_empty() { + // All three clause vertices must be selected, exceeding + // the two-per-clause bound for an empty (false) clause. + edges.push((base + k, base + k)); + continue; + } + // Repeating a literal pads a short clause without changing it. + let lit = clause.literals[k % clause.literals.len()]; let var_idx = lit.unsigned_abs() as usize - 1; // 0-indexed variable let literal_vertex = if lit > 0 { 2 * var_idx // positive literal vertex @@ -107,6 +136,10 @@ impl ReduceTo> for KSatisfiability { Ok(Reduction3SATToMVC { target, source_num_vars: n, + cover_bound: >>::exact_i64( + n + 2 * m, + "computing the cover bound", + )?, }) } } diff --git a/src/rules/ksatisfiability_monochromatictriangle.rs b/src/rules/ksatisfiability_monochromatictriangle.rs index 61a8de78a..d0f8fa8db 100644 --- a/src/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/rules/ksatisfiability_monochromatictriangle.rs @@ -55,7 +55,13 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } let nae_solution = (0..self.nae_reduction.target_problem().num_vars()) .map(|index| target_solution[2 * index]) .collect(); @@ -65,6 +71,20 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToMonochromaticTriangle { + type Source = KSatisfiability; + type Target = MonochromaticTriangle; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "16 * num_vars + 40 * num_clauses + 16", diff --git a/src/rules/ksatisfiability_oneinthreesatisfiability.rs b/src/rules/ksatisfiability_oneinthreesatisfiability.rs index 3c9faa76c..3fd9243b7 100644 --- a/src/rules/ksatisfiability_oneinthreesatisfiability.rs +++ b/src/rules/ksatisfiability_oneinthreesatisfiability.rs @@ -46,6 +46,20 @@ impl ReductionResult for Reduction3SATToOneInThreeSAT { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToOneInThreeSAT { + type Source = KSatisfiability; + type Target = OneInThreeSatisfiability; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vars = "num_vars + 2 + 6 * num_clauses", diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index 36bfb650c..f958636c0 100644 --- a/src/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/rules/ksatisfiability_preemptivescheduling.rs @@ -351,6 +351,7 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for Reduction3SATToPreemptiveScheduling { type Source = KSatisfiability; type Target = PreemptiveScheduling; @@ -369,7 +370,6 @@ impl crate::rules::AggregateReductionResult for Reduction3SATToPreemptiveSchedul } #[reduction( - aggregate = custom, transform = upper_bound { num_tasks = "(2 * num_vars + 3 + 6 * num_clauses) * (num_vars + 3)", num_processors = "2 * num_vars + 3 + 6 * num_clauses", diff --git a/src/rules/ksatisfiability_quadraticcongruences.rs b/src/rules/ksatisfiability_quadraticcongruences.rs index 15320498f..f3c5aa650 100644 --- a/src/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/rules/ksatisfiability_quadraticcongruences.rs @@ -315,6 +315,20 @@ fn witness_config_for_assignment( Some(witness_value_from_alphas(&alphas, &construction.thetas)) } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticCongruences { + type Source = KSatisfiability; + type Target = QuadraticCongruences; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { bit_length_a = "64 * (2 * num_clauses + num_vars + 1)^2 + 3 * num_clauses + 4", diff --git a/src/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/rules/ksatisfiability_quadraticdiophantineequations.rs index e8e4053c8..3a21db24f 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -32,7 +32,13 @@ impl ReductionResult for Reduction3SATToQuadraticDiophantineEquations { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ self.congruence_reduction @@ -62,6 +68,20 @@ fn translate_congruence(source: &QuadraticCongruences) -> QuadraticDiophantineEq QuadraticDiophantineEquations::new(BigUint::one(), source.b().clone(), c) } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticDiophantineEquations { + type Source = KSatisfiability; + type Target = QuadraticDiophantineEquations; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { bit_length_a = "1", diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index 4a7b8b21c..d653c0ddd 100644 --- a/src/rules/ksatisfiability_qubo.rs +++ b/src/rules/ksatisfiability_qubo.rs @@ -326,6 +326,7 @@ fn build_qubo_matrix( Ok((matrix, constant)) } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO { type Source = KSatisfiability; type Target = QUBO; @@ -337,6 +338,7 @@ impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO { type Source = KSatisfiability; type Target = QUBO; @@ -349,7 +351,6 @@ impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO { } #[reduction( - aggregate = custom, transform = exact { num_vars = "num_vars", } @@ -378,7 +379,6 @@ impl ReduceTo> for KSatisfiability { } #[reduction( - aggregate = custom, transform = exact { num_vars = "num_vars + num_clauses", } diff --git a/src/rules/ksatisfiability_registersufficiency.rs b/src/rules/ksatisfiability_registersufficiency.rs index 8cde3e058..763660ae6 100644 --- a/src/rules/ksatisfiability_registersufficiency.rs +++ b/src/rules/ksatisfiability_registersufficiency.rs @@ -323,6 +323,18 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToRegisterSufficiency { + type Source = KSatisfiability; + type Target = RegisterSufficiency; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_vertices = "3 * num_vars^2 + 11 * num_vars + 4 * num_clauses + 4", diff --git a/src/rules/ksatisfiability_simultaneousincongruences.rs b/src/rules/ksatisfiability_simultaneousincongruences.rs index 785e808b8..24e55a166 100644 --- a/src/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/rules/ksatisfiability_simultaneousincongruences.rs @@ -30,7 +30,13 @@ impl ReductionResult for Reduction3SATToSimultaneousIncongruences { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ let x = u64::try_from(*target_solution).map_err(|_| { @@ -172,6 +178,20 @@ fn ensure_prime_product_fits_target( Ok(()) } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToSimultaneousIncongruences { + type Source = KSatisfiability; + type Target = SimultaneousIncongruences; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = unavailable { num_pairs = "the number of residue pairs depends on the first num_vars odd primes and is not expressible in the size-expression language", diff --git a/src/rules/ksatisfiability_subsetsum.rs b/src/rules/ksatisfiability_subsetsum.rs index 3c62e5d14..c915bacf0 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -39,7 +39,13 @@ impl ReductionResult for Reduction3SATToSubsetSum { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ // Variable integers are the first 2n elements in 0-based indexing: @@ -64,6 +70,20 @@ fn digits_to_integer(digits: &[u8]) -> BigUint { value } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToSubsetSum { + type Source = KSatisfiability; + type Target = SubsetSum; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_elements = "2 * num_vars + 2 * num_clauses" } )] diff --git a/src/rules/ksatisfiability_timetabledesign.rs b/src/rules/ksatisfiability_timetabledesign.rs index 112eabbb6..6cfdf3427 100644 --- a/src/rules/ksatisfiability_timetabledesign.rs +++ b/src/rules/ksatisfiability_timetabledesign.rs @@ -569,7 +569,7 @@ fn build_layout(source: &KSatisfiability) -> ReductionLayout { debug_assert!(colors.iter().all(|&color| color != usize::MAX)); let edge = match colors.len() { - 1 => add_direct_clause_edge(&mut graph, &all_colors, center, clause_vertex, colors), + 0 | 1 => add_direct_clause_edge(&mut graph, &all_colors, center, clause_vertex, colors), 2 => add_two_list_edge( &mut graph, &all_colors, @@ -579,7 +579,7 @@ fn build_layout(source: &KSatisfiability) -> ReductionLayout { colors[1], ), 3 => add_direct_clause_edge(&mut graph, &all_colors, center, clause_vertex, colors), - len => panic!("expected clause size 1, 2, or 3 after normalization, got {len}"), + len => panic!("expected at most three literals after normalization, got {len}"), }; clause_encodings.push(ClauseEncoding { edge }); @@ -748,7 +748,13 @@ impl ReductionResult for Reduction3SATToTimetableDesign { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target timetable is not feasible", + )); + } Ok({ let num_periods = self.target.num_periods(); @@ -786,11 +792,23 @@ impl ReductionResult for Reduction3SATToTimetableDesign { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for Reduction3SATToTimetableDesign { + type Source = KSatisfiability; + type Target = TimetableDesign; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { - num_periods = "4 * num_literals", - num_craftsmen = "24 * num_literals + 1", - num_tasks = "24 * num_literals + 1", + num_periods = "4 * num_literals + 4", + num_craftsmen = "24 * num_literals + num_clauses + 1", + num_tasks = "24 * num_literals + num_clauses + 1", } )] impl ReduceTo for KSatisfiability { diff --git a/src/rules/maximumindependentset_casts.rs b/src/rules/maximumindependentset_casts.rs index ff7d07906..1e7ac32b1 100644 --- a/src/rules/maximumindependentset_casts.rs +++ b/src/rules/maximumindependentset_casts.rs @@ -11,7 +11,6 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( src.graph().try_to_unit_disk_graph().map_err( crate::rules::ReductionError::construction::< @@ -26,7 +25,6 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( src.graph().try_to_unit_disk_graph().map_err( crate::rules::ReductionError::construction::< @@ -41,7 +39,6 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( SimpleGraph::new(src.num_vertices(), Graph::edges(src.graph())), src.weights().to_vec()) @@ -52,7 +49,6 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( src.graph().try_to_unit_disk_graph().map_err( crate::rules::ReductionError::construction::< @@ -67,7 +63,6 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( SimpleGraph::new(src.num_vertices(), Graph::edges(src.graph())), src.weights().to_vec()) @@ -78,7 +73,6 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), vec![1_i64; src.num_vertices()]) ); @@ -120,7 +114,6 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), vec![1_i64; src.num_vertices()]) ); @@ -129,7 +122,55 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), vec![1_i64; src.num_vertices()]) ); + +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult< + MaximumIndependentSet, + MaximumIndependentSet, + > +); diff --git a/src/rules/maximumsetpacking_casts.rs b/src/rules/maximumsetpacking_casts.rs index 04314c4df..73b4eada2 100644 --- a/src/rules/maximumsetpacking_casts.rs +++ b/src/rules/maximumsetpacking_casts.rs @@ -9,7 +9,6 @@ impl_variant_reduction!( MaximumSetPacking, => , fields: [num_sets, universe_size], - aggregate: identity, |src| MaximumSetPacking::with_weights( src.sets().to_vec(), vec![1_i64; src.num_sets()]) @@ -47,3 +46,7 @@ impl_variant_reduction!( #[cfg(test)] #[path = "../unit_tests/rules/maximumsetpacking_casts.rs"] mod tests; + +crate::register_aggregate_reduction!( + crate::rules::VariantReductionResult, MaximumSetPacking> +); diff --git a/src/rules/minimumvertexcover_comparativecontainment.rs b/src/rules/minimumvertexcover_comparativecontainment.rs index 4e08fc68a..46aa8c080 100644 --- a/src/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/rules/minimumvertexcover_comparativecontainment.rs @@ -45,6 +45,18 @@ impl ReductionResult for ReductionDecisionMVCToComparativeContainment { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionDecisionMVCToComparativeContainment { + type Source = Decision>; + type Target = ComparativeContainment; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { universe_size = "num_vertices", diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 7abb35be4..fea4372ce 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -148,6 +148,10 @@ pub(crate) mod subsetsum_integerknapsack; pub(crate) mod subsetsum_partition; #[cfg(test)] pub(crate) mod test_helpers; + +#[cfg(test)] +#[path = "../unit_tests/rules/aggregate_contracts.rs"] +mod aggregate_contracts; pub(crate) mod threedimensionalmatching_minimumweightdecoding; pub(crate) mod threedimensionalmatching_threepartition; pub(crate) mod threepartition_resourceconstrainedscheduling; @@ -612,13 +616,11 @@ macro_rules! impl_variant_reduction { ($problem:ident, < $($src_param:ty),+ > => < $($dst_param:ty),+ >, fields: [$($field:ident),+], - $(aggregate: $aggregate:ident,)? |$src:ident| $body:expr) => { #[$crate::reduction( transform = exact { $($field = $field),+ } - $(, aggregate = $aggregate)? )] impl $crate::rules::ReduceTo<$problem<$($dst_param),+>> for $problem<$($src_param),+> diff --git a/src/rules/monochromatictriangle_ilp.rs b/src/rules/monochromatictriangle_ilp.rs index 8a83cb1d6..d4ce22411 100644 --- a/src/rules/monochromatictriangle_ilp.rs +++ b/src/rules/monochromatictriangle_ilp.rs @@ -29,12 +29,30 @@ impl ReductionResult for ReductionMonochromaticTriangleToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution.iter().map(|&value| value == 1).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionMonochromaticTriangleToILP { + type Source = MonochromaticTriangle; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_edges", diff --git a/src/rules/multiplechoicebranching_ilp.rs b/src/rules/multiplechoicebranching_ilp.rs index cba6f76cd..edf398170 100644 --- a/src/rules/multiplechoicebranching_ilp.rs +++ b/src/rules/multiplechoicebranching_ilp.rs @@ -23,7 +23,13 @@ impl ReductionResult for ReductionMultipleChoiceBranchingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution[..self.num_arcs] .iter() .map(|&selected| selected == 1) @@ -31,6 +37,18 @@ impl ReductionResult for ReductionMultipleChoiceBranchingToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionMultipleChoiceBranchingToILP { + type Source = MultipleChoiceBranching; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_arcs + num_vertices", diff --git a/src/rules/multiprocessorscheduling_ilp.rs b/src/rules/multiprocessorscheduling_ilp.rs index c3d8ed3cd..79484d7db 100644 --- a/src/rules/multiprocessorscheduling_ilp.rs +++ b/src/rules/multiprocessorscheduling_ilp.rs @@ -37,7 +37,13 @@ impl ReductionResult for ReductionMSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -48,6 +54,18 @@ impl ReductionResult for ReductionMSToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionMSToILP { + type Source = MultiprocessorScheduling; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_tasks * num_processors", diff --git a/src/rules/naesatisfiability_ilp.rs b/src/rules/naesatisfiability_ilp.rs index 1c57fc3a5..2cd33986c 100644 --- a/src/rules/naesatisfiability_ilp.rs +++ b/src/rules/naesatisfiability_ilp.rs @@ -30,12 +30,30 @@ impl ReductionResult for ReductionNAESATToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution.iter().map(|&value| value == 1).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionNAESATToILP { + type Source = NAESatisfiability; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_vars", diff --git a/src/rules/naesatisfiability_maxcut.rs b/src/rules/naesatisfiability_maxcut.rs index 55e8e81c7..106675773 100644 --- a/src/rules/naesatisfiability_maxcut.rs +++ b/src/rules/naesatisfiability_maxcut.rs @@ -58,6 +58,7 @@ impl ReductionResult for ReductionNAESATToMaxCut { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionNAESATToMaxCut { type Source = NAESatisfiability; type Target = MaxCut; @@ -144,7 +145,6 @@ fn nae_maxcut_parameters( } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "2 * (num_vars + num_literals - 2 * num_clauses)", num_edges = "num_vars + 4 * num_literals - 7 * num_clauses", diff --git a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs index 4d3949f45..094fab7c1 100644 --- a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -346,6 +346,7 @@ fn build_layout( }) } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { type Source = NAESatisfiability; type Target = PartitionIntoPerfectMatchings; @@ -360,7 +361,6 @@ impl crate::rules::AggregateReductionResult for ReductionNAESATToPartitionIntoPe } #[reduction( - aggregate = identity, transform = upper_bound { num_vertices = "4 * num_vars + 20 * num_literals - 24 * num_clauses", num_edges = "3 * num_vars + 24 * num_literals - 27 * num_clauses", diff --git a/src/rules/naesatisfiability_setsplitting.rs b/src/rules/naesatisfiability_setsplitting.rs index 854e7ed69..8ee68267f 100644 --- a/src/rules/naesatisfiability_setsplitting.rs +++ b/src/rules/naesatisfiability_setsplitting.rs @@ -29,7 +29,13 @@ impl ReductionResult for ReductionNAESATToSetSplitting { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok(target_solution[..self.num_source_variables].to_vec()) } @@ -44,6 +50,18 @@ fn literal_element_index(lit: i64, num_vars: usize) -> usize { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionNAESATToSetSplitting { + type Source = NAESatisfiability; + type Target = SetSplitting; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { universe_size = "2 * num_vars", diff --git a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 30729b498..45f717b3e 100644 --- a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -30,7 +30,13 @@ impl ReductionResult for ReductionN3DMToNMTS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ let mut x_indices_by_pair_sum: BTreeMap> = BTreeMap::new(); @@ -77,6 +83,18 @@ fn checked_target_sum(bound: i64, w_size: i64) -> Result { .ok_or("computing a derived target sum overflowed") } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionN3DMToNMTS { + type Source = Numerical3DimensionalMatching; + type Target = NumericalMatchingWithTargetSums; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_pairs = "num_groups", diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index 3f658a8bf..a53646abc 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -48,7 +48,13 @@ impl ReductionResult for ReductionNMTSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let mut assignment = vec![0usize; self.m]; @@ -62,6 +68,18 @@ impl ReductionResult for ReductionNMTSToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionNMTSToILP { + type Source = NumericalMatchingWithTargetSums; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_pairs * num_pairs * num_pairs", @@ -85,7 +103,7 @@ impl ReduceTo> for NumericalMatchingWithTargetSums { for (i, &sxi) in sx.iter().enumerate() { for (j, &syj) in sy.iter().enumerate() { for (k, &tk) in targets.iter().enumerate() { - if sxi + syj == tk { + if i128::from(sxi) + i128::from(syj) == i128::from(tk) { triples.push(CompatibleTriple { i, j, k }); } } diff --git a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 1efcd0b0b..f6c9f00f5 100644 --- a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -48,6 +48,20 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult + for ReductionOptimalLinearArrangementToConsecutiveOnesMatrixAugmentation +{ + type Source = Decision>; + type Target = ConsecutiveOnesMatrixAugmentation; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_rows = "num_edges + 3", diff --git a/src/rules/partition_binpacking.rs b/src/rules/partition_binpacking.rs index 20301e8a5..37bdfc7fc 100644 --- a/src/rules/partition_binpacking.rs +++ b/src/rules/partition_binpacking.rs @@ -20,6 +20,7 @@ use crate::rules::traits::{ReduceTo, ReductionResult}; #[derive(Debug, Clone)] pub struct ReductionPartitionToBinPacking { target: BinPacking, + source_sum: i64, } impl ReductionResult for ReductionPartitionToBinPacking { @@ -34,7 +35,13 @@ impl ReductionResult for ReductionPartitionToBinPacking { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } Ok({ // BinPacking may use any bin indices (0..n-1). Remap the two distinct @@ -46,6 +53,20 @@ impl ReductionResult for ReductionPartitionToBinPacking { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToBinPacking { + type Source = Partition; + type Target = BinPacking; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or(self.source_sum % 2 == 0 && value.0 == Some(2)) + } +} + #[reduction( transform = exact { num_items = "num_elements", @@ -55,9 +76,11 @@ impl ReduceTo> for Partition { fn reduce_to(&self) -> Result { let sizes = self.sizes().to_vec(); - let capacity = self.total_sum() / 2; + // A singleton of size one is NO, but BinPacking requires positive capacity. + let capacity = (self.total_sum() / 2).max(1); Ok(ReductionPartitionToBinPacking { + source_sum: self.total_sum(), target: BinPacking::new(sizes, capacity).map_err(|cause| { crate::rules::ReductionError::construction::>(cause) })?, diff --git a/src/rules/partition_cosineproductintegration.rs b/src/rules/partition_cosineproductintegration.rs index 1698a5334..6e10f85d0 100644 --- a/src/rules/partition_cosineproductintegration.rs +++ b/src/rules/partition_cosineproductintegration.rs @@ -32,12 +32,30 @@ impl ReductionResult for ReductionPartitionToCPI { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok(target_solution.to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToCPI { + type Source = Partition; + type Target = CosineProductIntegration; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_coefficients = "num_elements", diff --git a/src/rules/partition_integralflowwithmultipliers.rs b/src/rules/partition_integralflowwithmultipliers.rs index 8c1a96391..319d61bb9 100644 --- a/src/rules/partition_integralflowwithmultipliers.rs +++ b/src/rules/partition_integralflowwithmultipliers.rs @@ -36,7 +36,15 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { "the fixed infeasible target instance has no extractable witness", ) })?; - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = crate::rules::traits::validate_target_solution( + self.target_problem(), + target_solution, + )?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } target_solution[..item_arc_count] .iter() @@ -46,8 +54,20 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { + type Source = Partition; + type Target = IntegralFlowWithMultipliers; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vertices = "num_elements + 3", num_arcs = "2 * num_elements + 1", }, diff --git a/src/rules/partition_knapsack.rs b/src/rules/partition_knapsack.rs index 6ea901cca..083107149 100644 --- a/src/rules/partition_knapsack.rs +++ b/src/rules/partition_knapsack.rs @@ -8,6 +8,7 @@ use crate::rules::traits::{ReduceTo, ReductionResult}; #[derive(Debug, Clone)] pub struct ReductionPartitionToKnapsack { target: Knapsack, + source_sum: i64, } impl ReductionResult for ReductionPartitionToKnapsack { @@ -22,12 +23,32 @@ impl ReductionResult for ReductionPartitionToKnapsack { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } Ok(target_solution.to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToKnapsack { + type Source = Partition; + type Target = Knapsack; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Max) -> crate::types::Or { + crate::types::Or(self.source_sum % 2 == 0 && value.0 == Some(self.source_sum / 2)) + } +} + #[reduction( transform = exact { num_items = "num_elements" }, unavailable = { @@ -43,6 +64,7 @@ impl ReduceTo for Partition { let capacity = self.total_sum() / 2; Ok(ReductionPartitionToKnapsack { + source_sum: self.total_sum(), target: Knapsack::new(weights, values, capacity), }) } diff --git a/src/rules/partition_multiprocessorscheduling.rs b/src/rules/partition_multiprocessorscheduling.rs index 9e35ce1d2..d99497925 100644 --- a/src/rules/partition_multiprocessorscheduling.rs +++ b/src/rules/partition_multiprocessorscheduling.rs @@ -36,7 +36,13 @@ impl ReductionResult for ReductionPartitionToMPS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok(target_solution .iter() @@ -45,6 +51,18 @@ impl ReductionResult for ReductionPartitionToMPS { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToMPS { + type Source = Partition; + type Target = MultiprocessorScheduling; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_tasks = "num_elements", diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index f3126f0c5..e584876f0 100644 --- a/src/rules/partition_openshopscheduling.rs +++ b/src/rules/partition_openshopscheduling.rs @@ -78,6 +78,7 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopScheduling { type Source = Partition; type Target = OpenShopScheduling; @@ -92,7 +93,6 @@ impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopSche } #[reduction( - aggregate = custom, transform = exact { num_jobs = "num_elements + 1", num_machines = "3", diff --git a/src/rules/partition_productionplanning.rs b/src/rules/partition_productionplanning.rs index 3dc8856fb..4bd918c52 100644 --- a/src/rules/partition_productionplanning.rs +++ b/src/rules/partition_productionplanning.rs @@ -21,7 +21,13 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok(target_solution[..self.target.num_periods() - 1] .iter() @@ -30,6 +36,18 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToProductionPlanning { + type Source = Partition; + type Target = ProductionPlanning; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_periods = "num_elements + 1", diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index 72827a5d0..913173f36 100644 --- a/src/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/rules/partition_sequencingtominimizetardytaskweight.rs @@ -52,6 +52,7 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { @@ -69,7 +70,6 @@ impl crate::rules::AggregateReductionResult } #[reduction( - aggregate = custom, transform = exact { num_tasks = "num_elements", })] diff --git a/src/rules/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs index 57f6c0d14..1432ab05e 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -30,7 +30,13 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } if target_solution.len() != self.source_n { return Err(crate::rules::ExtractionError::invalid(format!( @@ -43,8 +49,20 @@ impl ReductionResult for ReductionPartitionToSubsetSum { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToSubsetSum { + type Source = Partition; + type Target = SubsetSum; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_elements = "num_elements", })] impl ReduceTo for Partition { diff --git a/src/rules/partition_sumofsquarespartition.rs b/src/rules/partition_sumofsquarespartition.rs index 8eb491c9b..18f3d8432 100644 --- a/src/rules/partition_sumofsquarespartition.rs +++ b/src/rules/partition_sumofsquarespartition.rs @@ -8,10 +8,8 @@ //! which case `Partition::evaluate(extracted_witness) = Or(true)`. //! //! The target `SumOfSquaresPartition` model has no `J` bound field — it is a -//! pure minimisation (`Value = Min`). We therefore implement the rule in -//! the witness-style form used by `partition_multiprocessorscheduling.rs`: -//! the optimal target witness directly recovers the source YES/NO answer via -//! `source.evaluate(extract_solution(target_witness))`. +//! pure minimisation (`Value = Min`). The completed optimum maps to YES/NO +//! by testing `2 * optimum == S^2`; only a balanced target witness is decoded. //! //! Solution extraction is the identity (group assignment in the target is the //! subset assignment in the source). Small inputs with `|A| < 2` use a @@ -29,9 +27,9 @@ use crate::rules::traits::{ReduceTo, ReductionResult}; pub struct ReductionPartitionToSumOfSquaresPartition { target: SumOfSquaresPartition, /// Number of elements in the original Partition instance. - /// Used to return a correctly-sized NO witness when the sentinel path is - /// taken (i.e. `source_n < 2`). + /// Distinguishes the singleton NO case from the two-group construction. source_n: usize, + source_sum: i64, } impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { @@ -49,7 +47,13 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } if target_solution.len() != self.target.num_elements() { return Err(crate::rules::ExtractionError::invalid(format!( "expected {} target group assignments, got {}", @@ -65,6 +69,25 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionToSumOfSquaresPartition { + type Source = Partition; + type Target = SumOfSquaresPartition; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or( + self.source_n >= 2 + && value + .0 + .is_some_and(|cost| 2 * i128::from(cost) == i128::from(self.source_sum).pow(2)), + ) + } +} + #[reduction( transform = exact { num_elements = "num_elements", @@ -82,12 +105,14 @@ impl ReduceTo for Partition { // Partition is always NO (a single positive element cannot be // partitioned into two equal-sum subsets). return Ok(ReductionPartitionToSumOfSquaresPartition { + source_sum: self.total_sum(), target: SumOfSquaresPartition::new(vec![1, 1], 2), source_n, }); } Ok(ReductionPartitionToSumOfSquaresPartition { + source_sum: self.total_sum(), target: SumOfSquaresPartition::new(self.sizes().to_vec(), 2), source_n, }) diff --git a/src/rules/partitionintocliques_ilp.rs b/src/rules/partitionintocliques_ilp.rs index 843c6a905..3810581a7 100644 --- a/src/rules/partitionintocliques_ilp.rs +++ b/src/rules/partitionintocliques_ilp.rs @@ -25,7 +25,13 @@ impl ReductionResult for ReductionPartitionIntoCliquesToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } (0..self.num_vertices) .map(|vertex| { @@ -41,6 +47,18 @@ impl ReductionResult for ReductionPartitionIntoCliquesToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToILP { + type Source = PartitionIntoCliques; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_vertices^2", diff --git a/src/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/rules/partitionintocliques_minimumcoveringbycliques.rs index b3078e508..155c3a5a4 100644 --- a/src/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -213,6 +213,7 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques { @@ -229,7 +230,6 @@ impl crate::rules::AggregateReductionResult } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "2 * num_vertices + 4 * num_edges + 4", num_edges = "(num_vertices + 2 * num_edges)^2 + 4 * num_vertices + 14 * num_edges + 2", diff --git a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index 193a5d681..6e6f7507c 100644 --- a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -37,17 +37,35 @@ impl ReductionResult for ReductionPPL2ToBCSF { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok(target_solution.to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPPL2ToBCSF { + type Source = PartitionIntoPathsOfLength2; + type Target = BoundedComponentSpanningForest; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vertices = "num_vertices", num_edges = "num_edges", - max_components = "num_vertices / 3", + max_components = "num_vertices / 3 + 1", } )] impl ReduceTo> diff --git a/src/rules/partitionintopathsoflength2_ilp.rs b/src/rules/partitionintopathsoflength2_ilp.rs index f652ba758..280d9de85 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -48,7 +48,13 @@ impl ReductionResult for ReductionPIPL2ToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -59,6 +65,18 @@ impl ReductionResult for ReductionPIPL2ToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPIPL2ToILP { + type Source = PartitionIntoPathsOfLength2; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_vertices^2 + num_edges * num_vertices", @@ -74,7 +92,15 @@ impl ReduceTo> for PartitionIntoPathsOfLength2 { fn reduce_to(&self) -> Result { let num_vertices = self.num_vertices(); let q = self.num_groups(); - let edges: Vec<(usize, usize)> = self.graph().edges(); + let edges: Vec<_> = self + .graph() + .edges() + .into_iter() + .filter(|&(u, v)| u != v) + .map(|(u, v)| (u.min(v), u.max(v))) + .collect::>() + .into_iter() + .collect(); let num_edges = edges.len(); let num_vars = num_vertices * q + num_edges * q; diff --git a/src/rules/partitionintotriangles_ilp.rs b/src/rules/partitionintotriangles_ilp.rs index b80d3fe8c..bfcea4491 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -41,7 +41,13 @@ impl ReductionResult for ReductionPITToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -52,6 +58,18 @@ impl ReductionResult for ReductionPITToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPITToILP { + type Source = PartitionIntoTriangles; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_vertices^2", diff --git a/src/rules/pathconstrainednetworkflow_ilp.rs b/src/rules/pathconstrainednetworkflow_ilp.rs index 799cebfc8..a2b11b7b2 100644 --- a/src/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/rules/pathconstrainednetworkflow_ilp.rs @@ -26,14 +26,32 @@ impl ReductionResult for ReductionPCNFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(target_solution) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPCNFToILP { + type Source = PathConstrainedNetworkFlow; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vars = "num_paths", num_constraints = "num_arcs + 1", }, diff --git a/src/rules/precedenceconstrainedscheduling_ilp.rs b/src/rules/precedenceconstrainedscheduling_ilp.rs index c79cbc5fc..fb6c172f8 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -42,7 +42,13 @@ impl ReductionResult for ReductionPCSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -53,6 +59,18 @@ impl ReductionResult for ReductionPCSToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionPCSToILP { + type Source = PrecedenceConstrainedScheduling; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_tasks * deadline", diff --git a/src/rules/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index cf75fb3a1..4ccaf3f49 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -25,12 +25,30 @@ impl ReductionResult for ReductionRPCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution.iter().map(|&value| value == 1).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionRPCToILP { + type Source = RectilinearPictureCompression; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_rows^2 * num_cols^2", diff --git a/src/rules/registersufficiency_ilp.rs b/src/rules/registersufficiency_ilp.rs index 021b02edc..68f2430de 100644 --- a/src/rules/registersufficiency_ilp.rs +++ b/src/rules/registersufficiency_ilp.rs @@ -30,12 +30,30 @@ impl ReductionResult for ReductionRegisterSufficiencyToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(&target_solution[..self.num_vertices]) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionRegisterSufficiencyToILP { + type Source = RegisterSufficiency; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "3 * num_vertices^2 + num_vertices * (num_vertices - 1) / 2 + 2 * num_vertices", diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 3289b9e91..7247687d4 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -171,6 +171,7 @@ impl EdgeCapabilities { /// A registered reduction entry for static inventory registration. /// Uses function pointers to lazily derive variant fields from `Problem::variant()`. +#[derive(Clone, Copy)] pub struct ReductionEntry { /// Base name of source problem (e.g., "MaximumIndependentSet"). pub source_name: &'static str, @@ -190,7 +191,7 @@ pub struct ReductionEntry { pub reduce_fn: Option, /// Type-erased aggregate reduction executor. /// Takes a `&dyn Any` (must be `&SourceType`), calls - /// `ReduceToAggregate::reduce_to_aggregate()`, and returns either a boxed + /// the registered construction, and returns either a boxed /// `DynAggregateReductionResult` or the edge's `ReductionError`. pub reduce_aggregate_fn: Option, /// Shares the witness construction when both mappings are available. @@ -254,16 +255,70 @@ impl std::fmt::Debug for ReductionEntry { inventory::collect!(ReductionEntry); +/// A value mapping implemented by the same result as a witness reduction. +pub struct AggregateMappingEntry { + pub source_name: &'static str, + pub target_name: &'static str, + pub source_variant_fn: fn() -> Vec<(&'static str, &'static str)>, + pub target_variant_fn: fn() -> Vec<(&'static str, &'static str)>, + pub reduce_fn: AggregateReduceFn, + pub view_fn: AggregateViewFn, +} + +inventory::collect!(AggregateMappingEntry); + +fn attach_aggregate_mapping(entries: &mut [ReductionEntry], mapping: &AggregateMappingEntry) { + let source_variant = crate::export::variant_to_map((mapping.source_variant_fn)()); + let target_variant = crate::export::variant_to_map((mapping.target_variant_fn)()); + let edge = format!( + "{} {source_variant:?} -> {} {target_variant:?}", + mapping.source_name, mapping.target_name + ); + let mut matches = entries.iter_mut().filter(|entry| { + entry.source_name == mapping.source_name + && entry.target_name == mapping.target_name + && crate::export::variant_to_map(entry.source_variant()) == source_variant + && crate::export::variant_to_map(entry.target_variant()) == target_variant + }); + let entry = matches.next().unwrap_or_else(|| { + panic!("{edge}: aggregate mapping requires a registered witness reduction") + }); + assert!( + matches.next().is_none(), + "{edge}: duplicate witness reduction for aggregate mapping" + ); + assert!( + entry.reduce_fn.is_some() && !entry.turing, + "{edge}: aggregate mapping requires a witness executor" + ); + assert!( + entry.reduce_aggregate_fn.is_none() && entry.aggregate_view_fn.is_none(), + "{edge}: duplicate aggregate mapping" + ); + entry.reduce_aggregate_fn = Some(mapping.reduce_fn); + entry.aggregate_view_fn = Some(mapping.view_fn); +} + /// Return all registered reduction entries. pub fn reduction_entries() -> Vec<&'static ReductionEntry> { - inventory::iter::().collect() + static ENTRIES: std::sync::OnceLock> = std::sync::OnceLock::new(); + ENTRIES + .get_or_init(|| { + let mut entries: Vec<_> = inventory::iter::().copied().collect(); + for mapping in inventory::iter:: { + attach_aggregate_mapping(&mut entries, mapping); + } + entries + }) + .iter() + .collect() } /// Validate reduction parameter expressions against problem-owned endpoint schemas. pub fn validate_reduction_parameter_schemas() -> Result<(), Vec> { let mut errors = Vec::new(); - for entry in inventory::iter:: { + for entry in reduction_entries() { let source_variant = crate::export::variant_to_map(entry.source_variant()); let target_variant = crate::export::variant_to_map(entry.target_variant()); let Some(source) = crate::registry::find_variant_entry(entry.source_name, &source_variant) diff --git a/src/rules/resourceconstrainedscheduling_ilp.rs b/src/rules/resourceconstrainedscheduling_ilp.rs index bcc1726d9..72deeb980 100644 --- a/src/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/rules/resourceconstrainedscheduling_ilp.rs @@ -33,7 +33,13 @@ impl ReductionResult for ReductionRCSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -44,6 +50,18 @@ impl ReductionResult for ReductionRCSToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionRCSToILP { + type Source = ResourceConstrainedScheduling; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_tasks * deadline", diff --git a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs index 8c40f906a..0d511b96f 100644 --- a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -40,7 +40,13 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ let n = self.num_vertices; @@ -54,8 +60,24 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult + for ReductionRootedTreeArrangementToRootedTreeStorageAssignment +{ + type Source = RootedTreeArrangement; + type Target = RootedTreeStorageAssignment; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { + transform = upper_bound { universe_size = "num_vertices", num_subsets = "num_edges", } @@ -65,41 +87,29 @@ impl ReduceTo for RootedTreeArrangement Result { let n = self.num_vertices(); - let edges = self.graph().edges(); + // Loops have zero stretch and impose no storage constraint. + let edges: Vec<_> = self + .graph() + .edges() + .into_iter() + .filter(|&(u, v)| u != v) + .collect(); let num_edges = edges.len(); // Each edge becomes a 2-element subset let subsets: Vec> = edges.iter().map(|&(u, v)| vec![u, v]).collect(); - // Bound K' = K - |E|. If this underflows (K < |E|), the source instance - // is infeasible (each edge contributes at least 1 to the arrangement - // cost). In that case, return a fixed gadget instance that is - // guaranteed infeasible for the target problem as well. + // Every non-loop edge contributes at least one unit of stretch. let num_edges = i64::try_from(num_edges).map_err(|_| { crate::rules::ReductionError::integer_overflow::< RootedTreeArrangement, RootedTreeStorageAssignment, >("converting the number of edges to i64") })?; - let bound = match self.bound().checked_sub(num_edges) { - Some(b) => b, - None => { - // Gadget: universe {0,1,2} with all 2-element subsets and bound 0. - // For any rooted tree on three vertices, at least one pair has - // distance 2, so at least one subset has extension cost >= 1. - // Thus the minimum total extension cost is >= 1, making this - // instance infeasible for bound 0. - let gadget_n = 3; - let gadget_subsets = vec![vec![0, 1], vec![1, 2], vec![0, 2]]; - let target = RootedTreeStorageAssignment::new(gadget_n, gadget_subsets, 0); - - return Ok( - ReductionRootedTreeArrangementToRootedTreeStorageAssignment { - target, - num_vertices: gadget_n, - }, - ); - } + let bound = if self.bound() < num_edges { + -1 + } else { + self.bound() - num_edges }; let target = RootedTreeStorageAssignment::new(n, subsets, bound); diff --git a/src/rules/rootedtreestorageassignment_ilp.rs b/src/rules/rootedtreestorageassignment_ilp.rs index 70b4d9c56..1426f960e 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -76,12 +76,30 @@ impl ReductionResult for ReductionRTSAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode_rows(target_solution, self.n, self.n, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionRTSAToILP { + type Source = RootedTreeStorageAssignment; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "universe_size * universe_size * universe_size + 2 * universe_size * universe_size + universe_size + num_subsets * (universe_size * universe_size + 2 * universe_size + 3)", @@ -107,8 +125,13 @@ impl ReduceTo> for RootedTreeStorageAssignment { if n == 0 { return Ok(ReductionRTSAToILP { - target: ILP::new(0, vec![], vec![], ObjectiveSense::Minimize) - .map_err(Self::target_construction)?, + target: ILP::new( + 0, + vec![LinearConstraint::le(vec![], bound)], + vec![], + ObjectiveSense::Minimize, + ) + .map_err(Self::target_construction)?, n, }); } @@ -162,12 +185,7 @@ impl ReduceTo> for RootedTreeStorageAssignment { for v in 0..n { for u in 0..n { if u != v { - // d_v - d_u + n*p_{v,u} >= 1 - n + n = 1 - // => d_v - d_u + n*p_{v,u} >= 1 - n*(1 - p_{v,u}) - // Rewrite: d_v - d_u + n*p_{v,u} >= 1 - n + n*p_{v,u} ... no. - // Original: d_v - d_u >= 1 - n(1 - p_{v,u}) - // => d_v - d_u + n - n*p_{v,u} >= 1 - // => d_v - d_u - n*p_{v,u} >= 1 - n + // d_v - d_u - n*p_{v,u} >= 1 - n constraints.push(LinearConstraint::ge( vec![ (idx_d(n, v), 1), @@ -380,10 +398,8 @@ impl ReduceTo> for RootedTreeStorageAssignment { } // Total cost bound: Σ c_s <= K - if r > 0 { - let cost_terms: Vec<(usize, i64)> = (0..r).map(|s| (idx_c(n, r, s), 1)).collect(); - constraints.push(LinearConstraint::le(cost_terms, bound)); - } + let cost_terms: Vec<(usize, i64)> = (0..r).map(|s| (idx_c(n, r, s), 1)).collect(); + constraints.push(LinearConstraint::le(cost_terms, bound)); let target = ILP::new(nv, constraints, vec![], ObjectiveSense::Minimize) .map_err(Self::target_construction)?; diff --git a/src/rules/sat_circuitsat.rs b/src/rules/sat_circuitsat.rs index c6fbfab3e..503c16860 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -30,7 +30,13 @@ impl ReductionResult for ReductionSATToCircuit { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ self.source_var_indices @@ -41,6 +47,18 @@ impl ReductionResult for ReductionSATToCircuit { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSATToCircuit { + type Source = Satisfiability; + type Target = CircuitSAT; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_variables = "2 * num_vars + num_clauses + 1", diff --git a/src/rules/sat_coloring.rs b/src/rules/sat_coloring.rs index 09fadc3b2..50d724d16 100644 --- a/src/rules/sat_coloring.rs +++ b/src/rules/sat_coloring.rs @@ -138,10 +138,10 @@ impl SATColoringConstructor { /// For a single-literal clause, just set the literal to TRUE. /// For multi-literal clauses, build OR-gadgets recursively. fn add_clause(&mut self, literals: &[i64]) { - assert!( - !literals.is_empty(), - "Clause must have at least one literal" - ); + if literals.is_empty() { + self.add_edge(self.true_vertex(), self.true_vertex()); + return; + } let first_var = BoolVar::from_literal(literals[0]); let mut output_node = self.get_vertex(&first_var); @@ -218,7 +218,6 @@ pub struct ReductionSATToColoring { /// Mapping from variable index (0-indexed) to negative literal vertex index. neg_vertices: Vec, /// Number of variables in the source SAT problem. - num_source_variables: usize, /// Number of clauses in the source SAT problem. num_clauses: usize, } @@ -234,50 +233,23 @@ impl ReductionResult for ReductionSATToColoring { /// Extract a SAT solution from a KColoring solution. /// /// The coloring solution maps each vertex to a color (0, 1, or 2). - /// - Color 0: TRUE - /// - Color 1: FALSE - /// - Color 2: AUX - /// - /// For each variable, we check if its positive literal vertex has TRUE color (0). - /// If so, the variable is assigned true (1); otherwise false (0). + /// The color of vertex 0 represents TRUE, independently of color labels. fn extract_solution( &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - // First determine which color is TRUE, FALSE, and AUX - // Vertices 0, 1, 2 are TRUE, FALSE, AUX respectively - let true_color = target_solution[0]; - let false_color = target_solution[1]; - let aux_color = target_solution[2]; - - if true_color == false_color || true_color == aux_color || false_color == aux_color { - return Err(crate::rules::ExtractionError::invalid( - "target coloring does not distinguish true, false, and auxiliary colors", - )); - } - - let mut assignment = vec![false; self.num_source_variables]; - - for (i, &pos_vertex) in self.pos_vertices.iter().enumerate() { - let vertex_color = target_solution[pos_vertex]; - - // Sanity check: variable vertices should not have AUX color - if vertex_color == aux_color { - return Err(crate::rules::ExtractionError::invalid(format!( - "variable {i} has the auxiliary color" - ))); - } - - // If positive literal has TRUE color, variable is true (1) - // Otherwise, variable is false (0) - assignment[i] = vertex_color == true_color; - } - - assignment - }) + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target coloring is not valid", + )); + } + Ok(self + .pos_vertices + .iter() + .map(|&vertex| target_solution[vertex] == target_solution[0]) + .collect()) } } @@ -298,10 +270,22 @@ impl ReductionSATToColoring { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSATToColoring { + type Source = Satisfiability; + type Target = KColoring; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { - num_vertices = "2 * num_vars + 3 + 5 * (num_literals - num_clauses)", - num_edges = "3 + 3 * num_vars + 11 * num_literals - 9 * num_clauses", + transform = upper_bound { + num_vertices = "2 * num_vars + 3 + 5 * num_literals", + num_edges = "3 + 3 * num_vars + 11 * num_literals + 2 * num_clauses", num_colors = "3", } )] @@ -322,7 +306,6 @@ impl ReduceTo> for Satisfiability { target, pos_vertices: constructor.pos_vertices, neg_vertices: constructor.neg_vertices, - num_source_variables: self.num_vars(), num_clauses: self.num_clauses(), }) } diff --git a/src/rules/sat_ksat.rs b/src/rules/sat_ksat.rs index 897c8bb5b..3420b8a01 100644 --- a/src/rules/sat_ksat.rs +++ b/src/rules/sat_ksat.rs @@ -36,7 +36,13 @@ impl ReductionResult for ReductionSATToKSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target assignment is not satisfying", + )); + } Ok({ // Only return the original variables, discarding ancillas @@ -45,6 +51,19 @@ impl ReductionResult for ReductionSATToKSAT { } } +crate::register_aggregate_reduction!(ReductionSATToKSAT); + +impl crate::rules::AggregateReductionResult for ReductionSATToKSAT { + type Source = Satisfiability; + type Target = KSatisfiability; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + /// Add a clause to the K-SAT formula, splitting or padding as necessary. /// /// # Algorithm @@ -121,8 +140,8 @@ macro_rules! impl_sat_to_ksat { #[rustfmt::skip] #[reduction( transform = upper_bound { - num_clauses = "4 * num_clauses + num_literals", - num_vars = "num_vars + 3 * num_clauses + num_literals", + num_clauses = "8 * num_clauses + num_literals", + num_vars = "num_vars + 7 * num_clauses + num_literals", }, unavailable = { num_literals = "the exact target parameter is not represented by this reduction's symbolic transform", @@ -186,7 +205,13 @@ impl ReductionResult for ReductionKSATToSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target assignment is not satisfying", + )); + } Ok({ // Direct mapping - no transformation needed @@ -195,6 +220,19 @@ impl ReductionResult for ReductionKSATToSAT { } } +crate::register_aggregate_reduction!(ReductionKSATToSAT); + +impl crate::rules::AggregateReductionResult for ReductionKSATToSAT { + type Source = KSatisfiability; + type Target = Satisfiability; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + /// Helper function for KSAT -> SAT reduction logic (generic over K). fn reduce_ksat_to_sat(ksat: &KSatisfiability) -> ReductionKSATToSAT { let clauses = ksat.clauses().to_vec(); diff --git a/src/rules/sat_maximumindependentset.rs b/src/rules/sat_maximumindependentset.rs index d8271f74e..dd539cf7c 100644 --- a/src/rules/sat_maximumindependentset.rs +++ b/src/rules/sat_maximumindependentset.rs @@ -101,6 +101,7 @@ impl ReductionResult for ReductionSATToIS { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSATToIS { type Source = Satisfiability; type Target = MaximumIndependentSet; @@ -127,7 +128,6 @@ impl ReductionSATToIS { } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "num_literals", num_edges = "num_literals^2", diff --git a/src/rules/sat_minimumdominatingset.rs b/src/rules/sat_minimumdominatingset.rs index bed0c45e8..21074ea36 100644 --- a/src/rules/sat_minimumdominatingset.rs +++ b/src/rules/sat_minimumdominatingset.rs @@ -80,6 +80,7 @@ impl ReductionResult for ReductionSATToDS { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSATToDS { type Source = Satisfiability; type Target = MinimumDominatingSet; @@ -134,7 +135,6 @@ impl ReductionSATToDS { } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "3 * num_vars + num_clauses", num_edges = "3 * num_vars + num_literals", diff --git a/src/rules/satisfiability_integralflowhomologousarcs.rs b/src/rules/satisfiability_integralflowhomologousarcs.rs index d6a1fa8dc..daef6a649 100644 --- a/src/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/rules/satisfiability_integralflowhomologousarcs.rs @@ -106,7 +106,13 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target flow is not feasible", + )); + } Ok({ self.variable_paths @@ -117,8 +123,20 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSATToIntegralFlowHomologousArcs { + type Source = Satisfiability; + type Target = IntegralFlowHomologousArcs; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vertices = "2 * num_vars * num_clauses + 3 * num_vars + 2 * num_clauses + 2", num_arcs = "2 * num_vars * num_clauses + 5 * num_vars + num_clauses + num_literals", }, @@ -169,7 +187,9 @@ impl ReduceTo for Satisfiability { for (clause_idx, clause) in self.clauses().iter().enumerate() { let collector = indexer.collector(clause_idx); let distributor = indexer.distributor(clause_idx); - let bottleneck_capacity = i64::try_from(clause.literals.len().saturating_sub(1)) + // Repeated literals share one flow channel and count only once. + let distinct_literals: std::collections::BTreeSet<_> = clause.literals.iter().collect(); + let bottleneck_capacity = i64::try_from(distinct_literals.len().saturating_sub(1)) .map_err(|_| { crate::rules::ReductionError::integer_overflow::< Satisfiability, diff --git a/src/rules/satisfiability_maximum2satisfiability.rs b/src/rules/satisfiability_maximum2satisfiability.rs index 040837da5..79c94b577 100644 --- a/src/rules/satisfiability_maximum2satisfiability.rs +++ b/src/rules/satisfiability_maximum2satisfiability.rs @@ -39,6 +39,7 @@ impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { type Source = Satisfiability; type Target = Maximum2Satisfiability; @@ -121,7 +122,6 @@ fn add_gjs_gadget(clause: &CNFClause, w: i64, target_clauses: &mut Vec::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok(target_solution.to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSATToNonTautology { + type Source = Satisfiability; + type Target = NonTautology; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_vars = "num_vars", diff --git a/src/rules/schedulingwithindividualdeadlines_ilp.rs b/src/rules/schedulingwithindividualdeadlines_ilp.rs index a36ee872d..8c9394146 100644 --- a/src/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/rules/schedulingwithindividualdeadlines_ilp.rs @@ -43,12 +43,30 @@ impl ReductionResult for ReductionSWIDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode_rows(target_solution, self.num_tasks, self.max_deadline, 0) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSWIDToILP { + type Source = SchedulingWithIndividualDeadlines; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_tasks * max_deadline", diff --git a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs index f046bd232..408e95c23 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -37,7 +37,13 @@ impl ReductionResult for ReductionSTMWTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let n = self.num_tasks; @@ -49,7 +55,20 @@ impl ReductionResult for ReductionSTMWTToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSTMWTToILP { + type Source = SequencingToMinimizeWeightedTardiness; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + +#[reduction( + transform = upper_bound { num_vars = "num_tasks^2 + 2 * num_tasks", num_constraints = "2 * num_tasks^2 + 3 * num_tasks + 1", }, diff --git a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 21cd366b0..f709d6160 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -40,7 +40,13 @@ impl ReductionResult for ReductionSWDSTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let n = self.num_tasks; @@ -50,7 +56,20 @@ impl ReductionResult for ReductionSWDSTToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSWDSTToILP { + type Source = SequencingWithDeadlinesAndSetUpTimes; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + +#[reduction( + transform = upper_bound { num_vars = "2 * num_tasks^2 + num_tasks", num_constraints = "2 * num_tasks + num_tasks^2 * (num_tasks - 1) + 3 * num_tasks * (num_tasks - 1) + num_tasks * num_tasks", }, diff --git a/src/rules/sequencingwithinintervals_ilp.rs b/src/rules/sequencingwithinintervals_ilp.rs index daccfd111..b3de77917 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -47,7 +47,13 @@ impl ReductionResult for ReductionSWIToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } self.task_layout .iter() @@ -68,6 +74,18 @@ impl ReductionResult for ReductionSWIToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSWIToILP { + type Source = SequencingWithinIntervals; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_start_slots", diff --git a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index d14ad874c..66273db9a 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -33,7 +33,13 @@ impl ReductionResult for ReductionSWRTDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let n = self.num_tasks; @@ -50,7 +56,20 @@ impl ReductionResult for ReductionSWRTDToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSWRTDToILP { + type Source = SequencingWithReleaseTimesAndDeadlines; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + +#[reduction( + transform = upper_bound { num_vars = "num_tasks * time_horizon", num_constraints = "num_tasks * time_horizon + num_tasks + time_horizon", }, diff --git a/src/rules/setsplitting_betweenness.rs b/src/rules/setsplitting_betweenness.rs index 7e6ec7963..a77a678da 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -33,7 +33,13 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } let pole_position = target_solution[self.pole]; Ok(target_solution[..self.source_universe_size] @@ -43,6 +49,18 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSetSplittingToBetweenness { + type Source = SetSplitting; + type Target = Betweenness; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = unavailable { num_elements = "the exact target parameters depend on normalization statistics specific to this reduction", diff --git a/src/rules/setsplitting_ilp.rs b/src/rules/setsplitting_ilp.rs index db0b8a61c..7c21800e2 100644 --- a/src/rules/setsplitting_ilp.rs +++ b/src/rules/setsplitting_ilp.rs @@ -32,12 +32,30 @@ impl ReductionResult for ReductionSetSplittingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution.iter().map(|&value| value == 1).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSetSplittingToILP { + type Source = SetSplitting; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "universe_size", diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index b19b79368..e6ab9f498 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -26,7 +26,13 @@ impl ReductionResult for ReductionSMCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -37,10 +43,22 @@ impl ReductionResult for ReductionSMCToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSMCToILP { + type Source = SparseMatrixCompression; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_rows * bound_k", - num_constraints = "num_rows + num_rows * num_rows * bound_k * bound_k", + num_constraints = "num_rows + num_rows^2 * num_cols^2 * bound_k", }, unavailable = { num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", diff --git a/src/rules/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index 1f6cf33d2..94b0ac79c 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -58,7 +58,13 @@ impl ReductionResult for ReductionSTSCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let n = self.n; @@ -107,10 +113,22 @@ impl ReductionResult for ReductionSTSCToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSTSCToILP { + type Source = StringToStringCorrection; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "(bound + 1) * source_length^2 + (bound + 1) * source_length + 2 * bound * source_length + bound", - num_constraints = "4 * bound * source_length^3 + 2 * bound * source_length^2 + source_length^2 + 6 * bound * source_length + 5 * source_length + bound", + num_constraints = "4 * bound * source_length^3 + 2 * bound * source_length^2 + source_length^2 + 6 * bound * source_length + 5 * source_length + bound + 1", }, unavailable = { num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", diff --git a/src/rules/strongconnectivityaugmentation_ilp.rs b/src/rules/strongconnectivityaugmentation_ilp.rs index 02bb8a4e3..4a6da2d4e 100644 --- a/src/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/rules/strongconnectivityaugmentation_ilp.rs @@ -27,7 +27,13 @@ impl ReductionResult for ReductionSCAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution[..self.num_candidates] .iter() @@ -36,10 +42,22 @@ impl ReductionResult for ReductionSCAToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSCAToILP { + type Source = StrongConnectivityAugmentation; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vars = "num_potential_arcs + 2 * num_vertices * (num_arcs + num_potential_arcs)", - num_constraints = "1 + 2 * num_vertices * num_potential_arcs + 2 * num_vertices * num_vertices", + num_constraints = "1 + num_potential_arcs + 2 * num_arcs + 2 * num_vertices * num_potential_arcs + 2 * num_vertices * num_vertices", }, unavailable = { num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", diff --git a/src/rules/subgraphisomorphism_ilp.rs b/src/rules/subgraphisomorphism_ilp.rs index d8c6c3f4b..9bdfa1770 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -38,7 +38,13 @@ impl ReductionResult for ReductionSubIsoToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } one_hot_decode_rows( target_solution, @@ -49,6 +55,18 @@ impl ReductionResult for ReductionSubIsoToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSubIsoToILP { + type Source = SubgraphIsomorphism; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = upper_bound { num_vars = "num_pattern_vertices * num_host_vertices", @@ -79,9 +97,6 @@ impl ReduceTo> for SubgraphIsomorphism { for &(v, w) in &pat_edges { for u in 0..n_host { for u_prime in 0..n_host { - if u == u_prime { - continue; - } if host.has_edge(u, u_prime) { continue; } diff --git a/src/rules/subsetsum_closestvectorproblem.rs b/src/rules/subsetsum_closestvectorproblem.rs index 0b3c25880..c8e89c08f 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -41,6 +41,7 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSubsetSumToClosestVectorProblem { type Source = SubsetSum; type Target = ClosestVectorProblem; @@ -80,7 +81,6 @@ impl ReductionSubsetSumToClosestVectorProblem { } #[reduction( - aggregate = custom, transform = unavailable { ambient_dimension = "2n+b depends on input bit length b, which is not a registered SubsetSum parameter", num_basis_vectors = "n+b-1 depends on input bit length b, which is not a registered SubsetSum parameter", diff --git a/src/rules/subsetsum_integerexpressionmembership.rs b/src/rules/subsetsum_integerexpressionmembership.rs index c187a66e4..92b487702 100644 --- a/src/rules/subsetsum_integerexpressionmembership.rs +++ b/src/rules/subsetsum_integerexpressionmembership.rs @@ -21,7 +21,13 @@ impl ReductionResult for ReductionSubsetSumToIntegerExpressionMembership { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ // Union choice 0 = left = Atom(1) = exclude, choice 1 = right = Atom(s_i+1) = include. @@ -61,6 +67,18 @@ fn build_expression(sizes: &[i64]) -> Result { Ok(expr) } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToIntegerExpressionMembership { + type Source = SubsetSum; + type Target = IntegerExpressionMembership; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_union_nodes = "num_elements", diff --git a/src/rules/subsetsum_partition.rs b/src/rules/subsetsum_partition.rs index 8b1e2726f..0f7df4b83 100644 --- a/src/rules/subsetsum_partition.rs +++ b/src/rules/subsetsum_partition.rs @@ -34,7 +34,13 @@ impl ReductionResult for ReductionSubsetSumToPartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ let source_bits = &target_solution[..self.source_len]; @@ -60,8 +66,20 @@ impl ReductionResult for ReductionSubsetSumToPartition { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToPartition { + type Source = SubsetSum; + type Target = Partition; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_elements = "num_elements + 1", })] impl ReduceTo for SubsetSum { diff --git a/src/rules/threedimensionalmatching_ilp.rs b/src/rules/threedimensionalmatching_ilp.rs index 57db1ddc7..baa174195 100644 --- a/src/rules/threedimensionalmatching_ilp.rs +++ b/src/rules/threedimensionalmatching_ilp.rs @@ -22,12 +22,30 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok(target_solution.iter().map(|&value| value == 1).collect()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToILP { + type Source = ThreeDimensionalMatching; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "num_triples", diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index c8239dae0..facd476d8 100644 --- a/src/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -11,15 +11,14 @@ //! //! `source.evaluate(S) == Or(true)` ⇔ `target.evaluate(x) == Min(Some(q))`, //! -//! where `S = { t_j ∈ T : x_j = 1 }`. We rely on the witness-extraction -//! route `source.evaluate(extract_solution(x))` rather than comparing the -//! optimum value directly, mirroring `partition_sumofsquarespartition.rs`. +//! where `S = { t_j ∈ T : x_j = 1 }`. The completed optimum maps to YES/NO +//! by comparison with `q`; only a weight-`q` target witness is decoded. //! //! **Sentinel branch.** `MinimumWeightDecoding::new` panics on zero-row or //! zero-column matrices, so degenerate inputs (`q = 0` or `T = []`) emit a //! fixed `1×1` sentinel `H = [[1]]` with syndrome `s = [0]`. The unique -//! feasible codeword `x = (0)` decodes to the empty subset `S = ∅`, and -//! `source.evaluate(∅)` correctly returns `Or(true)` iff `q = 0`. +//! feasible codeword `x = (0)` has weight zero, so the aggregate mapping returns +//! YES iff `q = 0`. Only that YES case decodes to the empty subset. use crate::models::algebraic::MinimumWeightDecoding; use crate::models::set::ThreeDimensionalMatching; @@ -34,6 +33,7 @@ pub struct ReductionThreeDimensionalMatchingToMinimumWeightDecoding { /// Used to return a correctly-sized witness when the sentinel path is /// taken (i.e. `q == 0` or `num_triples == 0`). source_num_triples: usize, + source_universe_size: usize, } impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecoding { @@ -51,7 +51,13 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } if target_solution.len() != self.target.num_cols() { return Err(crate::rules::ExtractionError::invalid(format!( "expected {} target codeword bits, got {}", @@ -64,6 +70,26 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult + for ReductionThreeDimensionalMatchingToMinimumWeightDecoding +{ + type Source = ThreeDimensionalMatching; + type Target = MinimumWeightDecoding; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { + crate::types::Or( + value + .0 + .is_some_and(|count| i128::from(count) == self.source_universe_size as i128), + ) + } +} + #[reduction( transform = exact { num_rows = "3 * universe_size", @@ -84,6 +110,7 @@ impl ReduceTo for ThreeDimensionalMatching { // q = 0 → Or(true) (empty matching of empty universe) // q ≥ 1 → Or(false) (no triples cannot cover non-empty universe). return Ok(ReductionThreeDimensionalMatchingToMinimumWeightDecoding { + source_universe_size: q, target: MinimumWeightDecoding::new(vec![vec![true]], vec![false]), source_num_triples: m, }); @@ -101,6 +128,7 @@ impl ReduceTo for ThreeDimensionalMatching { let syndrome = vec![true; num_rows]; Ok(ReductionThreeDimensionalMatchingToMinimumWeightDecoding { + source_universe_size: q, target: MinimumWeightDecoding::new(matrix, syndrome), source_num_triples: m, }) diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index 58b95a84b..de65db8df 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -341,6 +341,18 @@ fn enumerate_pair_keys(num_regulars: usize) -> Option> { Some(pairs) } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToThreePartition { + type Source = ThreeDimensionalMatching; + type Target = ThreePartition; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = upper_bound { num_elements = "24 * num_triples * num_triples - 3 * num_triples + 6", diff --git a/src/rules/threepartition_resourceconstrainedscheduling.rs b/src/rules/threepartition_resourceconstrainedscheduling.rs index 817ace389..01c1727a7 100644 --- a/src/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/rules/threepartition_resourceconstrainedscheduling.rs @@ -42,12 +42,30 @@ impl ReductionResult for ReductionThreePartitionToRCS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok(target_solution.to_vec()) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionThreePartitionToRCS { + type Source = ThreePartition; + type Target = ResourceConstrainedScheduling; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_tasks = "num_elements", diff --git a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index d1b480304..075c8feda 100644 --- a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -51,7 +51,13 @@ impl ReductionResult for ReductionThreePartitionToSRTD { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not satisfy the target problem", + )); + } Ok({ // Simulate the schedule to find start times @@ -86,6 +92,18 @@ impl ReductionResult for ReductionThreePartitionToSRTD { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionThreePartitionToSRTD { + type Source = ThreePartition; + type Target = SequencingWithReleaseTimesAndDeadlines; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value + } +} + #[reduction( transform = exact { num_tasks = "num_elements + num_groups - 1", diff --git a/src/rules/timetabledesign_ilp.rs b/src/rules/timetabledesign_ilp.rs index 9e771d48e..9f9af22c1 100644 --- a/src/rules/timetabledesign_ilp.rs +++ b/src/rules/timetabledesign_ilp.rs @@ -35,7 +35,13 @@ impl ReductionResult for ReductionTDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok((0..self.num_craftsmen) .map(|craftsman| { @@ -56,10 +62,22 @@ impl ReductionResult for ReductionTDToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionTDToILP { + type Source = TimetableDesign; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vars = "num_craftsmen * num_tasks * num_periods", - num_constraints = "num_craftsmen * num_periods + num_tasks * num_periods + num_craftsmen * num_tasks", + num_constraints = "num_craftsmen * num_periods + num_tasks * num_periods + num_craftsmen * num_tasks + num_craftsmen * num_tasks * num_periods", }, unavailable = { num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", diff --git a/src/rules/traits.rs b/src/rules/traits.rs index 0d50b78f2..79c74b65b 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -259,6 +259,13 @@ pub trait AggregateReductionResult { fn target_problem(&self) -> &Self::Target; /// Extract an aggregate value from target problem space back to source space. + /// + /// The caller supplies the completed target aggregate: an exact optimum, + /// exhaustive YES/NO, count, or universal fold. Evaluating one candidate is + /// not a substitute for that aggregate when establishing NO or optimality. + /// A decision rule may also use its map to certify a candidate witness. + /// Each rule defines its own map; + /// source and target value types alone do not establish equivalence. fn extract_value( &self, target_value: ::Value, diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index f8b952ff7..17a1224af 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -95,6 +95,7 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionTravelingSalesmanToQUBO { type Source = TravelingSalesman; type Target = QUBO; @@ -123,7 +124,6 @@ impl crate::rules::AggregateReductionResult for ReductionTravelingSalesmanToQUBO } #[reduction( - aggregate = custom, transform = exact { num_vars = "num_vertices^2", } diff --git a/src/rules/undirectedflowlowerbounds_ilp.rs b/src/rules/undirectedflowlowerbounds_ilp.rs index 88db49edf..5f5f98280 100644 --- a/src/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/rules/undirectedflowlowerbounds_ilp.rs @@ -21,7 +21,7 @@ //! Flow conservation at non-terminal vertices. //! Net flow into sink ≥ requirement. //! -//! Size upper bound: 3*|E| variables, 4*|E| + |V| + 1 constraints (conservative for non-terminals). +//! Size upper bound: 3*|E| variables, 5*|E| + |V| + 1 constraints. use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::UndirectedFlowLowerBounds; @@ -58,7 +58,13 @@ impl ReductionResult for ReductionUFLBToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } Ok({ let e = self.num_edges; @@ -70,10 +76,22 @@ impl ReductionResult for ReductionUFLBToILP { } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionUFLBToILP { + type Source = UndirectedFlowLowerBounds; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( - transform = exact { + transform = upper_bound { num_vars = "3 * num_edges", - num_constraints = "4 * num_edges + num_vertices + 1", + num_constraints = "5 * num_edges + num_vertices + 1", }, unavailable = { num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", @@ -139,7 +157,8 @@ impl ReduceTo> for UndirectedFlowLowerBounds { // f_{uv} leaves vertex u, f_{vu} enters terms.push((f_uv(edge_idx), -1)); terms.push((f_vu(edge_idx), 1)); - } else if vertex == v { + } + if vertex == v { // f_{uv} enters vertex v, f_{vu} leaves terms.push((f_uv(edge_idx), 1)); terms.push((f_vu(edge_idx), -1)); @@ -159,7 +178,8 @@ impl ReduceTo> for UndirectedFlowLowerBounds { // f_{uv} flows into sink, f_{vu} flows out sink_terms.push((f_uv(edge_idx), 1)); sink_terms.push((f_vu(edge_idx), -1)); - } else if u == sink { + } + if u == sink { // f_{vu} flows into sink (from v side), f_{uv} flows out sink_terms.push((f_uv(edge_idx), -1)); sink_terms.push((f_vu(edge_idx), 1)); diff --git a/src/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/rules/undirectedtwocommodityintegralflow_ilp.rs index 821af6079..171145bb5 100644 --- a/src/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -55,12 +55,30 @@ impl ReductionResult for ReductionU2CIFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "target ILP assignment is infeasible", + )); + } crate::rules::ilp_helpers::decode_usize_values(&target_solution[..4 * self.num_edges]) } } +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionU2CIFToILP { + type Source = UndirectedTwoCommodityIntegralFlow; + type Target = ILP; + fn target_problem(&self) -> &Self::Target { + &self.target + } + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + #[reduction( transform = exact { num_vars = "6 * num_edges", @@ -151,7 +169,8 @@ impl ReduceTo> for UndirectedTwoCommodityIntegralFlow { if vertex == u { terms.push((uv, -1)); terms.push((vu, 1)); - } else if vertex == v { + } + if vertex == v { terms.push((uv, 1)); terms.push((vu, -1)); } @@ -168,7 +187,8 @@ impl ReduceTo> for UndirectedTwoCommodityIntegralFlow { if sink_1 == v { sink1_terms.push((f1_uv(edge_idx), 1)); sink1_terms.push((f1_vu(edge_idx), -1)); - } else if sink_1 == u { + } + if sink_1 == u { sink1_terms.push((f1_uv(edge_idx), -1)); sink1_terms.push((f1_vu(edge_idx), 1)); } @@ -182,7 +202,8 @@ impl ReduceTo> for UndirectedTwoCommodityIntegralFlow { if sink_2 == v { sink2_terms.push((f2_uv(edge_idx), 1)); sink2_terms.push((f2_vu(edge_idx), -1)); - } else if sink_2 == u { + } + if sink_2 == u { sink2_terms.push((f2_uv(edge_idx), -1)); sink2_terms.push((f2_vu(edge_idx), 1)); } diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 73d85007f..2a78f8f23 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -694,6 +694,27 @@ fn rule_specs_solution_pairs_are_consistent() { let chain = chain.unwrap_or_else(|error| { panic!("Rule {label}: witness reduction execution failed: {error}") }); + let aggregate_chain = if chain + .as_ref() + .is_some_and(|chain| chain.has_value_mapping()) + { + let aggregate_chain = graph + .reduce_aggregate_along_path(witness_path.as_ref().unwrap(), source.as_any()) + .unwrap() + .unwrap(); + assert_eq!( + crate::registry::serialize_any( + &example.target.problem, + &example.target.variant, + aggregate_chain.target_problem_any() + ), + Some(example.target.instance.clone()), + "Rule {label}: witness and aggregate execution construct different targets" + ); + Some(aggregate_chain) + } else { + None + }; for pair in &example.solutions { // Verify configs produce feasible evaluations. @@ -739,6 +760,62 @@ fn rule_specs_solution_pairs_are_consistent() { // Round-trip: extract_solution(target_config) must produce a valid // source config with the same evaluation value (witness paths only) if let Some(ref chain) = chain { + if source_eval == "Or(true)" { + assert!( + chain.has_value_mapping(), + "Rule {label}: decision recovery requires an explicit YES/NO map" + ); + } + if chain.has_value_mapping() { + let target_value = target.evaluate_json(&pair.target_config).unwrap(); + assert_eq!( + aggregate_chain + .as_ref() + .unwrap() + .extract_value(target_value.clone()) + .unwrap(), + source_val, + "Rule {label}: aggregate-only execution disagrees with witness evaluation" + ); + assert_eq!( + chain.extract_value(target_value).unwrap(), + source_val, + "Rule {label}: aggregate and witness mappings disagree" + ); + if source_eval == "Or(true)" { + assert_eq!( + chain + .extract_value(target.empty_aggregate_json().unwrap()) + .unwrap(), + serde_json::json!(false), + "Rule {label}: infeasible target must map to NO" + ); + if let Some(config) = pair.target_config.as_array() { + for bit in [false, true] { + let candidate = serde_json::Value::Array( + config + .iter() + .map(|value| match value { + serde_json::Value::Bool(_) => serde_json::json!(bit), + serde_json::Value::Number(_) => { + serde_json::json!(i64::from(bit)) + } + _ => value.clone(), + }) + .collect(), + ); + // Only test well-formed candidates whose mapped value is NO. + if let Ok(value) = target.evaluate_json(&candidate) { + if chain.extract_value(value).unwrap() + == serde_json::json!(false) + { + assert!(chain.extract_solution_json(candidate).is_err(), "Rule {label}: a negative certificate produced a witness"); + } + } + } + } + } + } let extracted = chain .extract_solution_json(pair.target_config.clone()) .unwrap(); diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index a2b6ec805..8d162aff6 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -74,7 +74,7 @@ fn symbolic_composition_propagates_num_colors_across_multiple_edges() { ])) .unwrap(); - assert_eq!(target.get("num_vars"), Some(15)); + assert_eq!(target.get("num_vars"), Some(30)); } #[test] @@ -986,14 +986,14 @@ fn test_optimization_to_decision_turing_edges() { } #[test] -fn test_ksatisfiability_k3_to_decision_minimum_vertex_cover_direct_witness_edge() { +fn test_ksatisfiability_k3_to_decision_minimum_vertex_cover_direct_mappings() { let graph = ReductionGraph::new(); assert!(graph.has_direct_reduction_mode::< KSatisfiability, Decision>, >(ReductionMode::Witness)); - assert!(!graph.has_direct_reduction_mode::< + assert!(graph.has_direct_reduction_mode::< KSatisfiability, Decision>, >(ReductionMode::Aggregate)); diff --git a/src/unit_tests/rules/acyclicpartition_ilp.rs b/src/unit_tests/rules/acyclicpartition_ilp.rs index 467d43aec..5574da900 100644 --- a/src/unit_tests/rules/acyclicpartition_ilp.rs +++ b/src/unit_tests/rules/acyclicpartition_ilp.rs @@ -46,10 +46,36 @@ fn test_reduction_num_vars() { let reduction: ReductionAcyclicPartitionToILP = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let ilp = reduction.target_problem(); - // n=4, m=3: n^2 + m*n + m = 16 + 12 + 3 = 31 - assert_eq!(ilp.num_vars(), 31); - // 2n + 3mn + 2m + 1 = 8 + 36 + 6 + 1 = 51 - assert_eq!(ilp.num_constraints(), 51); + assert_eq!(ilp.num_vars(), 35); + assert_eq!(ilp.num_constraints(), 75); +} + +#[test] +fn signed_partition_weights_and_costs_are_checked_after_summing() { + for (source, witness) in [ + ( + AcyclicPartition::new(DirectedGraph::new(2, vec![]), vec![2, -3], vec![], -1, 0), + vec![0, 0], + ), + ( + AcyclicPartition::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1; 3], + vec![3, -4], + 1, + -1, + ), + vec![0, 1, 2], + ), + ] { + assert!(source.evaluate(&witness).unwrap().0); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + crate::rules::test_helpers::assert_bf_vs_ilp(&source, &reduction); + } + let empty = AcyclicPartition::new(DirectedGraph::new(0, vec![]), vec![], vec![], 0, -1); + assert!(!empty.evaluate(&vec![]).unwrap().0); + let reduction = ReduceTo::>::reduce_to(&empty).unwrap(); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); } #[test] diff --git a/src/unit_tests/rules/aggregate_contracts.rs b/src/unit_tests/rules/aggregate_contracts.rs new file mode 100644 index 000000000..bddf431ab --- /dev/null +++ b/src/unit_tests/rules/aggregate_contracts.rs @@ -0,0 +1,327 @@ +use crate::models::algebraic::MinimumWeightDecoding; +use crate::models::formula::{CNFClause, KSatisfiability}; +use crate::models::graph::{ + BottleneckTravelingSalesman, HamiltonianCircuit, MinimumVertexCover, TravelingSalesman, +}; +use crate::models::misc::{ + BinPacking, Knapsack, MinimumAxiomSet, MinimumFaultDetectionTestSet, Partition, + SumOfSquaresPartition, +}; +use crate::models::set::{ExactCoverBy3Sets, MaximumSetPacking, ThreeDimensionalMatching}; +use crate::rules::{AggregateReductionResult, ReduceTo, ReductionResult}; +use crate::solvers::BruteForce; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; +use crate::types::{Aggregate, Extremum, One, Or, SolutionAggregate}; +use crate::variant::K3; + +#[test] +fn decision_graph_encodings_preserve_small_and_native_graph_cases() { + use crate::models::graph::{ + BalancedCompleteBipartiteSubgraph, KClique, KColoring, PartitionIntoCliques, RuralPostman, + StrongConnectivityAugmentation, SubgraphIsomorphism, + }; + use crate::models::misc::{Clustering, ConjunctiveBooleanQuery}; + use crate::variant::KN; + for graph in [ + SimpleGraph::empty(0), + SimpleGraph::empty(1), + SimpleGraph::path(2), + SimpleGraph::path(3), + SimpleGraph::cycle(3), + SimpleGraph::new(3, vec![(0, 0), (0, 1), (0, 1)]), + ] { + check_decision::<_, StrongConnectivityAugmentation>(&HamiltonianCircuit::new( + graph.clone(), + )); + check_decision::<_, RuralPostman>(&HamiltonianCircuit::new( + graph.clone(), + )); + check_decision::<_, Clustering>(&KColoring::::new(graph.clone())); + check_decision::<_, PartitionIntoCliques>(&KColoring::::with_k( + graph.clone(), + 4, + )); + for k in 1..=graph.num_vertices() { + let source = KClique::new(graph.clone(), k); + check_decision::<_, ConjunctiveBooleanQuery>(&source); + check_decision::<_, BalancedCompleteBipartiteSubgraph>(&source); + check_decision::<_, SubgraphIsomorphism>(&source); + } + } +} + +fn check_decision(source: &S) +where + S: Problem + ReduceTo + 'static, + T: Problem + 'static, + S::Solution: 'static, + T::Solution: 'static, + T::Value: SolutionAggregate, + >::Result: AggregateReductionResult, +{ + let reduction = source.reduce_to().unwrap(); + let target = ReductionResult::target_problem(&reduction); + let (total, witnesses) = BruteForce::new().solve_with_witnesses(target).unwrap(); + let expected = Or(BruteForce::new().solve(source).unwrap().is_some()); + assert_eq!(reduction.extract_value(total), expected); + for witness in witnesses { + let decoded = reduction.extract_solution(&witness); + if expected.0 { + assert_eq!(source.evaluate(&decoded.unwrap()).unwrap(), expected); + } else { + assert!( + decoded.is_err(), + "a NO result must not produce a source witness" + ); + } + } +} + +fn check_binary_ilp(source: &S) +where + S: Problem + ReduceTo> + 'static, + S::Solution: 'static, + >>::Result: + AggregateReductionResult>, +{ + let reduction = source.reduce_to().unwrap(); + let target = ReductionResult::target_problem(&reduction); + assert!(target.num_vars() <= 16, "keep exhaustive ILP checks small"); + let mut total = Extremum::minimize(None); + for mask in 0..1usize << target.num_vars() { + let assignment = (0..target.num_vars()) + .map(|bit| ((mask >> bit) & 1) as i64) + .collect(); + let value = target.evaluate(&assignment).unwrap(); + total = total.combine(value).unwrap(); + if value.value.is_some() { + let decoded = reduction.extract_solution(&assignment).unwrap(); + assert_eq!(source.evaluate(&decoded).unwrap(), Or(true)); + } else { + assert!(reduction.extract_solution(&assignment).is_err()); + } + } + assert_eq!( + reduction.extract_value(total), + Or(BruteForce::new().solve(source).unwrap().is_some()) + ); +} + +#[test] +fn binary_ilp_encodings_preserve_degenerate_graphs() { + use crate::models::graph::{DisjointConnectingPaths, HamiltonianPath, SubgraphIsomorphism}; + for graph in [ + SimpleGraph::empty(2), + SimpleGraph::new(2, vec![(0, 0), (1, 1)]), + SimpleGraph::new(2, vec![(0, 1), (0, 1)]), + ] { + check_binary_ilp(&DisjointConnectingPaths::new(graph.clone(), vec![(0, 1)])); + check_binary_ilp(&HamiltonianPath::new(graph)); + } + for host in [SimpleGraph::empty(1), SimpleGraph::new(1, vec![(0, 0)])] { + check_binary_ilp(&SubgraphIsomorphism::new( + host, + SimpleGraph::new(1, vec![(0, 0)]), + )); + } +} + +#[test] +fn zero_column_matrices_have_no_blocks() { + use crate::models::algebraic::ConsecutiveBlockMinimization; + check_binary_ilp(&ConsecutiveBlockMinimization::new(vec![vec![], vec![]], 0)); +} + +#[test] +fn empty_tree_storage_still_obeys_the_budget() { + use crate::models::{algebraic::ILP, set::RootedTreeStorageAssignment}; + use crate::solvers::{ILPSolveError, ILPSolver}; + for n in 0..=2 { + for bound in [-1, 0] { + let source = RootedTreeStorageAssignment::new(n, vec![], bound); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let expected = bound >= 0; + assert_eq!( + BruteForce::new().solve(&source).unwrap().is_some(), + expected + ); + let target = ReductionResult::target_problem(&reduction); + let value = match ILPSolver::new().solve(target) { + Ok(solution) => { + let decoded = reduction.extract_solution(&solution).unwrap(); + assert_eq!(source.evaluate(&decoded).unwrap(), Or(true)); + target.evaluate(&solution).unwrap() + } + Err(ILPSolveError::Infeasible) => Extremum::minimize(None), + Err(error) => panic!("{error}"), + }; + assert_eq!(reduction.extract_value(value), Or(expected)); + } + } +} + +#[test] +fn hamiltonian_tour_thresholds_match_all_small_graphs() { + for n in 0..=4 { + let edges: Vec<_> = (0..n) + .flat_map(|u| (u + 1..n).map(move |v| (u, v))) + .collect(); + for mask in 0..1usize << edges.len() { + let graph = SimpleGraph::new( + n, + edges + .iter() + .enumerate() + .filter_map(|(i, &e)| (mask & (1 << i) != 0).then_some(e)) + .collect(), + ); + let source = HamiltonianCircuit::new(graph); + check_decision::<_, TravelingSalesman>(&source); + check_decision::<_, BottleneckTravelingSalesman>(&source); + } + } +} + +#[test] +fn exact_cover_thresholds_include_uncovered_elements() { + for source in [ + ExactCoverBy3Sets::new(0, vec![]), + ExactCoverBy3Sets::new(3, vec![]), + ExactCoverBy3Sets::new(3, vec![[0, 1, 2]]), + ExactCoverBy3Sets::new(6, vec![[0, 1, 2]]), + ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5]]), + ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [0, 3, 4], [0, 4, 5]]), + ] { + check_decision::<_, MaximumSetPacking>(&source); + check_decision::<_, MinimumAxiomSet>(&source); + check_decision::<_, MinimumFaultDetectionTestSet>(&source); + } +} + +#[test] +fn partition_thresholds_distinguish_odd_totals_and_singletons() { + for sizes in [ + vec![1], + vec![2], + vec![1, 1], + vec![1, 2], + vec![1, 3], + vec![1, 1, 2], + vec![2, 2, 2], + ] { + let source = Partition::new(sizes).unwrap(); + check_decision::<_, BinPacking>(&source); + check_decision::<_, Knapsack>(&source); + check_decision::<_, SumOfSquaresPartition>(&source); + } +} + +#[test] +fn matching_decoding_threshold_handles_empty_and_unsatisfiable_instances() { + for source in [ + ThreeDimensionalMatching::new(0, vec![]), + ThreeDimensionalMatching::new(1, vec![]), + ThreeDimensionalMatching::new(1, vec![(0, 0, 0)]), + ThreeDimensionalMatching::new(2, vec![(0, 0, 0)]), + ThreeDimensionalMatching::new(2, vec![(0, 0, 0), (1, 1, 1)]), + ] { + check_decision::<_, MinimumWeightDecoding>(&source); + } +} + +#[test] +fn sat_cover_threshold_supports_short_and_empty_clauses() { + for clauses in [ + vec![], + vec![vec![]], + vec![vec![1]], + vec![vec![1], vec![-1]], + vec![vec![1, -1, 1]], + ] { + let source = KSatisfiability::::new_allow_less( + 1, + clauses.into_iter().map(CNFClause::new).collect(), + ); + check_decision::<_, MinimumVertexCover>(&source); + check_decision::<_, crate::models::graph::KClique>(&source); + check_decision::<_, crate::models::graph::Kernel>(&source); + check_decision::<_, crate::models::misc::SubsetSum>(&source); + } +} + +#[test] +fn partition_decision_encodings_preserve_yes_and_no() { + use crate::models::graph::IntegralFlowWithMultipliers; + use crate::models::misc::{ + CosineProductIntegration, MultiprocessorScheduling, ProductionPlanning, SubsetSum, + }; + for sizes in [vec![1], vec![2], vec![1, 1], vec![1, 2], vec![1, 3]] { + let source = Partition::new(sizes).unwrap(); + check_decision::<_, CosineProductIntegration>(&source); + check_decision::<_, MultiprocessorScheduling>(&source); + check_decision::<_, ProductionPlanning>(&source); + check_decision::<_, SubsetSum>(&source); + check_decision::<_, IntegralFlowWithMultipliers>(&source); + } +} + +#[test] +fn rooted_tree_mapping_preserves_empty_graphs_loops_and_negative_bounds() { + use crate::models::graph::RootedTreeArrangement; + use crate::models::set::RootedTreeStorageAssignment; + for graph in [ + SimpleGraph::empty(0), + SimpleGraph::empty(1), + SimpleGraph::new(2, vec![(0, 0), (0, 1), (0, 1)]), + ] { + for bound in [i64::MIN, -1, 0, 1, 2] { + check_decision::<_, RootedTreeStorageAssignment>(&RootedTreeArrangement::new( + graph.clone(), + bound, + )); + } + } +} + +#[test] +fn path_partition_requires_distinct_edges_between_distinct_vertices() { + use crate::models::graph::{BoundedComponentSpanningForest, PartitionIntoPathsOfLength2}; + for (edges, expected) in [ + (vec![(0, 0), (1, 1)], false), + (vec![(0, 1), (0, 1)], false), + (vec![(0, 1), (1, 2), (0, 1)], true), + ] { + let source = PartitionIntoPathsOfLength2::new(SimpleGraph::new(3, edges)); + assert_eq!(source.evaluate(&vec![0, 0, 0]).unwrap(), Or(expected)); + check_decision::<_, BoundedComponentSpanningForest>(&source); + check_binary_ilp(&source); + } +} + +#[test] +fn sat_empty_conjunction_and_empty_clause_preserve_opposite_answers() { + use crate::models::formula::Satisfiability; + use crate::models::graph::KColoring; + use crate::models::misc::TimetableDesign; + for clauses in [vec![], vec![CNFClause::new(vec![])]] { + let source = Satisfiability::new(0, clauses.clone()); + check_decision::<_, KSatisfiability>(&source); + check_decision::<_, KColoring>(&source); + check_decision::<_, TimetableDesign>(&KSatisfiability::::new_allow_less(0, clauses)); + } +} + +#[test] +fn numerical_matching_checks_pair_sums_without_wrapping() { + use crate::models::misc::NumericalMatchingWithTargetSums; + for (x, y, target, answer) in [ + (i64::MAX, 1, i64::MIN, false), + (i64::MIN, -1, i64::MAX, false), + (i64::MAX, -1, i64::MAX - 1, true), + ] { + let source = NumericalMatchingWithTargetSums::new(vec![x], vec![y], vec![target]); + assert_eq!(source.evaluate(&vec![0]).unwrap(), Or(answer)); + check_binary_ilp(&source); + } +} diff --git a/src/unit_tests/rules/bicliquecover_bmf.rs b/src/unit_tests/rules/bicliquecover_bmf.rs index 739b63df8..00b3e8d90 100644 --- a/src/unit_tests/rules/bicliquecover_bmf.rs +++ b/src/unit_tests/rules/bicliquecover_bmf.rs @@ -26,7 +26,8 @@ fn test_bicliquecover_to_bmf_overhead_matches_target_shape() { ReduceTo::::reduce_to(&problem).expect("reduction should succeed"); let target = reduction.target_problem(); - let entry = inventory::iter::() + let entry = crate::rules::registry::reduction_entries() + .into_iter() .find(|entry| entry.source_name == "BicliqueCover" && entry.target_name == "BMF") .expect("BicliqueCover -> BMF reduction should be registered"); let source_size = problem.parameters(); diff --git a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs index d438da773..263c6947e 100644 --- a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs @@ -4,6 +4,29 @@ use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; +#[test] +fn sink_self_loop_cannot_supply_commodity_flow() { + let source = DirectedTwoCommodityIntegralFlow::new( + DirectedGraph::new(4, vec![(1, 1)]), + vec![1], + 0, + 1, + 2, + 3, + 1, + 0, + ); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + assert!(!source.evaluate(&vec![1, 0]).unwrap().0); + assert!(reduction + .target_problem() + .evaluate(&vec![1, 0]) + .unwrap() + .value + .is_none()); + assert!(reduction.extract_solution(&vec![1, 0]).is_err()); +} + fn feasible_instance() -> DirectedTwoCommodityIntegralFlow { // 6-vertex network: s1=0, s2=1, t1=4, t2=5 // Arcs: (0,2),(0,3),(1,2),(1,3),(2,4),(2,5),(3,4),(3,5), all cap=1 diff --git a/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs index 5d1fc643f..98f072d86 100644 --- a/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -49,8 +49,8 @@ fn test_exactcoverby3sets_to_algebraicequationsovergf2_extract_solution_is_ident assert_eq!( reduction - .extract_solution(&vec![true, false, true]) + .extract_solution(&vec![true, true, false]) .unwrap(), - vec![true, false, true] + vec![true, true, false] ); } diff --git a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs index 146036ae1..ec36b02dd 100644 --- a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs @@ -65,8 +65,14 @@ fn test_exactcoverby3sets_to_maximumsetpacking_unsatisfiable() { assert_eq!(target.evaluate(&best).unwrap(), Max(Some(1))); // q = 2, but packing value is 1 < 2, so no exact cover exists - let extracted = reduction.extract_solution(&best).unwrap(); - assert!(!source.evaluate(&extracted).unwrap()); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction, + target.evaluate(&best).unwrap(), + ), + crate::types::Or(false), + ); + assert!(reduction.extract_solution(&best).is_err()); } #[test] diff --git a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs index 757f57c49..13bb13148 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs @@ -70,8 +70,14 @@ fn test_exactcoverby3sets_to_minimumaxiomset_no_instance_gap() { .expect("expected an optimal target witness"); assert_eq!(target.evaluate(&optimal).unwrap(), Min(Some(3))); - let extracted = reduction.extract_solution(&optimal).unwrap(); - assert!(!source.evaluate(&extracted).unwrap()); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction, + target.evaluate(&optimal).unwrap(), + ), + crate::types::Or(false), + ); + assert!(reduction.extract_solution(&optimal).is_err()); } #[test] @@ -82,7 +88,7 @@ fn test_extract_solution_reads_only_set_sentence_axioms() { let extracted = reduction .extract_solution(&vec![ - true, false, true, false, false, true, false, false, false, true, true, + false, false, false, false, false, false, false, false, false, true, true, ]) .unwrap(); assert_eq!(extracted, vec![false, false, false, true, true]); diff --git a/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index aeb36f7a0..6a26a8655 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -81,8 +81,14 @@ fn test_exactcoverby3sets_to_minimumfaultdetectiontestset_no_instance_gap() { .expect("expected an optimal target witness"); assert_eq!(target.evaluate(&best).unwrap(), Min(Some(3))); - let extracted = reduction.extract_solution(&best).unwrap(); - assert!(!source.evaluate(&extracted).unwrap()); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction, + target.evaluate(&best).unwrap(), + ), + crate::types::Or(false), + ); + assert!(reduction.extract_solution(&best).is_err()); } #[test] diff --git a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs index ea050fe01..1d674cc43 100644 --- a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs @@ -89,10 +89,7 @@ fn test_exactcoverby3sets_to_staffscheduling_extract_solution() { // Verify the extracted solution is valid in the source assert!(source.evaluate(&extracted).unwrap().0); - // Config with 0 workers everywhere should extract to all-zero (no subsets selected) - let empty_config = vec![0, 0, 0, 0]; - let extracted_empty = result.extract_solution(&empty_config).unwrap(); - assert_eq!(extracted_empty, vec![false, false, false, false]); + assert!(result.extract_solution(&vec![0, 0, 0, 0]).is_err()); } #[test] diff --git a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs index dac72241d..2f2361518 100644 --- a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs @@ -41,9 +41,9 @@ fn test_exactcoverby3sets_to_subsetproduct_extract_solution_is_identity() { assert_eq!( reduction - .extract_solution(&vec![true, false, true]) + .extract_solution(&vec![true, true, false]) .unwrap(), - vec![true, false, true] + vec![true, true, false] ); } diff --git a/src/unit_tests/rules/factoring_ilp.rs b/src/unit_tests/rules/factoring_ilp.rs index 472e97016..a7ec16aa1 100644 --- a/src/unit_tests/rules/factoring_ilp.rs +++ b/src/unit_tests/rules/factoring_ilp.rs @@ -215,7 +215,7 @@ fn test_solution_extraction() { // z_00 = p_0 * q_0 = 0, z_01 = p_0 * q_1 = 0 // z_10 = p_1 * q_0 = 1, z_11 = p_1 * q_1 = 1 // Variables: [p0, p1, q0, q1, z00, z01, z10, z11, c0, c1, c2, c3] - let ilp_solution = vec![0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0]; + let ilp_solution = vec![0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]; let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, (BigUint::from(2u32), BigUint::from(3u32))); diff --git a/src/unit_tests/rules/flowshopscheduling_ilp.rs b/src/unit_tests/rules/flowshopscheduling_ilp.rs index 34da74e87..7b8011ec3 100644 --- a/src/unit_tests/rules/flowshopscheduling_ilp.rs +++ b/src/unit_tests/rules/flowshopscheduling_ilp.rs @@ -4,6 +4,27 @@ use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; +#[test] +fn zero_duration_jobs_preserve_the_common_machine_order() { + let source = FlowShopScheduling::new(2, vec![vec![3, 0], vec![1, 10]], 11); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + // Job 1 precedes job 0, but both finish on machine 1 at time 11. + let assignment = vec![0, 4, 11, 1, 11]; + assert!(reduction + .target_problem() + .evaluate(&assignment) + .unwrap() + .value + .is_some()); + let decoded = reduction.extract_solution(&assignment).unwrap(); + assert_eq!(decoded, vec![1, 0]); + assert_eq!(source.evaluate(&decoded).unwrap(), Or(true)); + + let no_machines = FlowShopScheduling::new(0, vec![vec![], vec![]], 0); + let reduction = ReduceTo::>::reduce_to(&no_machines).unwrap(); + crate::rules::test_helpers::assert_bf_vs_ilp(&no_machines, &reduction); +} + #[test] fn test_flowshopscheduling_to_ilp_closed_loop() { // 2 machines, 3 jobs, deadline 10 diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index cf109625f..9b59dc234 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -10,7 +10,7 @@ use crate::models::misc::Knapsack; use crate::models::set::MaximumSetPacking; use crate::registry::ProblemCategory; use crate::rules::graph::{ReductionMode, ReductionStep}; -use crate::rules::registry::{ReductionEntry, ReductionParameterDeclarations}; +use crate::rules::registry::ReductionParameterDeclarations; use crate::rules::traits::{AggregateReductionResult, ReductionResult}; use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; @@ -1919,7 +1919,7 @@ fn test_parameter_names_returns_own_fields() { fn parameter_contract_variables_are_registered_source_fields() { let graph = ReductionGraph::new(); - for entry in inventory::iter:: { + for entry in crate::rules::registry::reduction_entries() { let declarations = (entry.parameter_declarations_fn)(); let input_vars: std::collections::HashSet<_> = declarations .fields diff --git a/src/unit_tests/rules/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index f27ffdb19..e2d428dbf 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -2,7 +2,8 @@ use super::*; #[test] fn constraint_count_is_only_an_upper_bound() { - let entry = inventory::iter::() + let entry = crate::rules::registry::reduction_entries() + .into_iter() .find(|entry| entry.source_name == "MaximumSetPacking" && entry.target_name == "ILP") .unwrap(); assert_eq!( diff --git a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs index 3f0d6b569..ab9a950a8 100644 --- a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -281,7 +281,7 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_two_literal_clause_no #[test] fn test_long_clauses_preserve_assignments() { - let entry = inventory::iter:: + let entry = crate::rules::registry::reduction_entries() .into_iter() .find(|e| { e.source_name == NAESatisfiability::NAME @@ -342,7 +342,7 @@ fn test_auxiliary_literal_overflow_is_an_error() { #[test] fn test_registered_partition_value_mapping() { let source = NAESatisfiability::new(1, vec![]); - let entry = inventory::iter:: + let entry = crate::rules::registry::reduction_entries() .into_iter() .find(|e| { e.source_name == NAESatisfiability::NAME diff --git a/src/unit_tests/rules/naesatisfiability_setsplitting.rs b/src/unit_tests/rules/naesatisfiability_setsplitting.rs index 522d9bf04..2be78646b 100644 --- a/src/unit_tests/rules/naesatisfiability_setsplitting.rs +++ b/src/unit_tests/rules/naesatisfiability_setsplitting.rs @@ -54,9 +54,9 @@ fn test_naesatisfiability_to_setsplitting_extract_solution_uses_positive_literal assert_eq!( reduction - .extract_solution(&vec![true, false, true, false, true, false]) + .extract_solution(&vec![true, true, true, false, false, false]) .unwrap(), - vec![true, false, true] + vec![true, true, true] ); } diff --git a/src/unit_tests/rules/partition_binpacking.rs b/src/unit_tests/rules/partition_binpacking.rs index 4d0185802..ee004b349 100644 --- a/src/unit_tests/rules/partition_binpacking.rs +++ b/src/unit_tests/rules/partition_binpacking.rs @@ -49,6 +49,12 @@ fn test_partition_to_binpacking_odd_total_is_not_satisfying() { let value = target.evaluate(&best).unwrap(); assert_eq!(value, Min(Some(3))); - let extracted = reduction.extract_solution(&best).unwrap(); - assert!(!source.evaluate(&extracted).unwrap()); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction, + target.evaluate(&best).unwrap(), + ), + crate::types::Or(false), + ); + assert!(reduction.extract_solution(&best).is_err()); } diff --git a/src/unit_tests/rules/partition_knapsack.rs b/src/unit_tests/rules/partition_knapsack.rs index c4d2274df..093a3e8e8 100644 --- a/src/unit_tests/rules/partition_knapsack.rs +++ b/src/unit_tests/rules/partition_knapsack.rs @@ -41,6 +41,12 @@ fn test_partition_to_knapsack_odd_total_is_not_satisfying() { assert_eq!(target.evaluate(&best).unwrap(), Max(Some(5))); - let extracted = reduction.extract_solution(&best).unwrap(); - assert!(!source.evaluate(&extracted).unwrap()); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction, + target.evaluate(&best).unwrap(), + ), + crate::types::Or(false), + ); + assert!(reduction.extract_solution(&best).is_err()); } diff --git a/src/unit_tests/rules/partition_subsetsum.rs b/src/unit_tests/rules/partition_subsetsum.rs index 2e87577e8..6cc6380eb 100644 --- a/src/unit_tests/rules/partition_subsetsum.rs +++ b/src/unit_tests/rules/partition_subsetsum.rs @@ -47,11 +47,7 @@ fn test_partition_to_subsetsum_odd_total() { let witness = BruteForce::new().solve(target).unwrap(); assert!(witness.is_none()); - let error = reduction.extract_solution(&vec![]).unwrap_err(); - assert_eq!( - error.to_string(), - "expected 3 subset-selection values, got 0" - ); + assert!(reduction.extract_solution(&vec![]).is_err()); } #[test] diff --git a/src/unit_tests/rules/partition_sumofsquarespartition.rs b/src/unit_tests/rules/partition_sumofsquarespartition.rs index 5f5a46fc6..04ee9584e 100644 --- a/src/unit_tests/rules/partition_sumofsquarespartition.rs +++ b/src/unit_tests/rules/partition_sumofsquarespartition.rs @@ -24,19 +24,21 @@ fn test_partition_to_sumofsquarespartition_closed_loop() { // Even-sum but unbalanced NO case: sizes [1, 1, 1, 5], S = 8 but no subset sums to 4. // The optimal SoSP witness is {5}, {1,1,1} -> 25 + 9 = 34 > S^2/2 = 32. - // Partition::evaluate on that witness must return Or(false). + // The completed optimum maps to NO; there is no source witness. let (source_no_even, reduction_no_even) = reduce_partition(&[1, 1, 1, 5]); let target_no_even = reduction_no_even.target_problem(); let solver = BruteForce::new(); let target_witnesses = solver.find_all_witnesses(target_no_even).unwrap(); assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction_no_even.extract_solution(witness).unwrap(); - assert_eq!(extracted.len(), source_no_even.num_elements()); - assert!( - !source_no_even.evaluate(&extracted).unwrap().0, - "even-sum but unbalanced NO Partition: extracted witness {extracted:?} should not satisfy source" + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction_no_even, + target_no_even.evaluate(witness).unwrap(), + ), + crate::types::Or(false), ); + assert!(reduction_no_even.extract_solution(witness).is_err()); } // Confirm the source is genuinely NO via direct solve. let direct_witness = solver.solve(&source_no_even).unwrap(); @@ -48,11 +50,14 @@ fn test_partition_to_sumofsquarespartition_closed_loop() { let target_witnesses_odd = solver.find_all_witnesses(target_no_odd).unwrap(); assert!(!target_witnesses_odd.is_empty()); for witness in &target_witnesses_odd { - let extracted = reduction_no_odd.extract_solution(witness).unwrap(); - assert!( - !source_no_odd.evaluate(&extracted).unwrap().0, - "odd-sum NO Partition: extracted witness {extracted:?} should not satisfy source" + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction_no_odd, + target_no_odd.evaluate(witness).unwrap(), + ), + crate::types::Or(false), ); + assert!(reduction_no_odd.extract_solution(witness).is_err()); } assert!(solver.solve(&source_no_odd).unwrap().is_none()); } @@ -107,19 +112,14 @@ fn test_partition_to_sumofsquarespartition_singleton_sentinel() { assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness).unwrap(); - assert_eq!(extracted.len(), source.num_elements()); assert_eq!( - extracted, - witness[..source.num_elements()] - .iter() - .map(|&value| value != 0) - .collect::>() - ); - assert!( - !source.evaluate(&extracted).unwrap().0, - "singleton Partition: extracted witness must yield Or(false)" + crate::rules::AggregateReductionResult::extract_value( + &reduction, + target.evaluate(witness).unwrap(), + ), + crate::types::Or(false), ); + assert!(reduction.extract_solution(witness).is_err()); } // Direct solve confirms the source is NO. diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index 7259ae0eb..f6a909138 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -1,6 +1,101 @@ use super::*; use crate::expr::Expr; +#[test] +fn registered_aggregate_mappings_share_the_witness_result() { + use crate::models::formula::{CNFClause, Satisfiability}; + use crate::traits::Problem; + + let source = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); + let entry = reduction_entries() + .into_iter() + .find(|entry| { + entry.source_name == Satisfiability::NAME && entry.target_name == "KSatisfiability" + }) + .unwrap(); + let witness = entry.reduce_fn.unwrap()(&source).unwrap(); + let view = entry.aggregate_view_fn.unwrap()(witness.as_ref()).unwrap(); + assert!(std::ptr::eq( + witness.target_problem_any(), + view.target_problem_any() + )); +} + +#[test] +fn aggregate_executors_reject_wrong_source_types() { + for mapping in inventory::iter:: { + let error = (mapping.reduce_fn)(&()) + .err() + .expect("wrong source type must fail"); + assert!( + matches!(error, crate::rules::ReductionError::SourceTypeMismatch { + source_problem, target_problem, .. + } if source_problem == mapping.source_name && target_problem == mapping.target_name) + ); + } +} + +#[test] +fn registered_executors_preserve_construction_errors() { + use crate::models::misc::Partition; + let source = Partition::new(vec![1_i64 << 61, 1_i64 << 61]).unwrap(); + let entry = reduction_entries() + .into_iter() + .find(|entry| entry.source_name == "Partition" && entry.target_name == "OpenShopScheduling") + .unwrap(); + let witness_error = entry.reduce_fn.unwrap()(&source).err().unwrap(); + let aggregate_error = entry.reduce_aggregate_fn.unwrap()(&source).err().unwrap(); + for error in [witness_error, aggregate_error] { + assert!(matches!( + error, + crate::rules::ReductionError::Construction { + source_problem: "Partition", + target_problem: "OpenShopScheduling", + cause: crate::registry::ConstructionError::IntegerOverflow(_), + } + )); + } +} + +#[test] +fn aggregate_registration_matches_exact_endpoints_and_rejects_conflicts() { + let mapping = AggregateMappingEntry { + source_name: "Source", + target_name: "Target", + source_variant_fn: || vec![("weight", "i64"), ("graph", "SimpleGraph")], + target_variant_fn: Vec::new, + reduce_fn: |_| unreachable!(), + view_fn: |_| unreachable!(), + }; + let mut entry = entry_with(ReductionParameterDeclarations::default); + entry.source_variant_fn = || vec![("graph", "SimpleGraph"), ("weight", "i64")]; + entry.reduce_fn = Some(|_| unreachable!()); + let mut wrong_variant = entry; + wrong_variant.source_variant_fn = Vec::new; + let mut entries = [wrong_variant, entry]; + attach_aggregate_mapping(&mut entries, &mapping); + assert!(!entries[0].capabilities().aggregate); + assert!(entries[1].capabilities().aggregate); + + let mut no_executor = entry; + no_executor.reduce_fn = None; + let mut turing = entry; + turing.turing = true; + for mut invalid in [ + vec![], + vec![wrong_variant], + vec![entry, entry], + vec![no_executor], + vec![turing], + vec![entries[1]], + ] { + assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + attach_aggregate_mapping(&mut invalid, &mapping); + })) + .is_err()); + } +} + fn entry_with(declarations: fn() -> ReductionParameterDeclarations) -> ReductionEntry { ReductionEntry { source_name: "Source", diff --git a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs index 28a7a9df2..2b583d068 100644 --- a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs @@ -27,6 +27,20 @@ fn all_assignments(num_vars: usize) -> Vec> { .collect() } +#[test] +fn repeated_literals_do_not_relax_the_flow_bottleneck() { + let source = Satisfiability::new( + 1, + vec![CNFClause::new(vec![1, 1]), CNFClause::new(vec![-1, -1])], + ); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); + for assignment in all_assignments(1) { + let flow = reduction.encode_assignment(&assignment); + assert!(!reduction.target_problem().evaluate(&flow).unwrap().0); + assert!(reduction.extract_solution(&flow).is_err()); + } +} + #[test] fn test_satisfiability_to_integralflowhomologousarcs_closed_loop() { let source = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); diff --git a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs index 9caba9ab6..4016a402e 100644 --- a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs +++ b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs @@ -52,12 +52,9 @@ fn test_subsetsum_to_integerexpressionmembership_extract_solution_matches_choice .unwrap(), issue_example_source_config() ); - assert_eq!( - reduction - .extract_solution(&vec![true, false, false, true]) - .unwrap(), - vec![true, false, false, true] - ); + assert!(reduction + .extract_solution(&vec![true, false, false, true]) + .is_err()); } #[test] diff --git a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs index 9b3edb61b..0be0f5096 100644 --- a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -128,7 +128,7 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_sentinel_q_zero() { #[test] fn test_threedimensionalmatching_to_minimumweightdecoding_sentinel_no_triples() { - // q >= 1, T = []: sentinel target, extracted S = ∅, source.evaluate(∅).unwrap() = Or(false). + // A feasible sentinel target maps to NO for a nonempty source universe. for q in [1, 2, 3] { let (source, reduction) = reduce_tdm(q, vec![]); let target = reduction.target_problem(); @@ -139,13 +139,14 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_sentinel_no_triples() let target_witnesses = solver.find_all_witnesses(target).unwrap(); assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness).unwrap(); - assert_eq!(extracted.len(), source.num_triples()); - // Empty triple set cannot cover non-empty universe. - assert!( - !source.evaluate(&extracted).unwrap().0, - "q = {q}, T = []: empty matching must be NO" + assert_eq!( + crate::rules::AggregateReductionResult::extract_value( + &reduction, + target.evaluate(witness).unwrap(), + ), + crate::types::Or(false), ); + assert!(reduction.extract_solution(witness).is_err()); } // Direct solve confirms the source is NO. assert!(solver.solve(&source).unwrap().is_none()); diff --git a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs index e76e50b55..ed7f8d383 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs @@ -38,7 +38,7 @@ fn test_threedimensionalmatching_to_threepartition_q1_overhead_and_bounds() { #[test] fn empty_triple_sets_preserve_empty_and_nonempty_universe_truth() { - let entry = inventory::iter:: + let entry = crate::rules::registry::reduction_entries() .into_iter() .find(|e| { e.source_name == ThreeDimensionalMatching::NAME && e.target_name == ThreePartition::NAME diff --git a/src/unit_tests/rules/travelingsalesman_qubo.rs b/src/unit_tests/rules/travelingsalesman_qubo.rs index f688c8d1b..894abd136 100644 --- a/src/unit_tests/rules/travelingsalesman_qubo.rs +++ b/src/unit_tests/rules/travelingsalesman_qubo.rs @@ -125,7 +125,7 @@ fn test_tour_value_mapping_and_invalid_configurations() { crate::rules::AggregateReductionResult::extract_value(&result, Min(Some(i64::MIN))), Min(Some(i64::MIN + result.objective_offset)) ); - let entry = inventory::iter:: + let entry = crate::rules::registry::reduction_entries() .into_iter() .find(|entry| entry.source_name == "TravelingSalesman" && entry.target_name == "QUBO") .unwrap(); diff --git a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs index feb60cb9b..9180733e6 100644 --- a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs @@ -17,6 +17,32 @@ fn feasible_instance() -> UndirectedFlowLowerBounds { ) } +#[test] +fn sink_self_loop_cannot_supply_net_flow() { + let source = UndirectedFlowLowerBounds::new( + SimpleGraph::new(2, vec![(1, 1)]), + vec![1], + vec![0], + 0, + 1, + 1, + ); + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let assignment = vec![1, 0, 1]; + assert!(reduction + .target_problem() + .evaluate(&assignment) + .unwrap() + .value + .is_none()); + assert!(reduction.extract_solution(&assignment).is_err()); + assert!(matches!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + )); +} + fn infeasible_instance() -> UndirectedFlowLowerBounds { // 3-vertex path: edges (0,1) cap=2 lower=2, (1,2) cap=1 lower=0 // source=0, sink=2, requirement=2: need 2 units but edge (1,2) cap=1 limits to 1 diff --git a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs index 1d8d087ed..bf0efae03 100644 --- a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -19,6 +19,36 @@ fn feasible_instance() -> UndirectedTwoCommodityIntegralFlow { ) } +#[test] +fn sink_self_loop_cannot_supply_either_commodity() { + for (first, second) in [(1, 0), (0, 1)] { + let source = UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(2, vec![(1, 1)]), + vec![1], + 0, + 1, + 0, + 1, + first, + second, + ); + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let assignment = vec![first, 0, second, 0, 1, 1]; + assert!(reduction + .target_problem() + .evaluate(&assignment) + .unwrap() + .value + .is_none()); + assert!(reduction.extract_solution(&assignment).is_err()); + assert!(matches!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + )); + } +} + fn infeasible_instance() -> UndirectedTwoCommodityIntegralFlow { // Same topology but requirements that can't be met simultaneously // path graph: 0-1-2; cap=1 everywhere; s1=0,t1=2 req=1; s2=0,t2=2 req=1 @@ -56,7 +86,8 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_overhead_matches_target() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); - let entry = inventory::iter::() + let entry = crate::rules::registry::reduction_entries() + .into_iter() .find(|entry| { entry.source_name == "UndirectedTwoCommodityIntegralFlow" && entry.target_name == "ILP" From 1cfa02b512ae36a6cc4027d911e8f5d0e2256c76 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 19 Sep 2026 23:10:47 +0800 Subject: [PATCH 08/44] Isolate HiGHS execution in a concrete adapter --- Cargo.toml | 2 +- docs/src/design.md | 2 +- problemreductions-cli/src/dispatch.rs | 50 +++-- problemreductions-cli/tests/cli_tests.rs | 5 +- src/solvers/ilp/adapter.rs | 219 +++++++++++++++++++++ src/solvers/ilp/mod.rs | 5 +- src/solvers/ilp/solver.rs | 184 +----------------- src/solvers/registry.rs | 4 +- src/unit_tests/solvers/ilp/adapter.rs | 235 +++++++++++++++++++++++ src/unit_tests/solvers/ilp/solver.rs | 22 +-- 10 files changed, 493 insertions(+), 235 deletions(-) create mode 100644 src/solvers/ilp/adapter.rs create mode 100644 src/unit_tests/solvers/ilp/adapter.rs diff --git a/Cargo.toml b/Cargo.toml index 5b2a71ea5..3117e15e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ thiserror = "2.0.20" num-bigint = "0.4.8" num-rational = "0.4.2" num-traits = "0.2.19" -good_lp = { version = "=1.14.2", default-features = false, features = ["highs"] } +highs = "2.4.0" inventory = "0.3.24" rand = "0.10.2" criterion = { version = "0.8.2", optional = true } diff --git a/docs/src/design.md b/docs/src/design.md index 66ae45c73..ac279977c 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -440,7 +440,7 @@ proved infeasibility, and `Err` reports an operational failure. | Solver | Description | |--------|-------------| | **BruteForce** | Enumerates a registered finite search space and returns an optimal or satisfying solution. Used for testing and verification. | -| **ILPSolver** | Executes a problem's registered ILP pipeline. Each pipeline terminates at `ILP` or `ILP`, which is solved by HiGHS via `good_lp`. | +| **ILPSolver** | Executes a problem's registered ILP pipeline. Each pipeline terminates at `ILP` or `ILP`, which is solved through the concrete `HighsAdapter`. The adapter owns numerical conversion, backend settings, termination status, and returned-assignment validation. Optimality and infeasibility follow HiGHS numerical tolerances; the adapter does not provide exact proofs. | ILP results are optimal or infeasible according to HiGHS numerical tolerances; zero MIP gaps do not imply mathematical exactness. Integer extraction rounds diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index d1a24e118..3a65ede7d 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -368,8 +368,9 @@ impl BundleReplay { Ok(chain.extract_value(value)?) } - /// Execute recovery of an exact completed result. The caller establishes - /// optimality or infeasibility; evaluating a candidate cannot establish it. + /// Execute recovery of a completed result. The caller establishes optimality + /// or infeasibility under its solver contract, including numerical tolerances; + /// evaluating a candidate cannot establish it. pub(crate) fn extract_result(&self, result: &SolveOutcome) -> Result { use problemreductions::rules::ExtractionError; let BundleChain::Witness(steps) = &self.chain else { @@ -452,23 +453,7 @@ impl BundleReplay { let target_result = self.target.solve(request)?; let solver = target_result.solver; let target_outcome = target_result.outcome; - let source_outcome = match (&solver, &target_outcome) { - // A numerical optimum does not establish an exact negative threshold. - ( - problemreductions::solvers::SolverExecution::Ilp { .. }, - SolveOutcome::Optimal { solution, .. }, - ) => { - let (solution, evaluation) = self.extract(solution)?; - SolveOutcome::Optimal { - solution, - evaluation, - } - } - (problemreductions::solvers::SolverExecution::Ilp { .. }, SolveOutcome::Infeasible) => { - anyhow::bail!("numerical target infeasibility does not certify the source result") - } - _ => self.extract_result(&target_outcome)?, - }; + let source_outcome = self.extract_result(&target_outcome)?; Ok(BundleSolveResult { source_name: self.source_name.clone(), @@ -740,17 +725,30 @@ mod tests { problem_step::>(), ], ); - let result = replay.solve(SolverRequest::BruteForce).unwrap(); - assert_eq!( - matches!(result.source_outcome, SolveOutcome::Optimal { .. }), - second == 1 - ); - if second == -1 { - assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); + for solver in [SolverRequest::BruteForce, SolverRequest::Ilp] { + let result = replay.solve(solver).unwrap(); + assert_eq!( + matches!(result.source_outcome, SolveOutcome::Optimal { .. }), + second == 1 + ); + if second == -1 { + assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); + } } } } + #[test] + fn ilp_bundle_recovers_target_infeasibility_under_backend_contract() { + use problemreductions::models::formula::{CNFClause, NAESatisfiability, Satisfiability}; + let source = + Satisfiability::new(1, vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])]); + let replay = replay(&source, vec![problem_step::()]); + let result = replay.solve(SolverRequest::Ilp).unwrap(); + assert!(matches!(result.target_outcome, SolveOutcome::Infeasible)); + assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); + } + #[test] fn aggregate_only_bundle_executes_and_recovers_without_witnesses() { use problemreductions::rules::{ReductionMode, ReductionPath, ReductionStep}; diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 1f3d891b5..da7a4897f 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -9659,12 +9659,13 @@ fn test_completed_decision_recovery_and_aggregate_cli() { ]) .output() .unwrap(); - assert_eq!( + assert!( numerical.status.success(), - bound == 2, "{}", String::from_utf8_lossy(&numerical.stderr) ); + let numerical: serde_json::Value = serde_json::from_slice(&numerical.stdout).unwrap(); + assert_eq!(numerical["status"], expected); for evaluation in [None, Some("Min(2)")] { let mut external = json!({"status":"optimal","solution":[true,true,false],"problem":"MinimumVertexCover","solver":{"kind":"brute-force"}}); diff --git a/src/solvers/ilp/adapter.rs b/src/solvers/ilp/adapter.rs new file mode 100644 index 000000000..2fa797aca --- /dev/null +++ b/src/solvers/ilp/adapter.rs @@ -0,0 +1,219 @@ +//! Numerical execution of a native ILP through HiGHS. +//! +//! This module knows only ILP data and backend settings. Registry lookup, +//! type-erased dispatch, and reduction-chain extraction belong to the caller. +//! Optimality and infeasibility follow HiGHS numerical tolerances, not exact proofs. + +use crate::models::algebraic::{Comparison, ILPCoefficient, ObjectiveSense, VariableDomain, ILP}; +use crate::types::{i64_to_exact_f64, MAX_EXACT_F64_INTEGER}; +use highs::{HighsModelStatus, HighsSolutionStatus, RowProblem, Sense}; + +use super::solver::ILPSolveError; + +/// Backend representation is an execution concern, not a model capability. +pub(crate) trait BackendCoefficient: ILPCoefficient { + fn to_backend_number(self) -> Result; +} +impl BackendCoefficient for i64 { + fn to_backend_number(self) -> Result { + Ok(i64_to_exact_f64(self)?) + } +} +impl BackendCoefficient for f64 { + fn to_backend_number(self) -> Result { + Ok(self) + } +} + +fn accept_backend_status(status: HighsModelStatus) -> Result<(), ILPSolveError> { + match status { + HighsModelStatus::Optimal => Ok(()), + HighsModelStatus::Infeasible => Err(ILPSolveError::Infeasible), + HighsModelStatus::Unbounded => Err(ILPSolveError::Unbounded), + HighsModelStatus::ReachedTimeLimit => Err(ILPSolveError::Timeout), + other => Err(ILPSolveError::BackendFailure(format!( + "HiGHS status: {other:?}" + ))), + } +} + +pub(crate) struct HighsAdapter { + time_limit: Option, +} + +impl HighsAdapter { + pub(crate) fn new(time_limit: Option) -> Self { + Self { time_limit } + } + pub(crate) fn solve(&self, problem: &ILP) -> Result, ILPSolveError> + where + V: VariableDomain, + C: BackendCoefficient, + { + if self + .time_limit + .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) + { + return Err(ILPSolveError::BackendFailure( + "time limit must be finite and nonnegative".into(), + )); + } + self.solve_with_objective(problem, problem.objective()) + } + + fn solve_with_objective( + &self, + problem: &ILP, + objective_terms: &[(usize, C)], + ) -> Result, ILPSolveError> + where + V: VariableDomain, + C: BackendCoefficient, + { + let n = problem.num_vars(); + if n == 0 { + return if problem + .is_feasible(&[]) + .map_err(|error| ILPSolveError::InvalidSolution(error.to_string()))? + { + Ok(vec![]) + } else { + Err(ILPSolveError::Infeasible) + }; + } + + if n > i32::MAX as usize || problem.constraints().len() > i32::MAX as usize { + return Err(ILPSolveError::BackendFailure( + "ILP dimensions exceed the HiGHS index representation".into(), + )); + } + let mut backend = RowProblem::new(); + let mut costs = vec![0.0; n]; + for &(index, coefficient) in objective_terms { + costs[index] = coefficient.to_backend_number()?; + } + let columns = problem + .variables() + .iter() + .enumerate() + .map(|(index, bounds)| { + let lower = bounds + .lower_bound() + .map(i64_to_exact_f64) + .transpose()? + .unwrap_or(f64::NEG_INFINITY); + let upper = bounds + .upper_bound() + .map(i64_to_exact_f64) + .transpose()? + .unwrap_or(f64::INFINITY); + Ok(backend.add_integer_column(costs[index], lower..=upper)) + }) + .collect::, ILPSolveError>>()?; + let mut terms = Vec::new(); + for constraint in problem.constraints() { + terms.clear(); + for &(index, coefficient) in constraint.terms() { + terms.push((columns[index], coefficient.to_backend_number()?)); + } + let rhs = constraint.rhs().to_backend_number()?; + let (lower, upper) = match constraint.comparison() { + Comparison::Le => (f64::NEG_INFINITY, rhs), + Comparison::Ge => (rhs, f64::INFINITY), + Comparison::Eq => (rhs, rhs), + }; + backend.add_row(lower..=upper, &terms); + } + let sense = match problem.sense() { + ObjectiveSense::Minimize => Sense::Minimise, + ObjectiveSense::Maximize => Sense::Maximise, + }; + let mut model = backend.try_optimise(sense).map_err(|error| { + ILPSolveError::BackendFailure(format!("loading HiGHS model: {error:?}")) + })?; + model.make_quiet(); + for (option, value) in [("random_seed", 0), ("threads", 1)] { + model.try_set_option(option, value).map_err(|error| { + ILPSolveError::BackendFailure(format!("setting {option}: {error:?}")) + })?; + } + for option in ["mip_rel_gap", "mip_abs_gap"] { + model.try_set_option(option, 0.0).map_err(|error| { + ILPSolveError::BackendFailure(format!("setting {option}: {error:?}")) + })?; + } + model.try_set_option("parallel", "off").map_err(|error| { + ILPSolveError::BackendFailure(format!("setting parallel: {error:?}")) + })?; + if let Some(seconds) = self.time_limit { + model + .try_set_option("time_limit", seconds) + .map_err(|error| { + ILPSolveError::BackendFailure(format!("setting time_limit: {error:?}")) + })?; + } + let solved = model + .try_solve() + .map_err(|error| ILPSolveError::BackendFailure(format!("running HiGHS: {error:?}")))?; + if solved.status() == HighsModelStatus::UnboundedOrInfeasible && !objective_terms.is_empty() + { + // A zero objective cannot be unbounded, so feasibility distinguishes these states. + self.solve_with_objective(problem, &[])?; + return Err(ILPSolveError::Unbounded); + } + accept_backend_status(solved.status())?; + if solved.primal_solution_status() != HighsSolutionStatus::Feasible { + return Err(ILPSolveError::BackendFailure( + "HiGHS returned no feasible primal solution".into(), + )); + } + decode_and_validate(problem, solved.get_solution().columns().iter().copied()) + } +} + +fn decode_and_validate( + problem: &ILP, + values: impl IntoIterator, +) -> Result, ILPSolveError> { + let result = values + .into_iter() + .enumerate() + .map(|(index, value)| { + if !value.is_finite() { + return Err(ILPSolveError::InvalidSolution(format!( + "variable {index} is non-finite" + ))); + } + let rounded = value.round(); + if (value - rounded).abs() > 1e-6 { + return Err(ILPSolveError::InvalidSolution(format!( + "variable {index} has non-integral value {value}" + ))); + } + if rounded.abs() > MAX_EXACT_F64_INTEGER as f64 { + return Err(ILPSolveError::InvalidSolution(format!( + "variable {index} value {rounded} exceeds exact f64 integer transport" + ))); + } + Ok(rounded as i64) + }) + .collect::, _>>()?; + if !problem + .is_feasible(&result) + .map_err(|error| ILPSolveError::InvalidSolution(error.to_string()))? + { + return Err(ILPSolveError::InvalidSolution( + "the rounded assignment violates the ILP; this may be caused by numerical tolerances. \ + Consider tightening the backend's integer feasibility tolerance" + .into(), + )); + } + problem + .evaluate_objective(&result) + .map_err(|error| ILPSolveError::InvalidSolution(error.to_string()))?; + Ok(result) +} + +#[cfg(test)] +#[path = "../../unit_tests/solvers/ilp/adapter.rs"] +mod tests; diff --git a/src/solvers/ilp/mod.rs b/src/solvers/ilp/mod.rs index 55556679e..96f2c55cb 100644 --- a/src/solvers/ilp/mod.rs +++ b/src/solvers/ilp/mod.rs @@ -1,8 +1,9 @@ //! ILP (Integer Linear Programming) solver module. //! -//! This module provides an ILP solver using the HiGHS solver via the `good_lp` crate. -//! It is only available when the `ilp` feature is enabled. +//! This module provides an ILP solver using HiGHS. +//! Numerical backend details are isolated in the HiGHS adapter. +mod adapter; mod solver; pub use solver::{ILPSolveError, ILPSolver}; diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 1475592f1..a1d8722de 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -1,15 +1,10 @@ //! ILP solver implementation using HiGHS. -use crate::models::algebraic::{Comparison, ObjectiveSense, VariableDomain, ILP}; +use super::adapter::HighsAdapter; +use crate::models::algebraic::ILP; use crate::solvers::registry::solver_capability_registry; use crate::solvers::ExactProblemKey; use crate::traits::Problem; -use crate::types::{i64_to_exact_f64, MAX_EXACT_F64_INTEGER}; -use good_lp::highs; -use good_lp::solvers::highs::HighsParallelType; -use good_lp::{ - variable, ProblemVariables, ResolutionError, Solution, SolutionStatus, SolverModel, Variable, -}; /// A failure to produce an ILP solution optimal within backend numerical tolerances. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] @@ -57,15 +52,6 @@ pub enum ILPSolveError { Reduction(#[from] crate::rules::ReductionError), } -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. /// /// Registered reductions map a source problem to an `ILP` terminal, @@ -134,175 +120,13 @@ impl ILPSolver { Ok(solution) } - fn solve_backend(&self, problem: &ILP) -> Result, ILPSolveError> - where - V: VariableDomain, - { - if self - .time_limit - .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) - { - return Err(ILPSolveError::BackendFailure( - "time limit must be finite and nonnegative".into(), - )); - } - self.solve_with_objective(problem, problem.objective()) - } - - fn solve_with_objective( - &self, - problem: &ILP, - objective_terms: &[(usize, f64)], - ) -> Result, ILPSolveError> - where - V: VariableDomain, - { - let n = problem.num_vars(); - if n == 0 { - return if problem - .is_feasible(&[]) - .map_err(|error| ILPSolveError::InvalidSolution(error.to_string()))? - { - Ok(vec![]) - } else { - Err(ILPSolveError::Infeasible) - }; - } - - let mut vars_builder = ProblemVariables::new(); - let vars: Vec = problem - .variables() - .iter() - .map(|variable_bounds| { - let mut definition = variable().integer(); - if let Some(lower) = variable_bounds.lower_bound() { - definition = definition.min(i64_to_exact_f64(lower)?); - } - if let Some(upper) = variable_bounds.upper_bound() { - definition = definition.max(i64_to_exact_f64(upper)?); - } - Ok(vars_builder.add(definition)) - }) - .collect::>()?; - - // Build objective expression - let objective: good_lp::Expression = objective_terms - .iter() - .map(|&(var_idx, coefficient)| coefficient * vars[var_idx]) - .sum(); - - // Build the model with objective - let unsolved = match problem.sense() { - ObjectiveSense::Maximize => vars_builder.maximise(&objective), - ObjectiveSense::Minimize => vars_builder.minimise(&objective), - }; - - // Create the solver model - let mut model = { - let mut model = unsolved - .using(highs) - .set_option("random_seed", 0i32) - .set_option("mip_rel_gap", 0.0) - .set_option("mip_abs_gap", 0.0) - .set_parallel(HighsParallelType::Off) - .set_threads(1); - if let Some(seconds) = self.time_limit { - model = model.set_time_limit(seconds); - } - model - }; - - // Add constraints - for constraint in problem.constraints() { - // Build left-hand side expression - let lhs: good_lp::Expression = constraint - .terms() - .iter() - .map(|&(var_idx, coefficient)| coefficient * vars[var_idx]) - .sum(); - - let rhs = constraint.rhs(); - - // Create the constraint based on comparison type - let good_lp_constraint = match constraint.comparison() { - Comparison::Le => lhs.leq(rhs), - Comparison::Ge => lhs.geq(rhs), - Comparison::Eq => lhs.eq(rhs), - }; - - model = model.with(good_lp_constraint); - } - - // Solve - let solution = match model.solve() { - Ok(solution) => solution, - Err(ResolutionError::Infeasible) - if !objective_terms.is_empty() - && problem.variables().iter().any(|variable| { - variable.lower_bound().is_none() || variable.upper_bound().is_none() - }) => - { - // A zero objective cannot be unbounded, so feasibility distinguishes the two states. - self.solve_with_objective(problem, &[])?; - return Err(ILPSolveError::Unbounded); - } - Err(error) => return Err(classify_backend_error(error, self.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(), - )); - } - } - - let result: Vec = vars - .iter() - .enumerate() - .map(|(index, v)| { - let value = solution.value(*v); - if !value.is_finite() { - return Err(ILPSolveError::InvalidSolution(format!( - "variable {index} is non-finite" - ))); - } - let rounded = value.round(); - if (value - rounded).abs() > 1e-6 { - return Err(ILPSolveError::InvalidSolution(format!( - "variable {index} has non-integral value {value}" - ))); - } - if rounded.abs() > MAX_EXACT_F64_INTEGER as f64 { - return Err(ILPSolveError::InvalidSolution(format!( - "variable {index} value {rounded} exceeds exact f64 integer transport" - ))); - } - Ok(rounded as i64) - }) - .collect::>()?; - - if !problem - .is_feasible(&result) - .map_err(|error| ILPSolveError::InvalidSolution(error.to_string()))? - { - return Err(ILPSolveError::InvalidSolution( - "the rounded assignment violates the ILP".into(), - )); - } - - Ok(result) - } - /// 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_backend(ilp); + return HighsAdapter::new(self.time_limit).solve(ilp); } if let Some(ilp) = any.downcast_ref::>() { - return self.solve_backend(ilp); + return HighsAdapter::new(self.time_limit).solve(ilp); } Err(ILPSolveError::UnsupportedProblemType) } diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index 9521d0c8e..2faa34a77 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -151,8 +151,8 @@ impl CompiledIlpPipeline { reductions[index - 1].target_problem_any() }; let aggregate = view(step.as_ref())?; - // A numerical target optimum can establish YES through a source witness, - // but a missed threshold alone cannot establish NO. + // This pipeline returns a witness, not an aggregate result. A negative + // decision value has no source witness for the extractor to recover. let value = aggregate.extract_value_from_solution_dyn(source_solution.as_ref())?; let source = crate::registry::find_variant_entry( &self.path[index].name, diff --git a/src/unit_tests/solvers/ilp/adapter.rs b/src/unit_tests/solvers/ilp/adapter.rs new file mode 100644 index 000000000..3f4c61fe1 --- /dev/null +++ b/src/unit_tests/solvers/ilp/adapter.rs @@ -0,0 +1,235 @@ +use super::*; +use crate::models::algebraic::{IntegerVariable, LinearConstraint}; + +#[test] +fn backend_statuses_preserve_termination_causes() { + assert_eq!(accept_backend_status(HighsModelStatus::Optimal), Ok(())); + assert_eq!( + accept_backend_status(HighsModelStatus::Infeasible), + Err(ILPSolveError::Infeasible) + ); + assert_eq!( + accept_backend_status(HighsModelStatus::Unbounded), + Err(ILPSolveError::Unbounded) + ); + assert_eq!( + accept_backend_status(HighsModelStatus::ReachedTimeLimit), + Err(ILPSolveError::Timeout) + ); + for status in [ + HighsModelStatus::UnboundedOrInfeasible, + HighsModelStatus::SolveError, + HighsModelStatus::ObjectiveBound, + HighsModelStatus::ObjectiveTarget, + HighsModelStatus::ReachedIterationLimit, + HighsModelStatus::ReachedMemoryLimit, + HighsModelStatus::ReachedSolutionLimit, + HighsModelStatus::ReachedInterrupt, + ] { + assert!(matches!(accept_backend_status(status), + Err(ILPSolveError::BackendFailure(message)) if message.contains(&format!("{status:?}")))); + } +} + +#[test] +fn native_terminals_return_the_input_ilp_solution_format() { + let adapter = HighsAdapter::new(None); + let boolean_integer = ILP::::new( + 2, + vec![LinearConstraint::le(vec![(0, 1), (1, 1)], 1)], + vec![(0, 1), (1, 2)], + ObjectiveSense::Maximize, + ) + .unwrap(); + let boolean_float = ILP::::new( + 2, + vec![LinearConstraint::le(vec![(0, 1.0), (1, 1.0)], 1.0)], + vec![(0, 1.0), (1, 2.0)], + ObjectiveSense::Maximize, + ) + .unwrap(); + let integer_integer = ILP::::with_variables( + vec![IntegerVariable::new(Some(-2), Some(3)).unwrap()], + vec![], + vec![(0, 1)], + ObjectiveSense::Minimize, + ) + .unwrap(); + let integer_float = ILP::::with_variables( + vec![IntegerVariable::new(Some(-2), Some(3)).unwrap()], + vec![], + vec![(0, 1.0)], + ObjectiveSense::Minimize, + ) + .unwrap(); + let values: [Vec; 4] = [ + adapter.solve(&boolean_integer).unwrap(), + adapter.solve(&boolean_float).unwrap(), + adapter.solve(&integer_integer).unwrap(), + adapter.solve(&integer_float).unwrap(), + ]; + assert_eq!(values, [vec![0, 1], vec![0, 1], vec![-2], vec![-2]]); +} + +#[test] +fn decoding_checks_shape_integrality_range_and_original_constraints() { + let ilp = ILP::::new( + 2, + vec![LinearConstraint::eq(vec![(0, 1), (1, 1)], 1)], + vec![(0, 1)], + ObjectiveSense::Maximize, + ) + .unwrap(); + assert_eq!( + decode_and_validate(&ilp, [1.00000001, 0.0]).unwrap(), + vec![1, 0] + ); + for raw in [ + vec![], + vec![1.0], + vec![1.0, 0.0, 0.0], + vec![1.0, 1.0], + vec![2.0, -1.0], + vec![0.5, 0.5], + vec![f64::NAN, 0.0], + vec![f64::INFINITY, 0.0], + vec![f64::NEG_INFINITY, 0.0], + vec![i64::MAX as f64, 0.0], + vec![i64::MIN as f64, 0.0], + ] { + assert!(matches!( + decode_and_validate(&ilp, raw), + Err(ILPSolveError::InvalidSolution(_)) + )); + } +} + +#[test] +fn validation_rejects_constraint_violations_in_both_coefficient_domains() { + let integer = ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, 2)], 1)], + vec![], + ObjectiveSense::Minimize, + ) + .unwrap(); + assert!(matches!( + decode_and_validate(&integer, [1.0]), + Err(ILPSolveError::InvalidSolution(_)) + )); + let float = ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, 2.0)], 1.0)], + vec![], + ObjectiveSense::Minimize, + ) + .unwrap(); + assert!(!float.is_feasible(&[1]).unwrap()); + assert!(matches!( + decode_and_validate(&float, [1.0]), + Err(ILPSolveError::InvalidSolution(_)) + )); +} + +#[test] +fn validation_propagates_constraint_and_objective_overflow() { + let objective = ILP::::new( + 2, + vec![], + vec![(0, i64::MAX), (1, 1)], + ObjectiveSense::Maximize, + ) + .unwrap(); + let constraint = ILP::::new( + 2, + vec![LinearConstraint::le(vec![(0, i64::MAX), (1, 1)], 0)], + vec![], + ObjectiveSense::Maximize, + ) + .unwrap(); + for ilp in [objective, constraint] { + assert!(matches!( + decode_and_validate(&ilp, [1.0, 1.0]), + Err(ILPSolveError::InvalidSolution(_)) + )); + } +} + +#[test] +fn coefficient_encoding_enforces_supported_transport_range() { + assert_eq!(BackendCoefficient::to_backend_number(17_i64).unwrap(), 17.0); + assert_eq!(BackendCoefficient::to_backend_number(0.5_f64).unwrap(), 0.5); + let value = MAX_EXACT_F64_INTEGER + 1; + for ilp in [ + ILP::::new(1, vec![], vec![(0, value)], ObjectiveSense::Maximize).unwrap(), + ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, value)], 1)], + vec![], + ObjectiveSense::Maximize, + ) + .unwrap(), + ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, 1)], value)], + vec![], + ObjectiveSense::Maximize, + ) + .unwrap(), + ] { + assert!(matches!( + HighsAdapter::new(None).solve(&ilp), + Err(ILPSolveError::InexactTransport(_)) + )); + } +} + +#[test] +fn invalid_time_limits_are_errors_instead_of_backend_panics() { + for time in [-1.0, f64::NAN, f64::INFINITY] { + assert!(matches!( + HighsAdapter::new(Some(time)).solve(&ILP::::empty()), + Err(ILPSolveError::BackendFailure(_)) + )); + } +} + +#[test] +fn adapter_accepts_an_ilp_domain_without_any_registry_entry() { + #[derive(Clone, Debug)] + struct UnregisteredDomain; + impl VariableDomain for UnregisteredDomain { + const NAME: &'static str = "UnregisteredDomain"; + fn default_variable() -> IntegerVariable { + ::default_variable() + } + fn validate_variables( + variables: &[IntegerVariable], + ) -> Result<(), crate::registry::ConstructionError> { + ::validate_variables(variables) + } + } + let ilp = ILP::::with_variables( + vec![IntegerVariable::new(Some(0), Some(2)).unwrap()], + vec![], + vec![(0, 1)], + ObjectiveSense::Maximize, + ) + .unwrap(); + assert_eq!(HighsAdapter::new(None).solve(&ilp).unwrap(), vec![2]); +} + +#[test] +fn backend_model_loading_failure_is_an_explicit_error() { + let ilp = ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, 1e30)], 1.0)], + vec![], + ObjectiveSense::Minimize, + ) + .unwrap(); + assert!(matches!( + HighsAdapter::new(None).solve(&ilp), + Err(ILPSolveError::BackendFailure(message)) if message.contains("loading HiGHS model") + )); +} diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 54441b328..603c449d0 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -1,5 +1,5 @@ use super::*; -use crate::models::algebraic::{IntegerVariable, LinearConstraint}; +use crate::models::algebraic::{IntegerVariable, LinearConstraint, ObjectiveSense}; use crate::traits::Problem; fn binary_ilp( @@ -113,26 +113,6 @@ fn test_ilp_solver_rejects_inexact_integer_transport() { )); } -#[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] fn test_ilp_rejects_solution_that_is_infeasible_after_rounding() { let ilp = binary_ilp( From 641631d8452bc832893ccdbcc7227f4a21f6cd0d Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 00:36:14 +0800 Subject: [PATCH 09/44] Solve native integer ILP variants directly through HiGHS adapter --- docs/src/design.md | 2 +- src/solvers/ilp/solver.rs | 12 +- src/solvers/pipelines.rs | 147 ------------------ src/solvers/registry.rs | 7 +- .../rules/threedimensionalmatching_ilp.rs | 2 +- src/unit_tests/solvers/ilp/solver.rs | 14 ++ src/unit_tests/solvers/registry.rs | 15 +- src/unit_tests/solvers/resolver.rs | 5 +- 8 files changed, 34 insertions(+), 170 deletions(-) diff --git a/docs/src/design.md b/docs/src/design.md index ac279977c..3a5432d2a 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -440,7 +440,7 @@ proved infeasibility, and `Err` reports an operational failure. | Solver | Description | |--------|-------------| | **BruteForce** | Enumerates a registered finite search space and returns an optimal or satisfying solution. Used for testing and verification. | -| **ILPSolver** | Executes a problem's registered ILP pipeline. Each pipeline terminates at `ILP` or `ILP`, which is solved through the concrete `HighsAdapter`. The adapter owns numerical conversion, backend settings, termination status, and returned-assignment validation. Optimality and infeasibility follow HiGHS numerical tolerances; the adapter does not provide exact proofs. | +| **ILPSolver** | Executes a problem's registered ILP pipeline, terminating at the native `ILP` with `bool`/`i64` variables and `i64`/`f64` coefficients. `HighsAdapter` owns numerical conversion, backend settings, termination status, and returned-assignment validation. Integer terminals go directly to the adapter; the explicit integer-to-float reduction remains available but is not part of solver pipelines. Optimality and infeasibility follow HiGHS numerical tolerances; the adapter does not provide exact proofs. | ILP results are optimal or infeasible according to HiGHS numerical tolerances; zero MIP gaps do not imply mathematical exactness. Integer extraction rounds diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index a1d8722de..089d73c1c 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -27,7 +27,7 @@ pub enum ILPSolveError { #[error("the ILP backend failed: {0}")] BackendFailure(String), /// Type-erased dispatch received a value other than a supported ILP variant. - #[error("the ILP backend requires bool/i64 variables and f64 coefficients")] + #[error("the ILP backend requires bool/i64 variables and i64/f64 coefficients")] UnsupportedProblemType, /// No ILP pipeline is registered for the exact problem variant. #[error("no ILP pipeline is registered for {0}")] @@ -54,8 +54,8 @@ pub enum ILPSolveError { /// An ILP solver using the HiGHS backend. /// -/// Registered reductions map a source problem to an `ILP` terminal, -/// which this solver sends to HiGHS before extracting the source solution. +/// Registered reductions map a source problem to a native `ILP` terminal, +/// which the HiGHS adapter converts for execution before source solution extraction. /// Optimality and infeasibility are assessed within HiGHS numerical tolerances. /// Zero MIP gaps do not make floating-point solving mathematically exact. /// @@ -122,6 +122,12 @@ impl ILPSolver { /// 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 HighsAdapter::new(self.time_limit).solve(ilp); + } + if let Some(ilp) = any.downcast_ref::>() { + return HighsAdapter::new(self.time_limit).solve(ilp); + } if let Some(ilp) = any.downcast_ref::>() { return HighsAdapter::new(self.time_limit).solve(ilp); } diff --git a/src/solvers/pipelines.rs b/src/solvers/pipelines.rs index 15e5aa525..d9674cd78 100644 --- a/src/solvers/pipelines.rs +++ b/src/solvers/pipelines.rs @@ -23,12 +23,10 @@ macro_rules! register_ilp_pipeline { register_ilp_pipeline! { ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -42,118 +40,99 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("AcyclicPartition", [("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("BMF", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("BalancedCompleteBipartiteSubgraph", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("BicliqueCover", []), ("BMF", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("BiconnectivityAugmentation", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("BinPacking", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("BottleneckTravelingSalesman", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("BoundedComponentSpanningForest", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("CapacityAssignment", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("CircuitSAT", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ClosestString", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ClosestSubstring", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("Clustering", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ConsecutiveBlockMinimization", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ConsecutiveOnesMatrixAugmentation", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ConsecutiveOnesSubmatrix", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ConsistencyOfDatabaseFrequencyTables", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "One")]), ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -161,50 +140,42 @@ register_ilp_pipeline! { ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MinimumSetCovering", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("DecisionOptimalLinearArrangement", [("graph", "SimpleGraph")]), ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("DirectedHamiltonianPath", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("DirectedTwoCommodityIntegralFlow", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("DisjointConnectingPaths", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("EnsembleComputation", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("EulerianPath", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ExactCoverBy3Sets", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -215,44 +186,37 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("Factoring", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("FeasibleRegisterAssignment", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("FlowShopScheduling", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("GraphPartitioning", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("HamiltonianCircuit", [("graph", "SimpleGraph")]), ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("HamiltonianPath", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("HighlyConnectedDeletion", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } // This exact variant also has a customized backend. Default dispatch selects the @@ -261,50 +225,42 @@ register_ilp_pipeline! { ("RootedTreeArrangement", [("graph", "SimpleGraph")]), ("RootedTreeStorageAssignment", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("IntegralFlowBundles", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("IntegralFlowHomologousArcs", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("IntegralFlowWithMultipliers", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("IsomorphicSpanningTree", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("KClique", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("KColoring", [("graph", "SimpleGraph"), ("k", "KN")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("KColoring", [("graph", "SimpleGraph"), ("k", "K3")]), ("Clustering", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -312,49 +268,41 @@ register_ilp_pipeline! { ("Satisfiability", []), ("NAESatisfiability", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("Knapsack", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("LengthBoundedDisjointPaths", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("LongestCommonSubsequence", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("LongestPath", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximalIS", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("Maximum2Satisfiability", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -363,43 +311,36 @@ register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumSetPacking", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "One")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumCommonEdgeSubgraph", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumContactMapOverlap", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumDomaticNumber", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -410,7 +351,6 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("MaximumEdgeWeightedKClique", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -418,7 +358,6 @@ register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumSetPacking", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -428,14 +367,12 @@ register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumSetPacking", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -444,7 +381,6 @@ register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -453,7 +389,6 @@ register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -462,7 +397,6 @@ register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -470,32 +404,27 @@ register_ilp_pipeline! { ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumLeafSpanningTree", [("graph", "SimpleGraph")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumLikelihoodRanking", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumMatching", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MaximumSetPacking", [("weight", "One")]), ("MaximumSetPacking", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -507,31 +436,26 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("MaximumSetPacking", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinMaxMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumCapacitatedSpanningTree", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumCutIntoBoundedSets", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -543,245 +467,205 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumEdgeCostFlow", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumExternalMacroDataCompression", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumFaultDetectionTestSet", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumFeedbackArcSet", [("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumFeedbackVertexSet", [("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumGraphBandwidth", [("graph", "SimpleGraph")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumHittingSet", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumInternalMacroDataCompression", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumMatrixCover", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumMaximalMatching", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumMetricDimension", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumMultiwayCut", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumSetCovering", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumTardinessSequencing", [("weight", "One")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumTardinessSequencing", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "One")]), ("MinimumHittingSet", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MinimumSetCovering", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MinimumWeightDecoding", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MixedChinesePostman", [("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MonochromaticTriangle", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MultipleCopyFileAllocation", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MultipleChoiceBranching", [("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("MultiprocessorScheduling", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("NAESatisfiability", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("Numerical3DimensionalMatching", []), ("NumericalMatchingWithTargetSums", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("NumericalMatchingWithTargetSums", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("OpenShopScheduling", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("OptimumCommunicationSpanningTree", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PaintShop", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PartiallyOrderedKnapsack", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("Partition", []), ("MultiprocessorScheduling", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PartitionIntoCliques", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PartitionIntoPathsOfLength2", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PartitionIntoTriangles", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PathConstrainedNetworkFlow", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PrecedenceConstrainedScheduling", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("PreemptiveScheduling", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -792,122 +676,102 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("QUBO", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("QuadraticAssignment", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RectilinearPictureCompression", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RegisterSufficiency", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ResourceConstrainedScheduling", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RootedTreeStorageAssignment", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RuralPostman", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("Satisfiability", []), ("NAESatisfiability", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SchedulingToMinimizeWeightedCompletionTime", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SchedulingWithIndividualDeadlines", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingToMinimizeMaximumCumulativeCost", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingToMinimizeTardyTaskWeight", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingToMinimizeWeightedTardiness", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingWithDeadlinesAndSetUpTimes", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingWithReleaseTimesAndDeadlines", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingWithinIntervals", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SetSplitting", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ShortestCommonSupersequence", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ShortestWeightConstrainedPath", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SparseMatrixCompression", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -920,66 +784,55 @@ register_ilp_pipeline! { ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "i64")]), ("QUBO", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("StackerCrane", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("StringToStringCorrection", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("StrongConnectivityAugmentation", [("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SubgraphIsomorphism", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SumOfSquaresPartition", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ThreeDimensionalMatching", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ThreePartition", []), ("ResourceConstrainedScheduling", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("TravelingSalesman", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("UndirectedFlowLowerBounds", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("UndirectedTwoCommodityIntegralFlow", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index 2faa34a77..2e99fd700 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -53,7 +53,10 @@ impl ExactProblemKey { self.variant.get("variable").map(String::as_str), Some("bool" | "i64") ) - && self.variant.get("coefficient").map(String::as_str) == Some("f64") + && matches!( + self.variant.get("coefficient").map(String::as_str), + Some("i64" | "f64") + ) } } @@ -299,7 +302,7 @@ pub enum RegistryBuildError { MissingSolverCapability(String), #[error("ILP pipeline must contain at least one node")] EmptyPipeline, - #[error("ILP pipeline for {0} does not end at an f64-coefficient ILP")] + #[error("ILP pipeline for {0} does not end at a supported ILP variant")] UnsupportedTarget(String), #[error("ILP pipeline for {0} continues after reaching a supported ILP node")] ContinuesAfterIlp(String), diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index aaec26d1e..528d233bf 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -140,7 +140,7 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { assert_eq!(problem.evaluate(&direct_source).unwrap(), Or(true)); let indirect_solution = solver.solve(indirect.target_problem()); assert!( - matches!(indirect_solution, Err(ILPSolveError::Extraction(_))), + matches!(indirect_solution, Err(ILPSolveError::InvalidSolution(_))), "the numerically unstable indirect ILP should be rejected: {indirect_solution:?}" ); assert!(direct.target_problem().num_vars() < indirect.target_problem().num_vars()); diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 603c449d0..0b3107ef9 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -113,6 +113,20 @@ fn test_ilp_solver_rejects_inexact_integer_transport() { )); } +#[test] +fn test_native_integer_coefficient_transport_reports_backend_error() { + let ilp = binary_ilp( + 1, + vec![], + vec![(0, crate::types::MAX_EXACT_F64_INTEGER + 1)], + ObjectiveSense::Maximize, + ); + assert!(matches!( + ILPSolver::new().solve(&ilp), + Err(ILPSolveError::InexactTransport(_)) + )); +} + #[test] fn test_ilp_rejects_solution_that_is_infeasible_after_rounding() { let ilp = binary_ilp( diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 449a91154..9193c5456 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -32,10 +32,6 @@ fn generic_decision_ilp_respects_maximization_bounds() { name: "ILP", variant: BOOL_VARIANT, }, - StaticProblemStep { - name: "ILP", - variant: FLOAT_BOOL_VARIANT, - }, ], }; let registry = build_registry( @@ -421,11 +417,7 @@ fn solver_capability_registry_exposes_representative_capability_classes() { assert!(direct_ilp.customized.is_none()); assert_eq!( direct_ilp.ilp.unwrap().path_labels(), - [ - "MaximumClique", - "ILP", - "ILP" - ] + ["MaximumClique", "ILP"] ); let multihop_ilp = solver_capabilities(&key( @@ -450,10 +442,7 @@ fn solver_capability_registry_exposes_representative_capability_classes() { let ilp_itself = solver_capabilities(&key("ILP", &[("variable", "bool"), ("coefficient", "i64")])).unwrap(); - assert_eq!( - ilp_itself.ilp.unwrap().path_labels(), - ["ILP", "ILP"] - ); + assert_eq!(ilp_itself.ilp.unwrap().path_labels(), ["ILP"]); } #[test] diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index 8cc05f909..ed9c120ab 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -365,7 +365,7 @@ fn deterministic_solver_dispatch_customized_infeasibility_does_not_fall_back() { } #[test] -fn deterministic_solver_dispatch_integer_ilp_uses_registered_cast_pipeline() { +fn deterministic_solver_dispatch_integer_ilp_uses_native_terminal() { let problem = ILP::::new(0, vec![], vec![], ObjectiveSense::Minimize).unwrap(); let loaded = load_dyn( ILP::::NAME, @@ -381,7 +381,7 @@ fn deterministic_solver_dispatch_integer_ilp_uses_registered_cast_pipeline() { assert_eq!( result.solver, SolverExecution::Ilp { - reduction_path: vec!["ILP".to_string(), "ILP".to_string()] + reduction_path: vec!["ILP".to_string()] } ); assert!(matches!( @@ -498,7 +498,6 @@ fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { "MaximumIndependentSet", "MaximumSetPacking", "ILP", - "ILP", ] ); } From 134be18ca4f0043ab6b765796cf024f005126161 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 00:56:54 +0800 Subject: [PATCH 10/44] Simplify model construction and completed result extraction --- .claude/CLAUDE.md | 2 +- docs/src/design.md | 9 +++-- problemreductions-cli/src/cli.rs | 6 +-- problemreductions-cli/src/commands/extract.rs | 11 +----- problemreductions-cli/src/dispatch.rs | 2 +- problemreductions-cli/tests/cli_tests.rs | 28 +++++++++---- .../graph/biconnectivity_augmentation.rs | 22 +---------- .../bounded_component_spanning_forest.rs | 17 -------- .../graph/bounded_diameter_spanning_tree.rs | 17 -------- src/models/graph/disjoint_connecting_paths.rs | 22 +---------- src/models/graph/generalized_hex.rs | 18 --------- src/models/graph/kclique.rs | 11 +----- src/models/graph/longest_circuit.rs | 11 ------ src/models/graph/longest_path.rs | 19 +++------ src/models/graph/max_cut.rs | 8 ---- src/models/graph/maximal_is.rs | 8 ---- src/models/graph/maximum_clique.rs | 8 ---- src/models/graph/maximum_co_k_plex.rs | 14 ------- src/models/graph/maximum_independent_set.rs | 26 ++----------- src/models/graph/maximum_matching.rs | 8 ---- src/models/graph/min_max_multicenter.rs | 32 --------------- .../minimum_capacitated_spanning_tree.rs | 17 -------- .../graph/minimum_cut_into_bounded_sets.rs | 13 ------- src/models/graph/minimum_dominating_set.rs | 8 ---- src/models/graph/minimum_feedback_arc_set.rs | 3 -- .../graph/minimum_feedback_vertex_set.rs | 3 -- src/models/graph/minimum_sum_multicenter.rs | 19 --------- src/models/graph/minimum_vertex_cover.rs | 8 ---- src/models/graph/rural_postman.rs | 15 ------- .../graph/shortest_weight_constrained_path.rs | 39 ------------------- .../misc/minimum_tardiness_sequencing.rs | 6 --- src/models/set/minimum_set_covering.rs | 17 -------- ...bility_directedtwocommodityintegralflow.rs | 7 +--- 33 files changed, 44 insertions(+), 410 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 81d6a68f6..f2cb96294 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -167,7 +167,7 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `BruteForce::find_all_witnesses()` is a reference-testing helper for collecting every optimal or satisfying solution - `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows. Neither requires a rule-category tag. When both are registered, completed-result recovery borrows both mappings from the same constructed reduction. - Register a completed-value mapping with `#[aggregate_reduction]` on its concrete `AggregateReductionResult` implementation. Generic implementations use `register_aggregate_reduction!(ResultType)` for each concrete result type. These register implementations, not rule categories. Read resolved edges through `reduction_entries()`, not raw inventory entries. -- Reduction chains expose solution and aggregate-value mappings, not solver outcomes. CLI execution coordinates those mappings when recovering a completed exact target result; callers must establish optimality or infeasibility. A missing mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. +- Reduction chains expose solution and aggregate-value mappings, not solver outcomes. CLI execution coordinates those mappings when recovering a completed target result; callers establish optimality or infeasibility under their solver's numerical contract. A missing mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. - Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. - Decision-equivalence rules map completed `Or` values identically. Decision-to-optimization rules own their feasibility/threshold map; reject target configurations that do not certify YES instead of returning an invalid source witness. Optimization rules decode optimal witnesses and evaluate the source; register a value map only when mathematically defined. Counting and universal rules map completed folds without witnesses. Follow [result mappings](../docs/src/design.md#result-mappings); no mandatory rule-category tags. - Decode only the reduction's defined mathematical mapping. Reject malformed structure with `ExtractionError`; never panic, truncate, clamp, invent defaults, or add recovery branches. Explicit mathematical alternatives and sentinels are allowed. Test successful decoding and every rejected representation. diff --git a/docs/src/design.md b/docs/src/design.md index 3a5432d2a..1e1077a48 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -330,10 +330,11 @@ rules use `ReduceToAggregate`. Reverse a multi-step chain one edge at a time. A NO result continues through explicit value maps, not through a fabricated invalid witness. Missing maps, -failed extraction, and solver errors are errors, never NO. A numerical ILP -optimum missing a decision threshold is unresolved, not an exact negative -certificate. These rules do not change `Problem`, `SolutionAggregate`, or -solver return types. +failed extraction, and solver errors are errors, never NO. Completed-result +recovery follows the selected solver's contract, including its numerical +tolerances. A witness-only solver API cannot return a witness for a negative +decision result and reports that limitation explicitly. These rules do not +change `Problem`, `SolutionAggregate`, or solver return types. `ReductionGraph::new()` reads `reduction_entries()`, which joins each construction with its registered result mappings, and builds a variant-level directed graph: diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index cdebc47a8..a51e2dcf3 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -223,11 +223,11 @@ Use this when an external solver has solved the bundle's target problem the corresponding solution in the original source problem space without having to shell back into `pred solve`. ---config recovers a candidate only. --result requires an exact completed result: +--config recovers a candidate only. --result requires a completed result: {\"status\":\"optimal\",\"solution\":[true,false],\"evaluation\":\"Min(1)\"} {\"status\":\"infeasible\"} Evaluation is optional, but must match when supplied. The external solver must -establish optimality or infeasibility; numerical status alone is not a proof. +establish optimality or infeasibility under its own numerical contract. --value maps an exact aggregate through value-capable edges, without a witness. Input: a reduction bundle JSON (from `pred reduce`). Use - to read from stdin. @@ -354,7 +354,7 @@ pub struct ExtractArgs { /// Target problem solution encoded as JSON (for example, [1,0,1,0]) #[arg(long)] pub config: Option, - /// JSON file containing an exact completed target result (optimal or infeasible). + /// JSON file containing a completed target result (optimal or infeasible). #[arg(long)] pub result: Option, /// Exact target aggregate encoded as JSON; uses only value mappings. diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index e3780658c..a11f3549d 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -12,9 +12,9 @@ enum ExternalResult { Infeasible, } -/// Recover a candidate, completed exact result, or aggregate through a bundle. +/// Recover a candidate, completed result, or aggregate through a bundle. /// `--result` accepts solve-output metadata, but validates any supplied evaluation. -/// The external solver is responsible for proving optimality or infeasibility. +/// Optimality and infeasibility follow the external solver's numerical contract. pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { let content = read_input(&args.input)?; let json: serde_json::Value = @@ -46,13 +46,6 @@ pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { if let Some(path) = &args.result { let json: serde_json::Value = serde_json::from_str(&read_input(path)?).context("Invalid completed target result")?; - if json - .pointer("/solver/kind") - .and_then(serde_json::Value::as_str) - == Some("ilp") - { - anyhow::bail!("numerical ILP status is not an exact certificate; recover a candidate with --config") - } let evaluation = json.get("evaluation").cloned(); let external: ExternalResult = serde_json::from_value(json).context("Invalid completed target result")?; diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 3a65ede7d..422550dd3 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -405,7 +405,7 @@ impl BundleReplay { .context("intermediate problem type mismatch")? }; let mapped = if step.chain.has_value_mapping() { - Some(step.chain.extract_value(value.clone())?) + Some(step.chain.extract_value(value)?) } else { None }; diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index da7a4897f..7eac5e1ad 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -3831,7 +3831,10 @@ fn test_create_bounded_component_spanning_forest_rejects_zero_k() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("k must be at least 1"), "stderr: {stderr}"); + assert!( + stderr.contains("max_components must be at least 1"), + "stderr: {stderr}" + ); } #[test] @@ -8653,7 +8656,7 @@ fn test_create_shortest_weight_constrained_path_edge_length_count_mismatch() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("edge_lengths has 7 entries, expected 8"), + stderr.contains("edge lengths length must match num_edges"), "stderr: {stderr}" ); } @@ -8699,7 +8702,7 @@ fn test_create_shortest_weight_constrained_path_rejects_out_of_bounds_source_ver assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("source_vertex 9 is outside graph with 6 vertices"), + stderr.contains("source_vertex 9 out of bounds"), "stderr: {stderr}" ); assert!( @@ -8787,7 +8790,7 @@ fn test_create_shortest_weight_constrained_path_rejects_non_positive_edge_length assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("edge_lengths must be positive"), + stderr.contains("edge lengths must be positive"), "stderr: {stderr}" ); } @@ -9667,8 +9670,13 @@ fn test_completed_decision_recovery_and_aggregate_cli() { let numerical: serde_json::Value = serde_json::from_slice(&numerical.stdout).unwrap(); assert_eq!(numerical["status"], expected); - for evaluation in [None, Some("Min(2)")] { - let mut external = json!({"status":"optimal","solution":[true,true,false],"problem":"MinimumVertexCover","solver":{"kind":"brute-force"}}); + for (evaluation, solver) in [ + (None, "brute-force"), + (Some("Min(2)"), "brute-force"), + (None, "ilp"), + (Some("Min(2)"), "ilp"), + ] { + let mut external = json!({"status":"optimal","solution":[true,true,false],"problem":"MinimumVertexCover","solver":{"kind":solver}}); if let Some(evaluation) = evaluation { external["evaluation"] = json!(evaluation); } @@ -9727,7 +9735,7 @@ fn test_completed_decision_recovery_and_aggregate_cli() { json!({"status":"optimal", "solution":[false,false,false]}), json!({"status":"timeout"}), json!({"status":"infeasible", "evaluation":"Min(2)"}), - json!({"status":"optimal", "solution":[true,true,false], "solver":{"kind":"ilp"}}), + json!({"status":"optimal", "solution":[true,true,false], "evaluation":"Min(99)", "solver":{"kind":"ilp"}}), ] { std::fs::write(&result, invalid.to_string()).unwrap(); let output = pred() @@ -9786,7 +9794,11 @@ fn test_completed_decision_recovery_and_aggregate_cli() { .output() .unwrap(); assert!(reduced.status.success()); - std::fs::write(&result, json!({"status":"infeasible"}).to_string()).unwrap(); + std::fs::write( + &result, + json!({"status":"infeasible", "solver":{"kind":"ilp"}}).to_string(), + ) + .unwrap(); let recovered = pred() .args([ "extract", diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index 44b2a1a8c..e5a158601 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -108,27 +108,7 @@ impl TryFrom return Err("num_vertices is too small for graph endpoints".into()); } let graph = SimpleGraph::new(count, spec.graph); - let mut seen = BTreeSet::new(); - for &(u, v, _) in &spec.potential_weights { - if u >= count || v >= count { - return Err("potential edge endpoint is out of bounds".into()); - } - if u == v { - return Err("potential edge is a self-loop".into()); - } - let edge = normalize_edge(u, v); - if graph.has_edge(edge.0, edge.1) { - return Err("potential edge already exists in graph".into()); - } - if !seen.insert(edge) { - return Err("duplicate potential edge".into()); - } - } - Ok(Self { - graph, - potential_weights: spec.potential_weights, - budget: spec.budget, - }) + Self::try_new(graph, spec.potential_weights, spec.budget) } } diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 9b240d754..a11593628 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -93,23 +93,6 @@ impl TryFrom type Error = crate::registry::ConstructionError; fn try_from(spec: BoundedComponentSpanningForestCreateSpec) -> Result { - if spec.weights.len() != spec.graph.num_vertices() { - return Err(format!( - "weights has {} entries, expected {}", - spec.weights.len(), - spec.graph.num_vertices() - ) - .into()); - } - if spec.weights.iter().any(|&weight| weight < 0) { - return Err("weights must be nonnegative".to_string().into()); - } - if spec.k == 0 { - return Err("k must be at least 1".to_string().into()); - } - if spec.max_weight <= 0 { - return Err("max_weight must be positive".to_string().into()); - } Self::try_new(spec.graph, spec.weights, spec.k, spec.max_weight) } } diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index 6cd1c6b54..812ea5244 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -123,23 +123,6 @@ impl TryFrom let edge_weights = spec .edge_weights .unwrap_or_else(|| vec![1; graph.num_edges()]); - if edge_weights.len() != graph.num_edges() { - return Err(format!( - "edge_weights has length {}, expected {}", - edge_weights.len(), - graph.num_edges() - ) - .into()); - } - if edge_weights.iter().any(|&weight| weight <= 0) { - return Err("edge_weights must be positive".to_string().into()); - } - if spec.weight_bound <= 0 { - return Err("weight_bound must be positive".to_string().into()); - } - if spec.diameter_bound == 0 { - return Err("diameter_bound must be at least 1".to_string().into()); - } Self::try_new(graph, edge_weights, spec.weight_bound, spec.diameter_bound) } } diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index 281a0c841..6dc5ed7fc 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -85,27 +85,7 @@ impl TryFrom for DisjointConnectingPaths= count || sink >= count { - return Err("terminal pair endpoint is out of bounds".into()); - } - if source == sink { - return Err("terminal pair endpoints must be distinct".into()); - } - if used[source] || used[sink] { - return Err("terminal vertices must be pairwise disjoint".into()); - } - used[source] = true; - used[sink] = true; - } - Ok(Self { - graph: SimpleGraph::new(count, spec.graph), - terminal_pairs: spec.terminal_pairs, - }) + Self::try_new(SimpleGraph::new(count, spec.graph), spec.terminal_pairs) } } diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index 14188eb3e..6fd800d0d 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -71,24 +71,6 @@ impl TryFrom for GeneralizedHex { type Error = crate::registry::ConstructionError; fn try_from(spec: GeneralizedHexCreateSpec) -> Result { - let num_vertices = spec.graph.num_vertices(); - if spec.source >= num_vertices { - return Err(format!( - "source {} is outside graph with {num_vertices} vertices", - spec.source - ) - .into()); - } - if spec.sink >= num_vertices { - return Err(format!( - "sink {} is outside graph with {num_vertices} vertices", - spec.sink - ) - .into()); - } - if spec.source == spec.sink { - return Err("source and sink must be distinct".to_string().into()); - } Self::try_new(spec.graph, spec.source, spec.sink) } } diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index a9b740d21..89d751272 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -80,16 +80,7 @@ impl TryFrom for KClique { if count < inferred { return Err("num_vertices is too small for graph endpoints".into()); } - if spec.k == 0 { - return Err("k must be positive".into()); - } - if spec.k > count { - return Err("k must be <= graph num_vertices".into()); - } - Ok(Self { - graph: SimpleGraph::new(count, spec.graph), - k: spec.k, - }) + Self::try_new(SimpleGraph::new(count, spec.graph), spec.k) } } diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index d6d154e0b..d42bc6461 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -81,17 +81,6 @@ impl TryFrom for LongestCircuit { let edge_lengths = spec .edge_weights .unwrap_or_else(|| vec![1; graph.num_edges()]); - if edge_lengths.len() != graph.num_edges() { - return Err(format!( - "edge_weights has length {}, expected {}", - edge_lengths.len(), - graph.num_edges() - ) - .into()); - } - if edge_lengths.iter().any(|&length| length <= 0) { - return Err("edge_weights must be positive".to_string().into()); - } Self::try_new(graph, edge_lengths) } } diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index 1446db033..a30acd830 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -112,21 +112,12 @@ macro_rules! longest_path_create_spec { return Err("num_vertices is too small".into()); } let edge_lengths = longest_path_create_spec!(@lengths spec $(, $lengths)?); - if edge_lengths.len() != spec.graph.len() { - return Err("edge_lengths length must match graph edge count".into()); - } - if edge_lengths.iter().any(|v| v.to_sum() <= 0) { - return Err("edge lengths must be positive".into()); - } - if spec.source_vertex >= count || spec.target_vertex >= count { - return Err("source_vertex and target_vertex must be valid vertices".into()); - } - Ok(Self { - graph: SimpleGraph::new(count, spec.graph), + Self::try_new( + SimpleGraph::new(count, spec.graph), edge_lengths, - source_vertex: spec.source_vertex, - target_vertex: spec.target_vertex, - }) + spec.source_vertex, + spec.target_vertex, + ) } } }; diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index a26ebed82..45f1c4759 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -111,14 +111,6 @@ macro_rules! max_cut_create_spec { fn try_from(spec: $name) -> Result { let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; let edge_weights = { $(if let Some(value) = spec.$edge_weights { value } else)? { vec![$one; graph.num_edges()] } }; - if edge_weights.len() != graph.num_edges() { - return Err(format!( - "edge_weights has length {}, expected {}", - edge_weights.len(), - graph.num_edges() - ) - .into()); - } Self::try_new(graph, edge_weights) } } diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index f28e33834..a071b6b89 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -89,14 +89,6 @@ struct MaximalISCreateSpec { impl TryFrom for MaximalIS { type Error = crate::registry::ConstructionError; fn try_from(spec: MaximalISCreateSpec) -> Result { - if spec.weights.len() != spec.graph.num_vertices() { - return Err(format!( - "weights has {} entries, expected {}", - spec.weights.len(), - spec.graph.num_vertices() - ) - .into()); - } Self::try_new(spec.graph, spec.weights) } } diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 97ec9fac9..d4551ce84 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -92,14 +92,6 @@ struct MaximumCliqueCreateSpec { impl TryFrom> for MaximumClique { type Error = crate::registry::ConstructionError; fn try_from(spec: MaximumCliqueCreateSpec) -> Result { - if spec.weights.len() != spec.graph.num_vertices() { - return Err(format!( - "weights has {} entries, expected {}", - spec.weights.len(), - spec.graph.num_vertices() - ) - .into()); - } Self::try_new(spec.graph, spec.weights) } } diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index 5af8c7a46..2c513f469 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -115,17 +115,6 @@ impl TryFrom> type Error = crate::registry::ConstructionError; fn try_from(spec: MaximumCoKPlexCreateSpec) -> Result { - if spec.weights.len() != spec.graph.num_vertices() { - return Err(format!( - "weights has {} entries, expected {}", - spec.weights.len(), - spec.graph.num_vertices() - ) - .into()); - } - if spec.k == 0 { - return Err("k must be at least 1".to_string().into()); - } Self::try_with_k(spec.graph, spec.weights, spec.k) } } @@ -304,9 +293,6 @@ impl TryFrom for MaximumCoKPlex Result { let weights = vec![One; spec.graph.num_vertices()]; - if spec.k == 0 { - return Err("k must be at least 1".into()); - } Self::try_with_k(spec.graph, weights, spec.k) } } diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index bfddc2f25..d7a25323f 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -119,13 +119,7 @@ macro_rules! simple_mis_spec { return Err("num_vertices is too small".into()); } let weights = { $(if let Some(value) = spec.$weights { value } else)? { vec![$one; count] } }; - if weights.len() != count { - return Err("weights length must match num_vertices".into()); - } - Ok(Self { - graph: SimpleGraph::new(count, spec.graph), - weights, - }) + Self::try_new(SimpleGraph::new(count, spec.graph), weights) } } }; @@ -158,13 +152,7 @@ macro_rules! grid_mis_spec { type Error = crate::registry::ConstructionError; fn try_from(spec: $name) -> Result { let weights = { $(if let Some(value) = spec.$weights { value } else)? { vec![$one; spec.positions.len()] } }; - if weights.len() != spec.positions.len() { - return Err("weights length must match positions length".into()); - } - Ok(Self { - graph: <$graph>::new(spec.positions), - weights, - }) + Self::try_new(<$graph>::new(spec.positions), weights) } } }; @@ -206,15 +194,7 @@ macro_rules! unit_disk_mis_spec { fn try_from(spec: $name) -> Result { let radius = spec.radius.unwrap_or(1.0); let weights = { $(if let Some(value) = spec.$weights { value } else)? { vec![$one; spec.positions.len()] } }; - if weights.len() != spec.positions.len() { - return Err(ConstructionError::Conversion( - "weights length must match positions length".into(), - )); - } - Ok(Self { - graph: UnitDiskGraph::new(spec.positions, radius)?, - weights, - }) + Self::try_new(UnitDiskGraph::new(spec.positions, radius)?, weights) } } }; diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index 542497fdd..9b2e81b0a 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -98,14 +98,6 @@ impl TryFrom for MaximumMatching { let edge_weights = spec .edge_weights .unwrap_or_else(|| vec![1; graph.num_edges()]); - if edge_weights.len() != graph.num_edges() { - return Err(format!( - "edge_weights has length {}, expected {}", - edge_weights.len(), - graph.num_edges() - ) - .into()); - } Self::try_new(graph, edge_weights) } } diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 2da29bc69..4f34acd5d 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -110,39 +110,7 @@ macro_rules! min_max_multicenter_create_spec { fn try_from(spec: $name) -> Result { let graph = simple_graph_from_create(spec.graph, spec.num_vertices)?; let vertex_weights = { $(if let Some(value) = spec.$weights { value } else)? { vec![$one; graph.num_vertices()] } }; - if vertex_weights.len() != graph.num_vertices() { - return Err(format!( - "weights has length {}, expected {}", - vertex_weights.len(), - graph.num_vertices() - ) - .into()); - } let edge_lengths = { $(if let Some(value) = spec.$edge_weights { value } else)? { vec![$one; graph.num_edges()] } }; - if edge_lengths.len() != graph.num_edges() { - return Err(format!( - "edge_weights has length {}, expected {}", - edge_lengths.len(), - graph.num_edges() - ) - .into()); - } - let zero = <$weight as WeightElement>::Sum::zero(); - if vertex_weights - .iter() - .any(|weight| weight.to_sum() < zero.clone()) - { - return Err("weights must be non-negative".to_string().into()); - } - if edge_lengths - .iter() - .any(|weight| weight.to_sum() < zero.clone()) - { - return Err("edge_weights must be non-negative".to_string().into()); - } - if spec.k == 0 || spec.k > graph.num_vertices() { - return Err(format!("k must be between 1 and {}", graph.num_vertices()).into()); - } Self::try_new(graph, vertex_weights, edge_lengths, spec.k) } } diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 7133d30f1..5648aa2dc 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -113,23 +113,6 @@ impl TryFrom fn try_from(spec: MinimumCapacitatedSpanningTreeCreateSpec) -> Result { let edges = spec.graph.num_edges(); let weights = spec.weights.unwrap_or_else(|| vec![1; edges]); - if weights.len() != edges { - return Err(format!("weights has {} entries, expected {edges}", weights.len()).into()); - } - let vertices = spec.graph.num_vertices(); - if vertices < 2 { - return Err("graph must have at least two vertices".to_string().into()); - } - if spec.requirements.len() != vertices { - return Err(format!( - "requirements has {} entries, expected {vertices}", - spec.requirements.len() - ) - .into()); - } - if spec.root >= vertices { - return Err("root is outside the graph".to_string().into()); - } Self::try_new( spec.graph, weights, diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 90bac2d32..4830f55a1 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -116,19 +116,6 @@ impl TryFrom for MinimumCutIntoBoundedSets< fn try_from(spec: MinimumCutIntoBoundedSetsCreateSpec) -> Result { let count = spec.graph.num_edges(); let edge_weights = spec.edge_weights.unwrap_or_else(|| vec![1; count]); - if edge_weights.len() != count { - return Err(format!( - "edge_weights has {} entries, expected {count}", - edge_weights.len() - ) - .into()); - } - let vertices = spec.graph.num_vertices(); - if spec.source >= vertices || spec.sink >= vertices || spec.source == spec.sink { - return Err("source and sink must be distinct valid graph vertices" - .to_string() - .into()); - } Self::try_new( spec.graph, edge_weights, diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index 84d3859be..d1ebc35b3 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -90,14 +90,6 @@ impl TryFrom> { type Error = crate::registry::ConstructionError; fn try_from(spec: MinimumDominatingSetCreateSpec) -> Result { - if spec.weights.len() != spec.graph.num_vertices() { - return Err(format!( - "weights has {} entries, expected {}", - spec.weights.len(), - spec.graph.num_vertices() - ) - .into()); - } Self::try_new(spec.graph, spec.weights) } } diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index 8e62fb8ef..5b7bb7ab2 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -91,9 +91,6 @@ impl TryFrom for MinimumFeedbackArcSet { fn try_from(spec: MinimumFeedbackArcSetCreateSpec) -> Result { let count = spec.graph.num_arcs(); let weights = spec.weights.unwrap_or_else(|| vec![1; count]); - if weights.len() != count { - return Err(format!("weights has {} entries, expected {count}", weights.len()).into()); - } Self::try_new(spec.graph, weights) } } diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index e1fe25b29..0956a7f1f 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -87,9 +87,6 @@ impl TryFrom> fn try_from(spec: MinimumFeedbackVertexSetCreateSpec) -> Result { let count = spec.graph.num_vertices(); let weights = spec.weights.unwrap_or_else(|| vec![W::unit(); count]); - if weights.len() != count { - return Err(format!("weights has {} entries, expected {count}", weights.len()).into()); - } Self::try_new(spec.graph, weights) } } diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 01097f26c..327bf3f3e 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -119,28 +119,9 @@ impl TryFrom for MinimumSumMulticenter graph.num_vertices() { - return Err(format!("k must be between 1 and {}", graph.num_vertices()).into()); - } Self::try_new(graph, vertex_weights, edge_lengths, spec.k) } } diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 650fcc31c..001b84fc6 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -93,14 +93,6 @@ impl TryFrom> let weights = spec .weights .unwrap_or_else(|| vec![W::unit(); spec.graph.num_vertices()]); - if weights.len() != spec.graph.num_vertices() { - return Err(format!( - "weights has {} entries, expected {}", - weights.len(), - spec.graph.num_vertices() - ) - .into()); - } Self::try_new(spec.graph, weights) } } diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index bac988674..703f98593 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -101,21 +101,6 @@ impl TryFrom for RuralPostman { let edge_lengths = spec .edge_weights .unwrap_or_else(|| vec![1; graph.num_edges()]); - if edge_lengths.len() != graph.num_edges() { - return Err(format!( - "edge_weights has length {}, expected {}", - edge_lengths.len(), - graph.num_edges() - ) - .into()); - } - if let Some(&edge) = spec - .required_edges - .iter() - .find(|&&edge| edge >= graph.num_edges()) - { - return Err(format!("required edge index {edge} is out of bounds").into()); - } Self::try_new(graph, edge_lengths, spec.required_edges) } } diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index 10dd20666..c6cf2e6c1 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -121,45 +121,6 @@ impl TryFrom { type Error = crate::registry::ConstructionError; fn try_from(spec: ShortestWeightConstrainedPathCreateSpec) -> Result { - let edge_count = spec.graph.num_edges(); - if spec.edge_lengths.len() != edge_count { - return Err(format!( - "edge_lengths has {} entries, expected {edge_count}", - spec.edge_lengths.len() - ) - .into()); - } - if spec.edge_weights.len() != edge_count { - return Err(format!( - "edge_weights has {} entries, expected {edge_count}", - spec.edge_weights.len() - ) - .into()); - } - if spec.edge_lengths.iter().any(|&value| value <= 0) { - return Err("edge_lengths must be positive".to_string().into()); - } - if spec.edge_weights.iter().any(|&value| value <= 0) { - return Err("edge_weights must be positive".to_string().into()); - } - let vertex_count = spec.graph.num_vertices(); - if spec.source_vertex >= vertex_count { - return Err(format!( - "source_vertex {} is outside graph with {vertex_count} vertices", - spec.source_vertex - ) - .into()); - } - if spec.target_vertex >= vertex_count { - return Err(format!( - "target_vertex {} is outside graph with {vertex_count} vertices", - spec.target_vertex - ) - .into()); - } - if spec.weight_bound <= 0 { - return Err("weight_bound must be positive".to_string().into()); - } Self::try_new( spec.graph, spec.edge_lengths, diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index 11320566d..1f7835b05 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -94,12 +94,6 @@ impl TryFrom for MinimumTardinessSequen fn try_from(spec: MinimumTardinessSequencingOneCreateSpec) -> Result { let num_tasks = spec.deadlines.len(); let precedences = spec.precedences.unwrap_or_default(); - if precedences - .iter() - .any(|&(a, b)| a >= num_tasks || b >= num_tasks) - { - return Err("precedence indices must be within the task count".into()); - } Self::try_new(num_tasks, spec.deadlines, precedences) } } diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 47a30a6b9..14d550990 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -94,23 +94,6 @@ impl TryFrom for MinimumSetCovering { type Error = crate::registry::ConstructionError; fn try_from(spec: MinimumSetCoveringCreateSpec) -> Result { - if spec.subsets.len() != spec.weights.len() { - return Err(format!( - "weights has {} entries, expected one for each of {} subsets", - spec.weights.len(), - spec.subsets.len() - ) - .into()); - } - for (set_index, set) in spec.subsets.iter().enumerate() { - if let Some(&element) = set.iter().find(|&&element| element >= spec.universe_size) { - return Err(format!( - "subsets[{set_index}] contains element {element} outside universe of size {}", - spec.universe_size - ) - .into()); - } - } Self::try_with_weights(spec.universe_size, spec.subsets, spec.weights) } } diff --git a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs index c952b41ee..206183ecd 100644 --- a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -65,11 +65,6 @@ fn literal_var_index(literal: i64) -> usize { literal.unsigned_abs() as usize - 1 } -#[cfg_attr(not(any(test, feature = "example-db")), allow(dead_code))] -fn literal_satisfied(requires_true: bool, assignment: &[bool], variable: usize) -> bool { - assignment.get(variable).copied().unwrap_or(false) == requires_true -} - fn build_branch( add_vertex: &mut FV, add_arc: &mut FA, @@ -150,7 +145,7 @@ impl Reduction3SATToDirectedTwoCommodityIntegralFlow { for (clause_idx, routes) in self.clause_routes.iter().enumerate() { if let Some(route) = routes .iter() - .find(|route| literal_satisfied(route.requires_true, assignment, route.variable)) + .find(|route| assignment[route.variable] == route.requires_true) { flow[num_arcs + route.source_arc] = 1; flow[num_arcs + route.branch_arc] = 1; From b4c10fee16b4776ffa0188b39e4ddc429473ea15 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 02:03:02 +0800 Subject: [PATCH 11/44] Enumerate brute-force candidates without total cardinality limits --- problemreductions-cli/src/test_support.rs | 31 +++++++++--------- src/solvers/brute_force.rs | 32 ++++++++----------- src/unit_tests/solvers/brute_force.rs | 38 ++++++++++++++++------- 3 files changed, 57 insertions(+), 44 deletions(-) diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index f7087b2b0..7592dc02c 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -139,24 +139,24 @@ fn decode_bits(indices: Vec) -> Vec { fn cartesian_indices( dimensions: Vec, ) -> Result>, problemreductions::solvers::SolveError> { - let total = if dimensions.is_empty() { - 1 - } else if dimensions.contains(&0) { - 0 + let first = if dimensions.contains(&0) { + None } else { - dimensions.iter().try_fold(1usize, |total, &dimension| { - total.checked_mul(dimension).ok_or_else(|| { - problemreductions::solvers::SolveError::SearchSpaceOverflow(dimensions.clone()) - }) - })? + let mut coordinates = Vec::new(); + coordinates.try_reserve_exact(dimensions.len())?; + coordinates.resize(dimensions.len(), 0); + Some(coordinates) }; - Ok((0..total).map(move |mut index| { - let mut coordinates = vec![0; dimensions.len()]; + Ok(std::iter::successors(first, move |current| { + let mut coordinates = current.clone(); for position in (0..dimensions.len()).rev() { - coordinates[position] = index % dimensions[position]; - index /= dimensions[position]; + coordinates[position] += 1; + if coordinates[position] < dimensions[position] { + return Some(coordinates); + } + coordinates[position] = 0; } - coordinates + None })) } @@ -168,6 +168,9 @@ where let mut total = P::Value::identity(); for indices in cartesian_indices(problem.dimensions())? { total = total.combine(problem.evaluate(&decode_bits(indices))?)?; + if total.is_absorbing() { + break; + } } Ok(total) } diff --git a/src/solvers/brute_force.rs b/src/solvers/brute_force.rs index fd3add8fe..c21dea5d7 100644 --- a/src/solvers/brute_force.rs +++ b/src/solvers/brute_force.rs @@ -46,26 +46,21 @@ pub trait BruteForceProblem: Problem { pub(crate) struct CartesianIndices { dimensions: Vec, current: Option>, - remaining: usize, } impl CartesianIndices { pub(crate) fn new(dimensions: Vec) -> Result { - let total = if dimensions.is_empty() { - 1 - } else if dimensions.contains(&0) { - 0 + let current = if dimensions.contains(&0) { + None } else { - dimensions.iter().try_fold(1usize, |total, &dimension| { - total - .checked_mul(dimension) - .ok_or_else(|| SolveError::SearchSpaceOverflow(dimensions.clone())) - })? + let mut current = Vec::new(); + current.try_reserve_exact(dimensions.len())?; + current.resize(dimensions.len(), 0); + Some(current) }; Ok(Self { - current: (total != 0).then(|| vec![0; dimensions.len()]), + current, dimensions, - remaining: total, }) } } @@ -79,24 +74,23 @@ impl Iterator for CartesianIndices { for index in (0..self.dimensions.len()).rev() { next[index] += 1; if next[index] < self.dimensions[index] { + self.current = Some(next); break; } next[index] = 0; } - self.remaining -= 1; - if self.remaining != 0 { - self.current = Some(next); - } Some(current) } fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) + if self.current.is_some() { + (1, None) + } else { + (0, Some(0)) + } } } -impl ExactSizeIterator for CartesianIndices {} - /// Exact reference solver for variants with a registered finite enumeration. #[derive(Debug, Clone, Default)] pub struct BruteForce; diff --git a/src/unit_tests/solvers/brute_force.rs b/src/unit_tests/solvers/brute_force.rs index f2b6547d7..c6cf3667b 100644 --- a/src/unit_tests/solvers/brute_force.rs +++ b/src/unit_tests/solvers/brute_force.rs @@ -563,18 +563,34 @@ fn cartesian_indices_zero_dimension_has_no_candidates() { } #[test] -fn cartesian_indices_is_exact_size() { - let mut indices = CartesianIndices::new(vec![2, 3]).unwrap(); - assert_eq!(indices.len(), 6); - indices.next(); - assert_eq!(indices.len(), 5); +fn cartesian_indices_stays_exhausted() { + let mut indices = CartesianIndices::new(vec![1]).unwrap(); + assert_eq!(indices.size_hint(), (1, None)); + assert_eq!(indices.next(), Some(vec![0])); + assert_eq!(indices.size_hint(), (0, Some(0))); + assert_eq!(indices.next(), None); + assert_eq!(indices.next(), None); } #[test] -fn cartesian_indices_reports_cardinality_overflow() { - assert!(matches!( - CartesianIndices::new(vec![usize::MAX, 2]), - Err(crate::solvers::SolveError::SearchSpaceOverflow(dimensions)) - if dimensions == vec![usize::MAX, 2] - )); +fn cartesian_indices_enumerates_without_representable_cardinality() { + let indices = CartesianIndices::new(vec![usize::MAX, 2]).unwrap(); + assert_eq!( + indices.take(3).collect::>(), + vec![vec![0, 0], vec![0, 1], vec![1, 0]] + ); +} + +#[test] +fn brute_force_finds_sat_witness_without_representable_cardinality() { + use crate::models::formula::{CNFClause, Satisfiability}; + + let num_vars = usize::BITS as usize; + let clauses = (1..=num_vars) + .map(|variable| CNFClause::new(vec![-(variable as i64)])) + .collect(); + let problem = Satisfiability::new(num_vars, clauses); + let solution = BruteForce::new().solve(&problem).unwrap().unwrap(); + assert_eq!(solution, vec![false; num_vars]); + assert_eq!(problem.evaluate(&solution).unwrap(), Or(true)); } From 138ce7a85e30e68ab26e9984147100481f5aee92 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 12:32:55 +0800 Subject: [PATCH 12/44] Separate model validity from enumeration and ILP encoding limits --- docs/src/design.md | 13 ++++ src/models/misc/closest_substring.rs | 19 +---- ...onsistency_of_database_frequency_tables.rs | 42 ++++------- ...imum_discrete_planar_inverse_kinematics.rs | 18 +---- ...onsistencyofdatabasefrequencytables_ilp.rs | 57 ++++++++++----- .../models/misc/closest_substring.rs | 13 +++- ...onsistency_of_database_frequency_tables.rs | 39 +++++------ ...imum_discrete_planar_inverse_kinematics.rs | 23 ++++++ src/unit_tests/registry/variant.rs | 61 ++++++++++++++++ ...onsistencyofdatabasefrequencytables_ilp.rs | 70 +++++++++++++++++++ 10 files changed, 254 insertions(+), 101 deletions(-) diff --git a/docs/src/design.md b/docs/src/design.md index 1e1077a48..c045e7921 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -304,6 +304,19 @@ impl ReduceTo> } ``` +### Model size and algorithm limits + +Model parameters describe representable input sizes, not a requirement that the +number of candidate solutions fit a machine integer. ClosestSubstring and +MinimumDiscretePlanarInverseKinematics bound their products of choice counts by +the arithmetic mean raised to the number of choices. Database frequency-table +consistency uses the largest attribute domain to bound assignment counts. These +are complexity upper bounds, not exact per-instance candidate counts. + +Database ILP indicator counts and allocation limits are checked when constructing +that reduction, not when loading the source model. Source input invariants and +its witness representation remain model constraints. + ## Reduction Graph ### Result mappings diff --git a/src/models/misc/closest_substring.rs b/src/models/misc/closest_substring.rs index 56a504e5a..ece27fc47 100644 --- a/src/models/misc/closest_substring.rs +++ b/src/models/misc/closest_substring.rs @@ -113,11 +113,6 @@ impl ClosestSubstring { .map(|string| string.len() - substring_length + 1) .try_fold(0_usize, usize::checked_add) .ok_or("total number of windows exceeds usize")?; - strings - .iter() - .map(|string| string.len() - substring_length + 1) - .try_fold(1_usize, usize::checked_mul) - .ok_or("window-choice count exceeds usize")?; Ok(Self { alphabet_size, strings, @@ -157,16 +152,6 @@ impl ClosestSubstring { .map(|s| s.len() - self.substring_length + 1) .sum() } - - /// Returns `prod_i W_i`, the number of distinct window-selection tuples. - /// - pub fn num_window_choice_product(&self) -> usize { - self.strings - .iter() - .map(|s| s.len() - self.substring_length + 1) - .try_fold(1usize, usize::checked_mul) - .expect("validated window-choice count must fit usize") - } } impl Problem for ClosestSubstring { @@ -180,7 +165,6 @@ impl Problem for ClosestSubstring { ("substring_length", substring_length), ("total_length", total_length), ("total_num_windows", total_num_windows), - ("num_window_choice_product", num_window_choice_product), ]; fn variant() -> Vec<(&'static str, &'static str)> { @@ -244,7 +228,8 @@ impl crate::solvers::BruteForceProblem for ClosestSubstring { } crate::declare_variants! { - default ClosestSubstring => "alphabet_size ^ substring_length * num_window_choice_product", + // AM-GM bounds the window-count product; this is an upper bound, not the exact count. + default ClosestSubstring => "alphabet_size ^ substring_length * (total_num_windows / num_strings)^num_strings", } crate::register_brute_force! { diff --git a/src/models/misc/consistency_of_database_frequency_tables.rs b/src/models/misc/consistency_of_database_frequency_tables.rs index 653aab308..8cc6f33f2 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -167,31 +167,26 @@ fn validate_cdft_create( ); } } - domains - .iter() - .try_fold(1usize, |product, &size| product.checked_mul(size)) - .ok_or_else(|| { - crate::registry::ConstructionError::IntegerOverflow( - "representing the domain-size product".into(), - ) - })?; + num_objects.checked_mul(domains.len()).ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing the table-assignment witness length".into(), + ) + })?; domains .iter() .try_fold(0usize, |sum, &size| sum.checked_add(size)) - .and_then(|sum| num_objects.checked_mul(sum)) .ok_or_else(|| { crate::registry::ConstructionError::IntegerOverflow( - "representing assignment indicators".into(), + "representing the total domain size".into(), ) })?; tables .iter() .flat_map(|table| table.counts()) .try_fold(0usize, |sum, row| sum.checked_add(row.len())) - .and_then(|cells| num_objects.checked_mul(cells)) .ok_or_else(|| { crate::registry::ConstructionError::IntegerOverflow( - "representing auxiliary frequency indicators".into(), + "representing the number of frequency-table cells".into(), ) })?; let mut pairs = BTreeSet::new(); @@ -255,7 +250,7 @@ fn validate_cdft_create( impl ConsistencyOfDatabaseFrequencyTables { /// Create a new consistency-of-database-frequency-tables instance. - /// Domain and encoding counts must fit in `usize`. + /// Input parameter counts and the table-assignment witness length must fit in `usize`. pub fn new( num_objects: usize, attribute_domains: Vec, @@ -317,9 +312,9 @@ impl ConsistencyOfDatabaseFrequencyTables { &self.known_values } - /// Returns the product of attribute domain sizes. - pub fn domain_size_product(&self) -> usize { - self.attribute_domains.iter().copied().product() + /// Largest attribute domain; one for no attributes, whose assignment count is one. + pub fn max_domain_size(&self) -> usize { + self.attribute_domains.iter().copied().max().unwrap_or(1) } /// Returns the sum of all attribute-domain sizes. @@ -342,11 +337,6 @@ impl ConsistencyOfDatabaseFrequencyTables { self.known_values.len() } - /// Returns the number of one-hot assignment indicators used by the ILP reduction. - pub fn num_assignment_indicators(&self) -> usize { - self.num_objects * self.attribute_domains.iter().sum::() - } - /// Returns the total number of published frequency-table cells. pub fn num_frequency_cells(&self) -> usize { self.frequency_tables @@ -355,11 +345,6 @@ impl ConsistencyOfDatabaseFrequencyTables { .sum() } - /// Returns the number of auxiliary ILP indicators used for frequency-cell counting. - pub fn num_auxiliary_frequency_indicators(&self) -> usize { - self.num_objects * self.num_frequency_cells() - } - fn config_index(&self, object: usize, attribute: usize) -> usize { object * self.num_attributes() + attribute } @@ -374,7 +359,7 @@ impl Problem for ConsistencyOfDatabaseFrequencyTables { ("num_objects", num_objects), ("num_attributes", num_attributes), ("total_domain_size", total_domain_size), - ("domain_size_product", domain_size_product), + ("max_domain_size", max_domain_size), ("num_frequency_tables", num_frequency_tables), ("num_frequency_cells", num_frequency_cells), ("num_known_values", num_known_values), @@ -452,7 +437,8 @@ impl crate::solvers::BruteForceProblem for ConsistencyOfDatabaseFrequencyTables } crate::declare_variants! { - default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects" create ConsistencyOfDatabaseFrequencyTablesCreateSpec, + // Bound each attribute's choices by the largest domain, rather than storing their product. + default ConsistencyOfDatabaseFrequencyTables => "max_domain_size^(num_objects * num_attributes)" create ConsistencyOfDatabaseFrequencyTablesCreateSpec, } crate::register_brute_force! { diff --git a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs index f002f1bc5..823c87335 100644 --- a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs +++ b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs @@ -113,16 +113,12 @@ impl MinimumDiscretePlanarInverseKinematics { if orientation_samples.len() != n { return Err("orientation_samples must have one entry per link".into()); } - let mut total_configurations = 1_usize; for (link, samples) in orientation_samples.iter().enumerate() { if samples.is_empty() { return Err( format!("link {link} must have at least one candidate orientation").into(), ); } - total_configurations = total_configurations - .checked_mul(samples.len()) - .ok_or("orientation configuration count exceeds usize")?; for (sample, &angle) in samples.iter().enumerate() { if !angle.is_finite() { return Err(format!( @@ -180,16 +176,6 @@ impl MinimumDiscretePlanarInverseKinematics { self.link_lengths.len() } - /// Total number of configurations (product of per-link sample counts): - /// `prod_{j=1}^n m_j`. This is the size of the brute-force search space. - pub fn total_configurations(&self) -> usize { - self.orientation_samples - .iter() - .map(|samples| samples.len()) - .try_fold(1_usize, usize::checked_mul) - .expect("validated orientation configuration count must fit usize") - } - /// Total number of sampled orientations across all links: /// `sum_{j=1}^n m_j`. This is the QUBO variable count for the one-hot /// encoding used by the QUBO reduction. @@ -276,7 +262,6 @@ impl Problem for MinimumDiscretePlanarInverseKinematics { type Value = Min; crate::problem_parameters![ - ("total_configurations", total_configurations), ("num_links", num_links), ("num_orientation_samples", num_orientation_samples), ]; @@ -322,7 +307,8 @@ impl crate::solvers::BruteForceProblem for MinimumDiscretePlanarInverseKinematic } crate::declare_variants! { - default MinimumDiscretePlanarInverseKinematics => "total_configurations", + // AM-GM bounds the sample-count product; this is an upper bound, not the exact count. + default MinimumDiscretePlanarInverseKinematics => "(num_orientation_samples / num_links)^num_links", } crate::register_brute_force! { diff --git a/src/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/rules/consistencyofdatabasefrequencytables_ilp.rs index b927cef48..48fd15e1d 100644 --- a/src/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -33,7 +33,7 @@ impl ReductionCDFTToILP { } fn auxiliary_block_start(&self, table_index: usize) -> usize { - self.source.num_assignment_indicators() + self.source.num_objects() * self.assignment_block_size() + self.source.frequency_tables()[..table_index] .iter() .map(|table| self.source.num_objects() * table.num_cells()) @@ -158,24 +158,52 @@ impl ReduceTo> for ConsistencyOfDatabaseFrequencyTables { type Result = ReductionCDFTToILP; fn reduce_to(&self) -> Result { + let overflow = || { + crate::rules::ReductionError::integer_overflow::>( + "representing the database ILP encoding", + ) + }; + let assignments = self + .num_objects() + .checked_mul(self.total_domain_size()) + .ok_or_else(overflow)?; + let auxiliaries = self + .num_objects() + .checked_mul(self.num_frequency_cells()) + .ok_or_else(overflow)?; + let num_vars = assignments.checked_add(auxiliaries).ok_or_else(overflow)?; + let num_constraints = auxiliaries + .checked_mul(3) + .and_then(|count| count.checked_add(self.num_assignment_variables())) + .and_then(|count| count.checked_add(self.num_known_values())) + .and_then(|count| count.checked_add(self.num_frequency_cells())) + .ok_or_else(overflow)?; let source = self.clone(); let helper = ReductionCDFTToILP { target: ILP::empty(), source: source.clone(), }; - let mut constraints = Vec::with_capacity( - source.num_assignment_variables() - + source.num_known_values() - + source.num_frequency_cells() - + 3 * source.num_auxiliary_frequency_indicators(), - ); + let allocation_error = |error| { + crate::rules::ReductionError::invalid_target::>(format!( + "cannot allocate database ILP encoding: {error}" + )) + }; + let mut constraints = Vec::new(); + constraints + .try_reserve_exact(num_constraints) + .map_err(allocation_error)?; for object in 0..source.num_objects() { for (attribute, &domain_size) in source.attribute_domains().iter().enumerate() { - let terms = (0..domain_size) - .map(|value| (helper.assignment_var_index(object, attribute, value), 1)) - .collect(); + let mut terms = Vec::new(); + terms + .try_reserve_exact(domain_size) + .map_err(allocation_error)?; + terms.extend( + (0..domain_size) + .map(|value| (helper.assignment_var_index(object, attribute, value), 1)), + ); constraints.push(LinearConstraint::eq(terms, 1)); } } @@ -222,13 +250,8 @@ impl ReduceTo> for ConsistencyOfDatabaseFrequencyTables { } } - let target = ILP::new( - source.num_assignment_indicators() + source.num_auxiliary_frequency_indicators(), - constraints, - vec![], - ObjectiveSense::Minimize, - ) - .map_err(Self::target_construction)?; + let target = ILP::new(num_vars, constraints, vec![], ObjectiveSense::Minimize) + .map_err(Self::target_construction)?; Ok(ReductionCDFTToILP { target, source }) } diff --git a/src/unit_tests/models/misc/closest_substring.rs b/src/unit_tests/models/misc/closest_substring.rs index 1a98cfef5..17d4797cf 100644 --- a/src/unit_tests/models/misc/closest_substring.rs +++ b/src/unit_tests/models/misc/closest_substring.rs @@ -4,6 +4,17 @@ use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; +#[test] +fn large_window_product_does_not_restrict_model_evaluation() { + let problem = ClosestSubstring::new(1, vec![vec![0, 0]; 64], 1).unwrap(); + let restored: ClosestSubstring = + serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); + assert_eq!(restored.evaluate(&vec![0; 65]).unwrap(), Min(Some(0))); + assert_eq!(restored.parameters(), problem.parameters()); + assert_eq!(restored.parameters().get("total_num_windows"), Some(128)); + assert_eq!(restored.dimensions(), [vec![1], vec![2; 64]].concat()); +} + fn issue_instance() -> ClosestSubstring { // The #1033 canonical example: q = 2, ell = 3, three length-5 binary strings. ClosestSubstring::new( @@ -26,7 +37,6 @@ fn test_closest_substring_creation() { assert_eq!(problem.substring_length(), 3); assert_eq!(problem.total_length(), 15); assert_eq!(problem.total_num_windows(), 9); - assert_eq!(problem.num_window_choice_product(), 27); // dims: 3 center slots (each of size 2) + one window-position slot per // string (each of size W_i = 5 - 3 + 1 = 3). assert_eq!(problem.dimensions(), vec![2, 2, 2, 3, 3, 3]); @@ -120,7 +130,6 @@ fn test_closest_substring_specializes_to_closest_string() { 3, ) .unwrap(); - assert_eq!(problem.num_window_choice_product(), 1); assert_eq!(problem.dimensions(), vec![2, 2, 2, 1, 1, 1, 1]); let solver = BruteForce::new(); assert_eq!( diff --git a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs index ba1f19c42..3dd089f45 100644 --- a/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/unit_tests/models/misc/consistency_of_database_frequency_tables.rs @@ -1,19 +1,10 @@ use super::*; #[test] -fn domain_and_encoding_counts_must_fit_usize() { +fn input_counts_and_witness_length_must_fit_usize() { for (objects, domains, tables) in [ - (1, vec![2; usize::BITS as usize], vec![]), (0, vec![usize::MAX, 1], vec![]), - (usize::MAX, vec![2], vec![]), - ( - usize::MAX / 6 + 1, - vec![3, 1, 1], - vec![ - FrequencyTable::new(0, 1, vec![vec![0]; 3]), - FrequencyTable::new(0, 2, vec![vec![0]; 3]), - ], - ), + (usize::MAX, vec![1, 1], vec![]), ] { assert!(matches!( ConsistencyOfDatabaseFrequencyTables::try_new( @@ -26,17 +17,23 @@ fn domain_and_encoding_counts_must_fit_usize() { )); assert!(serde_json::from_value::(serde_json::json!({"num_objects": objects, "attribute_domains": domains, "frequency_tables": tables, "known_values": []})).is_err()); } - let problem = ConsistencyOfDatabaseFrequencyTables::new( - 1, - vec![2; usize::BITS as usize - 1], - vec![], - vec![], - ); - assert_eq!(problem.domain_size_product(), 1usize << (usize::BITS - 1)); +} + +#[test] +fn large_domain_product_does_not_restrict_model_evaluation() { + let problem = ConsistencyOfDatabaseFrequencyTables::new(1, vec![2; 64], vec![], vec![]); + let restored: ConsistencyOfDatabaseFrequencyTables = + serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); assert_eq!( - problem.num_assignment_indicators(), - 2 * (usize::BITS as usize - 1) + restored.evaluate(&vec![0; 64]).unwrap(), + crate::types::Or(true) ); + assert_eq!(restored.parameters(), problem.parameters()); + assert_eq!(restored.max_domain_size(), 2); + assert_eq!(restored.dimensions(), vec![2; 64]); + let empty = ConsistencyOfDatabaseFrequencyTables::new(0, vec![], vec![], vec![]); + assert_eq!(empty.max_domain_size(), 1); + assert_eq!(empty.evaluate(&vec![]).unwrap(), crate::types::Or(true)); } #[test] @@ -118,7 +115,7 @@ fn test_cdft_creation_and_getters() { let problem = issue_yes_instance(); assert_eq!(problem.num_objects(), 6); assert_eq!(problem.num_attributes(), 3); - assert_eq!(problem.domain_size_product(), 12); + assert_eq!(problem.max_domain_size(), 3); assert_eq!(problem.num_assignment_variables(), 18); assert_eq!(problem.attribute_domains(), &[2, 3, 2]); assert_eq!(problem.frequency_tables().len(), 2); diff --git a/src/unit_tests/models/misc/minimum_discrete_planar_inverse_kinematics.rs b/src/unit_tests/models/misc/minimum_discrete_planar_inverse_kinematics.rs index 6c82a88b4..9eeeaa600 100644 --- a/src/unit_tests/models/misc/minimum_discrete_planar_inverse_kinematics.rs +++ b/src/unit_tests/models/misc/minimum_discrete_planar_inverse_kinematics.rs @@ -7,6 +7,29 @@ use std::f64::consts::FRAC_PI_2; const EPS: f64 = 1e-9; +#[test] +fn large_orientation_product_does_not_restrict_evaluation_or_reduction() { + use crate::models::algebraic::QUBO; + use crate::rules::{ReduceTo, ReductionResult}; + + let problem = MinimumDiscretePlanarInverseKinematics::new( + vec![1.0; 64], + (64.0, 0.0), + vec![vec![0.0, 1.0]; 64], + vec![vec![(0, 0), (0, 1), (1, 0), (1, 1)]; 63], + ) + .unwrap(); + let restored: MinimumDiscretePlanarInverseKinematics = + serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); + assert_eq!(restored.evaluate(&vec![0; 64]).unwrap(), Min(Some(0.0))); + assert_eq!(restored.parameters(), problem.parameters()); + assert_eq!(restored.dimensions(), vec![2; 64]); + let reduction = ReduceTo::>::reduce_to(&restored).unwrap(); + assert_eq!(reduction.target_problem().num_vars(), 128); + let target = (0..128).map(|i| i % 2 == 0).collect(); + assert_eq!(reduction.extract_solution(&target).unwrap(), vec![0; 64]); +} + fn sample_problem() -> MinimumDiscretePlanarInverseKinematics { MinimumDiscretePlanarInverseKinematics::new( vec![2.0, 1.0], diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index 7d3db1786..a1d9c09f0 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -5,6 +5,67 @@ use crate::registry::variant::{ use crate::registry::{ConstructionError, CreateInputCodec, CreateInputInfo, FieldInfo}; use std::collections::{BTreeMap, BTreeSet}; +#[test] +fn complexity_bounds_cover_heterogeneous_choice_counts() { + use crate::models::misc::{ + ClosestSubstring, ConsistencyOfDatabaseFrequencyTables, + MinimumDiscretePlanarInverseKinematics, + }; + let cases: Vec<(&str, Box, f64, f64)> = vec![ + ( + "ClosestSubstring", + Box::new(ClosestSubstring::new(1, vec![vec![0; 2], vec![0; 4]], 1).unwrap()), + 8.0, + 9.0, + ), + ( + "MinimumDiscretePlanarInverseKinematics", + Box::new( + MinimumDiscretePlanarInverseKinematics::new( + vec![1.0, 1.0], + (2.0, 0.0), + vec![vec![0.0, 1.0], vec![0.0, 1.0, 2.0, 3.0]], + vec![vec![(0, 0)]], + ) + .unwrap(), + ), + 8.0, + 9.0, + ), + ( + "ConsistencyOfDatabaseFrequencyTables", + Box::new(ConsistencyOfDatabaseFrequencyTables::new( + 2, + vec![2, 3], + vec![], + vec![], + )), + 36.0, + 81.0, + ), + ( + "ConsistencyOfDatabaseFrequencyTables", + Box::new(ConsistencyOfDatabaseFrequencyTables::new( + 0, + vec![], + vec![], + vec![], + )), + 1.0, + 1.0, + ), + ]; + for (name, problem, exact_count, expected_bound) in cases { + let entry = variant_entries() + .into_iter() + .find(|entry| entry.name == name) + .unwrap(); + let bound = (entry.complexity_eval_fn)(problem.as_ref()); + assert_eq!(bound, expected_bound, "{name}"); + assert!(bound >= exact_count, "{name}"); + } +} + #[test] fn variant_alias_inventory_is_valid() { if let Err(conflicts) = validate_variant_aliases() { diff --git a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs index c9e07b866..18146da9e 100644 --- a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -6,6 +6,76 @@ use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; +#[test] +fn binary_attributes_reduce_without_materializing_the_domain_product() { + let source = ConsistencyOfDatabaseFrequencyTables::new(1, vec![2; 64], vec![], vec![]); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + assert_eq!(reduction.target_problem().num_vars(), 128); + assert_eq!(reduction.target_problem().num_constraints(), 64); + let witness = vec![0; 64]; + let encoded = reduction.encode_source_solution(&witness); + assert!(reduction + .target_problem() + .evaluate(&encoded) + .unwrap() + .value + .is_some()); + assert_eq!(reduction.extract_solution(&encoded).unwrap(), witness); +} + +#[test] +fn ilp_encoding_overflow_is_a_reduction_error() { + let auxiliary_objects = usize::MAX / 6 + 1; + for (objects, domains, tables) in [ + (usize::MAX / 2 + 1, vec![2], vec![]), + ( + auxiliary_objects, + vec![3, 1, 1], + vec![ + FrequencyTable::new(0, 1, vec![vec![auxiliary_objects as i64], vec![0], vec![0]]), + FrequencyTable::new(0, 2, vec![vec![auxiliary_objects as i64], vec![0], vec![0]]), + ], + ), + ( + usize::MAX / 3 + 1, + vec![1, 1], + vec![FrequencyTable::new( + 0, + 1, + vec![vec![(usize::MAX / 3 + 1) as i64]], + )], + ), + ( + usize::MAX / 4, + vec![1, 1], + vec![FrequencyTable::new( + 0, + 1, + vec![vec![(usize::MAX / 4) as i64]], + )], + ), + ] { + let source = ConsistencyOfDatabaseFrequencyTables::new(objects, domains, tables, vec![]); + let restored: ConsistencyOfDatabaseFrequencyTables = + serde_json::from_value(serde_json::to_value(&source).unwrap()).unwrap(); + assert_eq!(restored.parameters(), source.parameters()); + assert!(matches!( + ReduceTo::>::reduce_to(&restored), + Err(crate::rules::ReductionError::IntegerOverflow { .. }) + )); + } +} + +#[test] +fn unrepresentable_ilp_row_storage_does_not_restrict_source_evaluation() { + let source = ConsistencyOfDatabaseFrequencyTables::new(1, vec![usize::MAX], vec![], vec![]); + assert_eq!(source.evaluate(&vec![0]).unwrap(), crate::types::Or(true)); + assert!(matches!( + ReduceTo::>::reduce_to(&source), + Err(crate::rules::ReductionError::InvalidTarget { .. }) + )); +} + fn small_yes_instance() -> ConsistencyOfDatabaseFrequencyTables { ConsistencyOfDatabaseFrequencyTables::new( 2, From 5030cd60163a15603b5973ead01762eaa44c95c8 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 13:01:06 +0800 Subject: [PATCH 13/44] Clarify reduction construction and answer recovery definitions --- .claude/CLAUDE.md | 2 +- docs/paper/reductions.typ | 6 +++++- docs/src/design.md | 19 ++++++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f2cb96294..2465f66cd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -213,7 +213,7 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - Each primitive reduction is determined by the exact `(source_variant, target_variant)` endpoint pair - Reduction edges carry `EdgeCapabilities { witness, aggregate, turing }`; graph search defaults to witness mode, aggregate mode is available through `ReductionMode::Aggregate`, and Turing (multi-query) mode via `ReductionMode::Turing` - `#[reduction]` requires one `transform = exact`, `transform = upper_bound`, or `transform = unavailable` declaration and currently registers witness/config reductions; aggregate-only and Turing edges require manual `ReductionEntry` registration -- `Decision

→ P` supports both mappings: compare the exact optimum to the bound, and recover a witness only if it meets the bound. `P → Decision

` is a non-executable Turing edge. +- `Decision

→ P` supports both mappings: compare the exact optimum to the bound, and recover a witness only if it meets the bound. `P → Decision

` is a Turing edge (binary search over decision bound). ### Extension Points - New models register dynamic load/serialize metadata through `declare_variants!` and, when finite enumeration exists, register it separately through `register_brute_force!`; neither belongs in CLI match arms diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 3033f42b2..858cd360b 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -789,7 +789,11 @@ = Introduction -A _reduction_ from problem $A$ to problem $B$, denoted $A arrow.long B$, is a polynomial-time transformation of $A$-instances into $B$-instances such that: (1) the transformation runs in polynomial time, (2) solutions to $B$ can be efficiently mapped back to solutions of $A$, and (3) optimal solutions are preserved. The library implements #graph-data.edges.len() catalogued edges connecting #graph-data.nodes.len() problem types; most are solver-executable witness, aggregate, or Turing reductions, while a few are proof-only NP-hardness embeddings that are excluded from runtime path search. +A _single-instance reduction_ $A arrow.long B$ constructs a legal target instance $F(x)$ and recovers a correct source answer $G(x, y)$ from any correct target answer $y$. Both algorithms run in polynomial time in their encoded inputs. + +A correct answer is YES/NO, a valid witness, an optimal solution, or a total count, according to the problem. Infeasibility must be represented explicitly or excluded from the legal domain. Recovery must handle every optimal target solution, including ties; equal objective values and one-to-one witness mappings are not required. + +Turing reductions allow multiple adaptive queries, such as binary search over a decision bound. The library implements #graph-data.edges.len() catalogued edges connecting #graph-data.nodes.len() problem types. == Notation diff --git a/docs/src/design.md b/docs/src/design.md index c045e7921..49f67c464 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -233,7 +233,24 @@ relations within that variant family. ## Reduction Rules -A reduction requires two pieces: a **result struct** and a **`ReduceTo` impl**. +### Mathematical contract + +A single-instance reduction from A to B constructs a legal target instance F(x) +and recovers a correct source answer G(x, y) from **any** correct target answer y. +F and G run in polynomial time in their encoded inputs. + +“Correct answer” means YES/NO, a valid witness, an optimal solution, or a total +count, according to the problem; infeasibility must be represented explicitly +or excluded from the legal domain. All optimal target solutions, including ties, +must recover optimal source solutions. Equal objective values and one-to-one +witness mappings are not required. See [result mappings](#result-mappings). + +Turing reductions allow multiple adaptive queries: `P → Decision

` uses binary +search over the decision bound. + +### Witness-mapping implementation + +A witness-mapping reduction uses two pieces: a **result struct** and a **`ReduceTo` impl**. The result struct holds the target problem and the logic to map solutions back: From 22750e140f6eb7f33621fce231f7e9727eebd46d Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 15:33:22 +0800 Subject: [PATCH 14/44] Model decision targets explicitly and simplify CLI result extraction --- docs/paper/reductions.typ | 192 +++++------ docs/src/cli-commands.md | 19 +- docs/src/design.md | 5 +- ...hained_reduction_factoring_to_spinglass.rs | 4 +- problemreductions-cli/src/cli.rs | 70 ++-- problemreductions-cli/src/commands/extract.rs | 185 ++++++----- problemreductions-cli/src/commands/reduce.rs | 94 +++--- problemreductions-cli/src/commands/solve.rs | 4 +- problemreductions-cli/src/dispatch.rs | 314 ++++++++---------- problemreductions-cli/src/mcp/tools.rs | 8 +- problemreductions-cli/tests/cli_tests.rs | 221 +++++++----- .../algebraic/closest_vector_problem.rs | 42 +++ src/models/algebraic/quadratic_assignment.rs | 49 +++ src/models/algebraic/qubo.rs | 35 ++ .../formula/maximum_2_satisfiability.rs | 47 +++ src/models/graph/longest_circuit.rs | 59 ++++ src/models/graph/longest_path.rs | 41 +++ src/models/graph/max_cut.rs | 43 +++ src/models/graph/min_max_multicenter.rs | 51 +++ .../graph/minimum_covering_by_cliques.rs | 56 ++++ src/models/graph/minimum_sum_multicenter.rs | 62 ++++ src/models/graph/rural_postman.rs | 54 +++ src/models/graph/spin_glass.rs | 53 +++ src/models/misc/mod.rs | 6 +- src/models/misc/open_shop_scheduling.rs | 39 +++ ...equencing_to_minimize_tardy_task_weight.rs | 38 +++ src/models/misc/stacker_crane.rs | 45 +++ src/rules/circuit_spinglass.rs | 32 +- src/rules/coloring_qubo.rs | 50 +-- ...imumdominatingset_minimumsummulticenter.rs | 24 +- ...nminimumdominatingset_minmaxmulticenter.rs | 22 +- .../hamiltoniancircuit_longestcircuit.rs | 39 ++- .../hamiltoniancircuit_quadraticassignment.rs | 21 +- src/rules/hamiltoniancircuit_ruralpostman.rs | 37 ++- src/rules/hamiltoniancircuit_stackercrane.rs | 47 ++- ...onianpathbetweentwovertices_longestpath.rs | 48 ++- ...tisfiability_decisionminimumvertexcover.rs | 123 +++++-- .../ksatisfiability_minimumvertexcover.rs | 188 ----------- src/rules/ksatisfiability_qubo.rs | 84 ++--- src/rules/mod.rs | 29 +- src/rules/naesatisfiability_maxcut.rs | 36 +- src/rules/partition_openshopscheduling.rs | 31 +- ...ion_sequencingtominimizetardytaskweight.rs | 29 +- ...ionintocliques_minimumcoveringbycliques.rs | 72 ++-- src/rules/sat_maximumindependentset.rs | 34 +- src/rules/sat_minimumdominatingset.rs | 39 +-- .../satisfiability_maximum2satisfiability.rs | 57 ++-- src/rules/subsetsum_closestvectorproblem.rs | 41 ++- src/solvers/customized/solver.rs | 11 + src/solvers/pipelines.rs | 2 + src/unit_tests/example_db.rs | 24 +- src/unit_tests/reduction_graph.rs | 30 +- src/unit_tests/registry/variant.rs | 4 + src/unit_tests/rules/aggregate_contracts.rs | 10 +- src/unit_tests/rules/circuit_spinglass.rs | 127 +++++-- src/unit_tests/rules/coloring_qubo.rs | 49 ++- ...imumdominatingset_minimumsummulticenter.rs | 60 +++- ...nminimumdominatingset_minmaxmulticenter.rs | 58 +++- src/unit_tests/rules/graph.rs | 24 +- .../hamiltoniancircuit_longestcircuit.rs | 58 +++- .../hamiltoniancircuit_quadraticassignment.rs | 100 ++++-- .../rules/hamiltoniancircuit_ruralpostman.rs | 51 ++- .../rules/hamiltoniancircuit_stackercrane.rs | 45 ++- ...onianpathbetweentwovertices_longestpath.rs | 57 +++- .../ksatisfiability_minimumvertexcover.rs | 156 --------- src/unit_tests/rules/ksatisfiability_qubo.rs | 91 +++-- .../rules/naesatisfiability_maxcut.rs | 86 +++-- .../rules/partition_openshopscheduling.rs | 83 ++++- ...ion_sequencingtominimizetardytaskweight.rs | 100 ++++-- ...ionintocliques_minimumcoveringbycliques.rs | 56 +++- src/unit_tests/rules/reduction_path_parity.rs | 4 +- src/unit_tests/rules/registry.rs | 6 +- .../rules/sat_maximumindependentset.rs | 120 ++++--- .../rules/sat_minimumdominatingset.rs | 131 +++++--- .../satisfiability_maximum2satisfiability.rs | 60 +++- .../rules/subsetsum_closestvectorproblem.rs | 74 +++-- .../customized/closest_vector_problem.rs | 26 ++ tests/suites/reductions.rs | 24 +- 78 files changed, 2941 insertions(+), 1705 deletions(-) delete mode 100644 src/rules/ksatisfiability_minimumvertexcover.rs delete mode 100644 src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 858cd360b..b61d587a1 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -381,6 +381,21 @@ "MinimumGraphBandwidth": [Minimum Graph Bandwidth], "MinimumMetricDimension": [Minimum Metric Dimension], "DecisionMinimumDominatingSet": [Decision Minimum Dominating Set], + "DecisionClosestVectorProblem": [Decision Closest Vector Problem], + "DecisionQuadraticAssignment": [Decision Quadratic Assignment], + "DecisionQUBO": [Decision QUBO], + "DecisionMaximum2Satisfiability": [Decision Maximum 2-Satisfiability], + "DecisionLongestCircuit": [Decision Longest Circuit], + "DecisionLongestPath": [Decision Longest Path], + "DecisionMaxCut": [Decision Max-Cut], + "DecisionMinMaxMulticenter": [Decision Min-Max Multicenter], + "DecisionMinimumCoveringByCliques": [Decision Minimum Covering by Cliques], + "DecisionMinimumSumMulticenter": [Decision Minimum Sum Multicenter], + "DecisionRuralPostman": [Decision Rural Postman], + "DecisionSpinGlass": [Decision Spin Glass], + "DecisionOpenShopScheduling": [Decision Open Shop Scheduling], + "DecisionSequencingToMinimizeTardyTaskWeight": [Decision Sequencing to Minimize Tardy Task Weight], + "DecisionStackerCrane": [Decision Stacker Crane], "DecisionMinimumVertexCover": [Decision Minimum Vertex Cover], "DecisionOptimalLinearArrangement": [Decision Optimal Linear Arrangement], "MinimumCodeGenerationUnlimitedRegisters": [Minimum Code Generation (Unlimited Registers)], @@ -11601,12 +11616,12 @@ the displayed rule, extracted from the corresponding `pred path` entry. #let dmds_mmmc = load-example( "DecisionMinimumDominatingSet", - "MinMaxMulticenter", + "DecisionMinMaxMulticenter", source-variant: (graph: "SimpleGraph", weight: "One"), target-variant: (graph: "SimpleGraph", weight: "One"), ) #let dmds_mmmc_sol = dmds_mmmc.solutions.at(0) -#reduction-rule("DecisionMinimumDominatingSet", "MinMaxMulticenter", +#reduction-rule("DecisionMinimumDominatingSet", "DecisionMinMaxMulticenter", example: true, example-source-variant: (graph: "SimpleGraph", weight: "One"), example-target-variant: (graph: "SimpleGraph", weight: "One"), @@ -11620,7 +11635,7 @@ the displayed rule, extracted from the corresponding `pred path` entry. ) *Step 1 -- Source instance.* The source graph has vertices ${0, 1, 2, 3, 4, 5}$, edges #{dmds_mmmc.source.instance.inner.graph.edges.map(e => $(#e.at(0), #e.at(1))$).join(", ")}, and bound $K = #dmds_mmmc.source.instance.bound$. The stored dominating-set witness is $D = {#dmds_mmmc_sol.source_config.enumerate().filter(((i, x)) => x).map(((i, _)) => str(i)).join(", ")}$. - *Step 2 -- Build the target instance.* Append two isolated vertices, assign weight $1$ to every vertex and length $1$ to every edge, and set the number of centers to $k = #dmds_mmmc.target.instance.k$. The target therefore has $#graph-num-vertices(dmds_mmmc.target.instance)$ vertices and $#graph-num-edges(dmds_mmmc.target.instance)$ edges. + *Step 2 -- Build the target instance.* Append two isolated vertices, assign weight $1$ to every vertex and length $1$ to every edge, and set the number of centers to $k = #dmds_mmmc.target.instance.inner.k$. The target therefore has $#graph-num-vertices(dmds_mmmc.target.instance)$ vertices and $#graph-num-edges(dmds_mmmc.target.instance)$ edges. *Step 3 -- Verify a witness.* Choosing centers $P = {#dmds_mmmc_sol.target_config.enumerate().filter(((i, x)) => x).map(((i, _)) => str(i)).join(", ")}$ yields distances $(0, 1, 1, 0, 1, 1, 0, 0)$ to the nearest center, so the maximum weighted distance is $1$. Discarding the two auxiliary center bits recovers a dominating set of size $2$ #sym.checkmark ], @@ -11631,17 +11646,17 @@ the displayed rule, extracted from the corresponding `pred path` entry. _Correctness._ Every finite target placement must select both isolated vertices. If a source dominating set $D$ has $|D|<=K$, then $q>=0$ and $|D|<=q<=n$. Extend $D$ to $q$ original vertices and add $a,b$. This placement has $k$ centers and radius at most $1$, proving the forward direction. Conversely, a target placement of radius at most $1$ selects both isolates and exactly $q$ original vertices. Each original vertex is within one original edge of a selected vertex, so those $q<=K$ vertices dominate $G$. For $K<0$, $k=1$ cannot cover both isolates and the target has no finite placement. For $n=0,K>=0$, the two isolates form a radius-zero placement. Loops and repeated edges preserve this reasoning. - _Solution extraction and NO instances._ Evaluate the full target indicator first. A finite radius at most $1$ permits extraction of its first $n$ bits. Any larger radius or infeasible placement is rejected. The formal aggregate map sends an optimum $r<=1$ to true, and an optimum $r>1$ or infeasibility to false. In particular, a four-vertex path with $K=1$ produces optimum radius $2$, not an infeasible target. Checked parameter arithmetic precedes allocation; unrepresentable counts return the formal numeric error. Target sizes are exactly $n+2$ vertices and $m$ edge records. + _Solution extraction and NO instances._ The target is Decision Min-Max Multicenter with bound $1$. Its predicate checks the full placement. Decode a YES witness by taking its first $n$ bits; completed YES and NO answers pass through unchanged. In particular, a four-vertex path with $K=1$ produces optimum radius $2$, not an infeasible target. Checked parameter arithmetic precedes allocation; unrepresentable counts return the formal numeric error. Target sizes are exactly $n+2$ vertices and $m$ edge records. ] #let dmds_msmc = load-example( "DecisionMinimumDominatingSet", - "MinimumSumMulticenter", + "DecisionMinimumSumMulticenter", source-variant: (graph: "SimpleGraph", weight: "One"), target-variant: (graph: "SimpleGraph", weight: "i64"), ) #let dmds_msmc_sol = dmds_msmc.solutions.at(0) -#reduction-rule("DecisionMinimumDominatingSet", "MinimumSumMulticenter", +#reduction-rule("DecisionMinimumDominatingSet", "DecisionMinimumSumMulticenter", example: true, example-source-variant: (graph: "SimpleGraph", weight: "One"), example-target-variant: (graph: "SimpleGraph", weight: "i64"), @@ -11655,7 +11670,7 @@ the displayed rule, extracted from the corresponding `pred path` entry. ) *Step 1 -- Source instance.* The source graph has vertices ${0, 1, 2, 3, 4, 5}$, edges #{dmds_msmc.source.instance.inner.graph.edges.map(e => $(#e.at(0), #e.at(1))$).join(", ")}, and decision bound $K = #dmds_msmc.source.instance.bound$. The stored dominating-set witness is $D = {#dmds_msmc_sol.source_config.enumerate().filter(((i, x)) => x).map(((i, _)) => str(i)).join(", ")}$. - *Step 2 -- Build the target instance.* Add one isolated vertex $z$, assign vertex weight $1$ everywhere, assign edge length $1$ everywhere, and set the target center count to $k = #dmds_msmc.target.instance.k$. The comparison threshold is $B = |V| - K = 6 - 2 = 4$. + *Step 2 -- Build the target instance.* Add one isolated vertex $z$, assign vertex weight $1$ everywhere, assign edge length $1$ everywhere, and set the target center count to $k = #dmds_msmc.target.instance.inner.k$. The comparison threshold is $B = |V| - K = 6 - 2 = 4$. *Step 3 -- Verify a witness.* Choosing centers $P = {#dmds_msmc_sol.target_config.enumerate().filter(((i, x)) => x).map(((i, _)) => str(i)).join(", ")}$ yields distances $(0, 1, 1, 0, 1, 1, 0)$ to the nearest center, so the total weighted distance is $4 = B$. The extracted source witness removes the coordinate of $z$, hence a valid YES witness for the original decision instance #sym.checkmark ], @@ -11672,7 +11687,7 @@ the displayed rule, extracted from the corresponding `pred path` entry. _Boundary cases._ If $K = 0 < n$, one center cannot serve both the isolate and the original graph, so the target is infeasible. If $n = 0$ and $K >= 0$, the sole vertex $z$ is selected and the cost is zero, correctly certifying the empty dominating set. If $K >= n$, selecting all target vertices gives cost zero and extracts all original vertices. Negative bounds give infeasibility as shown above. - _Value and solution extraction._ Map a finite target optimum equal to $B$ to YES; map any other optimum or infeasibility to NO. For negative bounds use comparison value $-1$, which no finite nonnegative target cost can equal. Extract a source witness only from a placement whose cost equals the comparison value, by removing the auxiliary coordinates. Reject every other placement; an optimal target solution with cost greater than $B$ is not a source YES witness. + _Value and solution extraction._ The target is Decision Minimum Sum Multicenter with bound $B$ (or $-1$ for a negative source bound). Its predicate enforces the cost bound. Decode a YES witness by removing the auxiliary coordinates; completed YES and NO answers pass through unchanged. ] #let mvc_mmm = load-example("MinimumVertexCover", "MinimumMaximalMatching") @@ -12102,9 +12117,9 @@ The _penalty method_ @glover2019 @lucas2014 converts a constrained optimization $ f(bold(x)) = "obj"(bold(x)) + P sum_k g_k (bold(x))^2 $ where $P$ is a penalty weight large enough that any constraint violation costs more than the entire objective range. Since $g_k (bold(x))^2 >= 0$ with equality iff $g_k (bold(x)) = 0$, minimizers of $f$ are feasible and optimal for the original problem. Because binary variables satisfy $x_i^2 = x_i$, the resulting $f$ is a quadratic in $bold(x)$, i.e.\ a QUBO. -#let kc_qubo = load-example("KColoring", "QUBO") +#let kc_qubo = load-example("KColoring", "DecisionQUBO") #let kc_qubo_sol = kc_qubo.solutions.at(0) -#reduction-rule("KColoring", "QUBO", +#reduction-rule("KColoring", "DecisionQUBO", example: true, example-caption: [House graph ($n = 5$, $|E| = 6$, $chi = 3$) with $k = 3$ colors], extra: [ @@ -12173,7 +12188,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ Return $bold(x)$ directly. There are exactly $m$ target variables. ] -#reduction-rule("KSatisfiability", "QUBO")[ +#reduction-rule("KSatisfiability", "DecisionQUBO")[ Clause falsification penalties become a quadratic objective using Rosenberg quadratization. Retain its omitted constant to decode the SAT decision, rather than interpreting an arbitrary QUBO configuration as a satisfying assignment. ][ _Construction._ Let $n$ be the number of source variables and $m$ the clause count. For each literal let $y$ be its falsity indicator: $y=1-x$ for a positive literal and $y=x$ for a negative one. For widths zero, one and two, the clause penalty is respectively $1$, $y_1$, and $y_1 y_2$. For width three use @@ -12292,17 +12307,17 @@ where $P$ is a penalty weight large enough that any constraint violation costs m ] #{ - let ss-cvp = load-example("SubsetSum", "ClosestVectorProblem") + let ss-cvp = load-example("SubsetSum", "DecisionClosestVectorProblem") let ss-cvp-sol = ss-cvp.solutions.at(0) let ss-cvp-sizes = ss-cvp.source.instance.sizes let ss-cvp-target = ss-cvp.source.instance.target - let ss-cvp-basis = ss-cvp.target.instance.basis - let ss-cvp-target-vec = ss-cvp.target.instance.target + let ss-cvp-basis = ss-cvp.target.instance.inner.basis + let ss-cvp-target-vec = ss-cvp.target.instance.inner.target let ss-cvp-n = ss-cvp-sizes.len() let ss-cvp-x = ss-cvp-sol.target_config let to-mat(m) = math.mat(..m.map(row => row.map(v => $#v$))) [ - #reduction-rule("SubsetSum", "ClosestVectorProblem", + #reduction-rule("SubsetSum", "DecisionClosestVectorProblem", example: true, example-caption: [#ss-cvp-n elements, target sum $B = #ss-cvp-target$], extra: [ @@ -12771,9 +12786,9 @@ where $P$ is a penalty weight large enough that any constraint violation costs m == Non-Trivial Reductions -#let sat_mis = load-example("Satisfiability", "MaximumIndependentSet") +#let sat_mis = load-example("Satisfiability", "DecisionMaximumIndependentSet") #let sat_mis_sol = sat_mis.solutions.at(0) -#reduction-rule("Satisfiability", "MaximumIndependentSet", +#reduction-rule("Satisfiability", "DecisionMaximumIndependentSet", example: true, example-caption: [3-SAT with 5 variables and 7 clauses], extra: [ @@ -12784,7 +12799,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m "pred evaluate sat.json --config " + cli-config(sat_mis_sol.source_config), ) SAT assignment: $(x_1, ..., x_5) = (#fmt-values(sat_mis_sol.source_config))$ \ - IS graph: #graph-num-vertices(sat_mis.target.instance) vertices ($= 3 times #sat-num-clauses(sat_mis.source.instance)$ literals), #graph-num-edges(sat_mis.target.instance) edges \ + IS graph: #graph-num-vertices(sat_mis.target.instance.inner) vertices ($= 3 times #sat-num-clauses(sat_mis.source.instance)$ literals), #graph-num-edges(sat_mis.target.instance.inner) edges \ IS of size #sat-num-clauses(sat_mis.source.instance) $= m$: one vertex per clause $arrow.r$ satisfying assignment #sym.checkmark ], )[ @@ -12831,9 +12846,9 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ Set $x_i = 1$ iff $"color"("pos"_i) = "color"("TRUE")$. ] -#let sat_ds = load-example("Satisfiability", "MinimumDominatingSet") +#let sat_ds = load-example("Satisfiability", "DecisionMinimumDominatingSet") #let sat_ds_sol = sat_ds.solutions.at(0) -#reduction-rule("Satisfiability", "MinimumDominatingSet", +#reduction-rule("Satisfiability", "DecisionMinimumDominatingSet", example: true, example-caption: [5-variable 7-clause 3-SAT to dominating set], extra: [ @@ -12844,7 +12859,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m "pred evaluate sat.json --config " + cli-config(sat_ds_sol.source_config), ) SAT assignment: $(x_1, ..., x_5) = (#fmt-values(sat_ds_sol.source_config))$ \ - Vertex structure: $#graph-num-vertices(sat_ds.target.instance) = 3 times #sat_ds.source.instance.num_vars + #sat-num-clauses(sat_ds.source.instance)$ (variable triangles + clause vertices) \ + Vertex structure: $#graph-num-vertices(sat_ds.target.instance.inner) = 3 times #sat_ds.source.instance.num_vars + #sat-num-clauses(sat_ds.source.instance)$ (variable triangles + clause vertices) \ Dominating set of size $n = #sat_ds.source.instance.num_vars$: one vertex per variable triangle #sym.checkmark ], )[ @@ -12946,9 +12961,9 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ Discard auxiliary variables; return original variable assignments. ] -#let sat_max2sat = load-example("Satisfiability", "Maximum2Satisfiability") +#let sat_max2sat = load-example("Satisfiability", "DecisionMaximum2Satisfiability") #let sat_max2sat_sol = sat_max2sat.solutions.at(0) -#reduction-rule("Satisfiability", "Maximum2Satisfiability", +#reduction-rule("Satisfiability", "DecisionMaximum2Satisfiability", example: true, example-caption: [3-variable 2-clause SAT to MAX-2-SAT], extra: [ @@ -12972,7 +12987,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m $ The normalized formula therefore has $4$ variables and $3$ clauses. - *Step 3 -- Build the MAX-2-SAT gadgets.* Introduce one gadget variable per normalized clause, so the target has $#sat_max2sat.target.instance.num_vars$ variables and #sat_max2sat.target.instance.clauses.len() clauses. The stored witness is $(x_1, x_2, x_3, y_1, w_1, w_2, w_3) = (#fmt-values(sat_max2sat_sol.target_config))$. With $(y_1, w_1, w_2, w_3) = (0, 1, 0, 1)$, each of the three gadgets satisfies exactly $7$ clauses, so the target objective reaches $21 = 7 times 3$ #sym.checkmark. + *Step 3 -- Build the MAX-2-SAT gadgets.* Introduce one gadget variable per normalized clause, so the target has $#sat_max2sat.target.instance.inner.num_vars$ variables and #sat_max2sat.target.instance.inner.clauses.len() clauses. The stored witness is $(x_1, x_2, x_3, y_1, w_1, w_2, w_3) = (#fmt-values(sat_max2sat_sol.target_config))$. With $(y_1, w_1, w_2, w_3) = (0, 1, 0, 1)$, each of the three gadgets satisfies exactly $7$ clauses, so the target objective reaches $21 = 7 times 3$ #sym.checkmark. *Multiplicity:* The fixture stores one canonical optimum. Auxiliary variables such as $y_1$ can vary across optimal witnesses, but truncating any optimal target assignment to the first $3$ coordinates still yields a satisfying assignment of the original SAT formula. ], @@ -13086,9 +13101,9 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ Return the values of the named circuit variables and discard the auxiliary Tseitin variables. ] -#let cs_sg = load-example("CircuitSAT", "SpinGlass") +#let cs_sg = load-example("CircuitSAT", "DecisionSpinGlass") #let cs_sg_sol = cs_sg.solutions.at(0) -#reduction-rule("CircuitSAT", "SpinGlass", +#reduction-rule("CircuitSAT", "DecisionSpinGlass", example: true, example-caption: [1-bit full adder to Ising model], extra: [ @@ -13099,7 +13114,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m "pred evaluate circuitsat.json --config " + cli-config(cs_sg_sol.source_config), ) Circuit: #circuit-num-gates(cs_sg.source.instance) gates (2 XOR, 2 AND, 1 OR), #circuit-num-variables(cs_sg.source.instance) variables \ - Target: #spin-num-spins(cs_sg.target.instance) spins (each gate allocates I/O + auxiliary spins) \ + Target: #spin-num-spins(cs_sg.target.instance.inner) spins (each gate allocates I/O + auxiliary spins) \ Canonical ground-state witness shown ($2^3$ valid input combinations exist for the full adder) #sym.checkmark ], )[ @@ -15648,14 +15663,14 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Evaluate the target once, reject an infeasible assignment, and select precisely the stored edges whose edge-use block contains a one. Parallel edges keep their individual identities. ] -#let hc_lc = load-example("HamiltonianCircuit", "LongestCircuit") +#let hc_lc = load-example("HamiltonianCircuit", "DecisionLongestCircuit") #let hc_lc_sol = hc_lc.solutions.at(0) #let hc_lc_n = graph-num-vertices(hc_lc.source.instance) #let hc_lc_source_edges = hc_lc.source.instance.graph.edges -#let hc_lc_target_edges = hc_lc.target.instance.graph.edges -#let hc_lc_target_weights = hc_lc.target.instance.edge_lengths +#let hc_lc_target_edges = hc_lc.target.instance.inner.graph.edges +#let hc_lc_target_weights = hc_lc.target.instance.inner.edge_lengths #let hc_lc_selected_edges = hc_lc_target_edges.enumerate().filter(((i, _)) => hc_lc_sol.target_config.at(i)).map(((i, e)) => (e.at(0), e.at(1))) -#reduction-rule("HamiltonianCircuit", "LongestCircuit", +#reduction-rule("HamiltonianCircuit", "DecisionLongestCircuit", example: true, example-caption: [Cycle graph on $#hc_lc_n$ vertices with unit edge lengths], extra: [ @@ -16769,22 +16784,22 @@ The following table shows concrete target-variable counts for example instances, ), (source: "QUBO", target: "SpinGlass"), (source: "ClosestVectorProblem", target: "QUBO"), - (source: "KColoring", target: "QUBO"), + (source: "KColoring", target: "DecisionQUBO"), (source: "MaximumSetPacking", target: "QUBO"), ( source: "KSatisfiability", - target: "QUBO", + target: "DecisionQUBO", source-variant: (k: "K3"), target-variant: (weight: "i64"), ), (source: "ILP", target: "QUBO"), - (source: "Satisfiability", target: "MaximumIndependentSet"), - (source: "Satisfiability", target: "Maximum2Satisfiability"), + (source: "Satisfiability", target: "DecisionMaximumIndependentSet"), + (source: "Satisfiability", target: "DecisionMaximum2Satisfiability"), (source: "Satisfiability", target: "KColoring"), - (source: "Satisfiability", target: "MinimumDominatingSet"), + (source: "Satisfiability", target: "DecisionMinimumDominatingSet"), (source: "Satisfiability", target: "KSatisfiability"), (source: "CircuitSAT", target: "Satisfiability"), - (source: "CircuitSAT", target: "SpinGlass"), + (source: "CircuitSAT", target: "DecisionSpinGlass"), (source: "Factoring", target: "CircuitSAT"), (source: "MaximumSetPacking", target: "ILP"), (source: "MaximumMatching", target: "ILP"), @@ -17334,46 +17349,6 @@ The following table shows concrete target-variable counts for example instances, _Solution extraction._ Given a Hamiltonian circuit witness, inspect the two endpoints of each source vertex-path. Set $x_v = 1$ iff both path endpoints are adjacent to selector vertices in the cycle; otherwise set $x_v = 0$. Restore every vertex forced by a source loop. The resulting indicator vector is a valid source-side vertex cover. ] -#let ksat_mvc = load-example("KSatisfiability", "MinimumVertexCover") -#let ksat_mvc_sol = ksat_mvc.solutions.at(0) -#reduction-rule("KSatisfiability", "MinimumVertexCover", - example: true, - example-caption: [3-SAT with $n = #ksat_mvc.source.instance.num_vars$ variables, $m = #sat-num-clauses(ksat_mvc.source.instance)$ clauses], - extra: [ - #pred-commands( - "pred create --example " + problem-spec(ksat_mvc.source) + " -o ksat.json", - "pred reduce ksat.json --via route.json -o bundle.json", - "pred solve bundle.json", - "pred evaluate ksat.json --config " + cli-config(ksat_mvc_sol.source_config), - ) - - *Step 1 -- Source instance.* The 3-SAT formula has $n = #ksat_mvc.source.instance.num_vars$ variables and $m = #sat-num-clauses(ksat_mvc.source.instance)$ clauses: #{ksat_mvc.source.instance.clauses.enumerate().map(((j, c)) => { - let lits = c.literals.map(l => if l > 0 { $x_#l$ } else { $overline(x)_#calc.abs(l)$ }) - [$c_#j = (#lits.join($or$))$] - }).join(", ")}. A satisfying assignment is $(#fmt-values(ksat_mvc_sol.source_config))$, i.e.\ #{range(ksat_mvc.source.instance.num_vars).map(i => { - let v = ksat_mvc_sol.source_config.at(i) - if v { $x_#(i+1) = 1$ } else { $x_#(i+1) = 0$ } - }).join(", ")}. - - *Step 2 -- Truth-setting edges.* For each variable $x_i$, create vertices $u_i$ (index $2(i-1)$) and $overline(u)_i$ (index $2(i-1)+1$) connected by a truth-setting edge. This gives $2n = #(2 * ksat_mvc.source.instance.num_vars)$ literal vertices and $n = #ksat_mvc.source.instance.num_vars$ edges. - - *Step 3 -- Clause triangles and communication edges.* For each clause $c_j$, create a triangle of 3 vertices at indices $2n + 3j, 2n + 3j + 1, 2n + 3j + 2$, connected by 3 internal edges. Each triangle vertex $t^j_k$ is also connected to its literal vertex by a communication edge (3 per clause). Total: $3m = #(3 * sat-num-clauses(ksat_mvc.source.instance))$ clause vertices, $3m = #(3 * sat-num-clauses(ksat_mvc.source.instance))$ triangle edges, $3m = #(3 * sat-num-clauses(ksat_mvc.source.instance))$ communication edges. - - *Step 4 -- Target graph dimensions.* The resulting graph has $|V| = 2n + 3m = #ksat_mvc.target.instance.graph.num_vertices$ vertices and $|E| = n + 6m = #ksat_mvc.target.instance.graph.edges.len()$ edges, with unit weights. - - *Step 5 -- Verify a solution.* The satisfying assignment $(#fmt-values(ksat_mvc_sol.source_config))$ maps to a vertex cover of size $n + 2m = #(ksat_mvc.source.instance.num_vars + 2 * sat-num-clauses(ksat_mvc.source.instance))$. The target configuration is $(#fmt-values(ksat_mvc_sol.target_config))$: the cover selects #ksat_mvc_sol.target_config.filter(x => x).len() vertices. For each truth-setting edge, exactly one endpoint is in the cover #sym.checkmark. For each clause triangle, exactly two of three vertices are covered #sym.checkmark. Each communication edge has at least one endpoint in the cover #sym.checkmark. - - *Multiplicity:* The fixture stores one canonical witness. Other valid covers correspond to different satisfying assignments of the formula. - ], -)[ - Each variable contributes a truth-setting edge; each clause contributes a satisfaction-testing triangle. The formula is satisfiable iff the graph has a vertex cover of size $n + 2m$. -][ - _Construction._ Given 3-CNF $phi$ with $n$ variables and $m$ clauses, construct $G = (V, E)$ with $|V| = 2n + 3m$. For each variable $x_i$: vertices $u_i$ (index $2i$) and $overline(u)_i$ (index $2i+1$) with edge $(u_i, overline(u)_i)$. For each clause $c_j$: triangle vertices $t^j_0, t^j_1, t^j_2$ at indices $2n + 3j, 2n+3j+1, 2n+3j+2$. Communication edges connect each $t^j_k$ to the literal vertex of its $k$-th literal. - - _Correctness._ ($arrow.r.double$) A satisfying assignment selects literal vertices ($n$ total) and two triangle vertices per clause ($2m$ total), covering all edges. ($arrow.l.double$) A cover of size $n + 2m$ must include exactly one literal vertex per variable and two triangle vertices per clause; the uncovered triangle vertex's communication edge forces the corresponding literal to be true. - - _Solution extraction._ For variable $x_i$, set $x_i = 1$ if the cover indicator at position $2i$ is 1. -] #let ksat_mono = load-example("KSatisfiability", "MonochromaticTriangle") #let ksat_mono_sol = ksat_mono.solutions.at(0) @@ -17913,13 +17888,13 @@ The following table shows concrete target-variable counts for example instances, _Solution extraction._ Follow unique successors from vertex 0 to recover the Hamiltonian permutation. ] -#let hc_sc = load-example("HamiltonianCircuit", "StackerCrane") +#let hc_sc = load-example("HamiltonianCircuit", "DecisionStackerCrane") #let hc_sc_sol = hc_sc.solutions.at(0) #let hc_sc_n = graph-num-vertices(hc_sc.source.instance) #let hc_sc_source_edges = hc_sc.source.instance.graph.edges -#let hc_sc_target_arcs = hc_sc.target.instance.arcs -#let hc_sc_target_edges = hc_sc.target.instance.edges -#reduction-rule("HamiltonianCircuit", "StackerCrane", +#let hc_sc_target_arcs = hc_sc.target.instance.inner.arcs +#let hc_sc_target_edges = hc_sc.target.instance.inner.edges +#reduction-rule("HamiltonianCircuit", "DecisionStackerCrane", example: true, example-caption: [Cycle $C_#hc_sc_n$ ($n = #hc_sc_n$): vertex splitting to Stacker Crane], extra: [ @@ -17932,7 +17907,7 @@ The following table shows concrete target-variable counts for example instances, *Step 1 -- Source instance.* The canonical source fixture is the cycle $C_#hc_sc_n$ on vertices ${0, dots, #(hc_sc_n - 1)}$ with #hc_sc_source_edges.len() edges: #hc_sc_source_edges.map(e => $(#e.at(0), #e.at(1))$).join(", "). The stored Hamiltonian-circuit witness is the permutation $[#fmt-values(hc_sc_sol.source_config)]$.\ - *Step 2 -- Construction.* Each vertex $v_i$ splits into $v_i^"in" = 2i$ and $v_i^"out" = 2i + 1$, giving $2 dot #hc_sc_n = #hc_sc.target.instance.num_vertices$ vertices. The reduction creates #hc_sc_target_arcs.len() mandatory arcs: #hc_sc_target_arcs.map(a => $(#a.at(0) arrow #a.at(1))$).join(", "), each of length 1. For each source edge, two undirected connector edges of length 1 are added, giving $2 dot #hc_sc_source_edges.len() = #hc_sc_target_edges.len()$ connector edges: #hc_sc_target_edges.map(e => ${#e.at(0), #e.at(1)}$).join(", ").\ + *Step 2 -- Construction.* Each vertex $v_i$ splits into $v_i^"in" = 2i$ and $v_i^"out" = 2i + 1$, giving $2 dot #hc_sc_n = #hc_sc.target.instance.inner.num_vertices$ vertices. The reduction creates #hc_sc_target_arcs.len() mandatory arcs: #hc_sc_target_arcs.map(a => $(#a.at(0) arrow #a.at(1))$).join(", "), each of length 1. For each source edge, two undirected connector edges of length 1 are added, giving $2 dot #hc_sc_source_edges.len() = #hc_sc_target_edges.len()$ connector edges: #hc_sc_target_edges.map(e => ${#e.at(0), #e.at(1)}$).join(", ").\ *Step 3 -- Verify a solution.* The stored target configuration $[#fmt-values(hc_sc_sol.target_config)]$ is a permutation of arcs. Following this order: arc #hc_sc_sol.target_config.at(0) serves $(#hc_sc_target_arcs.at(hc_sc_sol.target_config.at(0)).at(0) arrow #hc_sc_target_arcs.at(hc_sc_sol.target_config.at(0)).at(1))$, then a connector edge leads to the next arc, and so on. The tour traverses $#hc_sc_target_arcs.len()$ arcs (cost $#hc_sc_target_arcs.len()$) and $#hc_sc_target_arcs.len()$ connector edges (cost $#hc_sc_target_arcs.len()$), for total cost $2 dot #hc_sc_n = #(hc_sc_n * 2)$. Recovering the source witness: arc $i$ corresponds to vertex $i$, so the permutation $[#fmt-values(hc_sc_sol.source_config)]$ is the Hamiltonian circuit #sym.checkmark\ @@ -17952,10 +17927,10 @@ The following table shows concrete target-variable counts for example instances, _Solution extraction._ Evaluate once, apply the same aggregate certificate predicate, and reject non-certifying tours with an extraction error. Otherwise the service permutation is the source vertex order. The target evaluator permits service arcs on connector paths; the proof remains valid because equality forces each connector to be a single undirected edge. No target-definition change is required. ] -#let hc_rp = load-example("HamiltonianCircuit", "RuralPostman") +#let hc_rp = load-example("HamiltonianCircuit", "DecisionRuralPostman") #let hc_rp_sol = hc_rp.solutions.at(0) #let hc_rp_n = graph-num-vertices(hc_rp.source.instance) -#reduction-rule("HamiltonianCircuit", "RuralPostman", +#reduction-rule("HamiltonianCircuit", "DecisionRuralPostman", example: true, example-caption: [Cycle $C_#hc_rp_n$ ($n = #hc_rp_n$): vertex splitting to Rural Postman], extra: [ @@ -17968,9 +17943,9 @@ The following table shows concrete target-variable counts for example instances, *Step 1 -- Source instance.* The canonical HC instance is a cycle $C_#hc_rp_n$ with $n = #hc_rp_n$ vertices and $|E| = #graph-num-edges(hc_rp.source.instance)$ edges. The stored witness is the permutation $(#fmt-values(hc_rp_sol.source_config))$. - *Step 2 -- Construction.* Each vertex splits into $(v_i^a, v_i^b)$, producing $2n = #graph-num-vertices(hc_rp.target.instance)$ vertices. The target graph has #graph-num-edges(hc_rp.target.instance) edges: #hc_rp.target.instance.required_edges.len() required edges (one per source vertex) and #(graph-num-edges(hc_rp.target.instance) - hc_rp.target.instance.required_edges.len()) connector edges (two per source edge). All edge lengths are 1. + *Step 2 -- Construction.* Each vertex splits into $(v_i^a, v_i^b)$, producing $2n = #graph-num-vertices(hc_rp.target.instance.inner)$ vertices. The target graph has #graph-num-edges(hc_rp.target.instance.inner) edges: #hc_rp.target.instance.inner.required_edges.len() required edges (one per source vertex) and #(graph-num-edges(hc_rp.target.instance.inner) - hc_rp.target.instance.inner.required_edges.len()) connector edges (two per source edge). All edge lengths are 1. - *Step 3 -- Verify a solution.* The target solution assigns edge multiplicities $(#fmt-values(hc_rp_sol.target_config))$. The tour traverses all #hc_rp.target.instance.required_edges.len() required edges plus #hc_rp_n connector edges, for total cost $= #(2 * hc_rp_n) = 2n$ #sym.checkmark. + *Step 3 -- Verify a solution.* The target solution assigns edge multiplicities $(#fmt-values(hc_rp_sol.target_config))$. The tour traverses all #hc_rp.target.instance.inner.required_edges.len() required edges plus #hc_rp_n connector edges, for total cost $= #(2 * hc_rp_n) = 2n$ #sym.checkmark. *Multiplicity:* The fixture stores one canonical witness. The $#hc_rp_n$-cycle has $#hc_rp_n$ rotations $times$ 2 reflections $= #(2 * hc_rp_n)$ directed Hamiltonian circuits. ], @@ -17995,7 +17970,8 @@ The following table shows concrete target-variable counts for example instances, "pred create --example DecisionMaximumIndependentSet/One -o independent-set.json", "pred reduce independent-set.json --via route.json -o bundle.json", "pred solve bundle.json", - "pred extract bundle.json --config " + cli-config(mis_ifb_sol.target_config), + "echo '{\"status\":\"feasible\",\"solution\":" + json.encode(mis_ifb_sol.target_config) + "}' > target-result.json", + "pred extract bundle.json --result target-result.json", ) Source bound: #mis_ifb.source.instance.bound; selected vertices: #fmt-values(mis_ifb_sol.source_config) \ Target: #mis_ifb.target.instance.graph.num_vertices vertices, #mis_ifb.target.instance.graph.arcs.len() arcs, #mis_ifb.target.instance.bundles.len() bundles; requirement #mis_ifb.target.instance.requirement \ @@ -18013,9 +17989,9 @@ The following table shows concrete target-variable counts for example instances, _Solution extraction._ After validating target feasibility, select original vertex $i$ exactly when its outgoing arc has flow 1. The auxiliary path is omitted. Repeated source edges add repeated constraints and do not change the proof. Allocation counts and the shifted threshold are checked before construction; no source solver is invoked during construction or extraction. ] -#let hc_qa = load-example("HamiltonianCircuit", "QuadraticAssignment") +#let hc_qa = load-example("HamiltonianCircuit", "DecisionQuadraticAssignment") #let hc_qa_sol = hc_qa.solutions.at(0) -#reduction-rule("HamiltonianCircuit", "QuadraticAssignment", +#reduction-rule("HamiltonianCircuit", "DecisionQuadraticAssignment", example: true, example-caption: [Cycle graph $C_#hc_qa.source.instance.graph.num_vertices$ ($n = #hc_qa.source.instance.graph.num_vertices$, $|E| = #hc_qa.source.instance.graph.edges.len()$)], extra: [ @@ -18028,7 +18004,7 @@ The following table shows concrete target-variable counts for example instances, *Step 1 -- Source instance.* The graph $G$ has $n = #hc_qa.source.instance.graph.num_vertices$ vertices and edges ${#hc_qa.source.instance.graph.edges.map(e => "(" + str(e.at(0)) + "," + str(e.at(1)) + ")").join(", ")}$, forming a cycle $C_#hc_qa.source.instance.graph.num_vertices$. - *Step 2 -- Construction.* The cost matrix $C$ encodes a directed cycle on positions: $c[i][(i+1) mod #hc_qa.source.instance.graph.num_vertices] = 1$, all other entries 0. The distance matrix $D$ encodes graph adjacency: $d[k][l] = 0$ if ${k,l} in E$, $d[k][l] = 1$ for distinct non-edges, $d[k][k] = 0$. Both matrices are $#hc_qa.source.instance.graph.num_vertices times #hc_qa.source.instance.graph.num_vertices$, so the QAP has $n = #hc_qa.target.instance.cost_matrix.len()$ facilities and $n = #hc_qa.target.instance.distance_matrix.len()$ locations. + *Step 2 -- Construction.* The cost matrix $C$ encodes a directed cycle on positions: $c[i][(i+1) mod #hc_qa.source.instance.graph.num_vertices] = 1$, all other entries 0. The distance matrix $D$ encodes graph adjacency: $d[k][l] = 0$ if ${k,l} in E$, $d[k][l] = 1$ for distinct non-edges, $d[k][k] = 0$. Both matrices are $#hc_qa.source.instance.graph.num_vertices times #hc_qa.source.instance.graph.num_vertices$, so the QAP has $n = #hc_qa.target.instance.inner.cost_matrix.len()$ facilities and $n = #hc_qa.target.instance.inner.distance_matrix.len()$ locations. *Step 3 -- Verify a solution.* The canonical Hamiltonian circuit visits vertices in order $gamma = (#fmt-values(hc_qa_sol.source_config))$. The QAP permutation is the same: $(#fmt-values(hc_qa_sol.target_config))$. The QAP cost is $sum_(i=0)^(n-1) c[i][(i+1) mod n] dot d[gamma(i)][gamma((i+1) mod n)]$. Since $gamma$ maps each position $i$ to vertex $i$, each consecutive pair $(gamma(i), gamma(i+1 mod n))$ is an edge in $G$, contributing $1 dot 0 = 0$. Total cost $= 0$ #sym.checkmark @@ -18491,9 +18467,9 @@ The following table shows concrete target-variable counts for example instances, ] // 5. PartitionIntoCliques → MinimumCoveringByCliques (#889) -#let pic_mcbc = load-example("PartitionIntoCliques", "MinimumCoveringByCliques") +#let pic_mcbc = load-example("PartitionIntoCliques", "DecisionMinimumCoveringByCliques") #let pic_mcbc_sol = pic_mcbc.solutions.at(0) -#reduction-rule("PartitionIntoCliques", "MinimumCoveringByCliques", +#reduction-rule("PartitionIntoCliques", "DecisionMinimumCoveringByCliques", example: true, example-caption: [$n = #graph-num-vertices(pic_mcbc.source.instance)$ vertices, $m = #graph-num-edges(pic_mcbc.source.instance)$ edges, $K = #pic_mcbc.source.instance.num_cliques$], extra: [ @@ -18506,7 +18482,7 @@ The following table shows concrete target-variable counts for example instances, *Step 1 -- Source instance.* Graph $G$ with $n = #graph-num-vertices(pic_mcbc.source.instance)$ vertices, $m = #graph-num-edges(pic_mcbc.source.instance)$ edge, and clique bound $K = #pic_mcbc.source.instance.num_cliques$. The stored partition witness is $(#fmt-values(pic_mcbc_sol.source_config))$, namely the cliques ${0,1}$ and ${2}$. - *Step 2 -- Orlin construction.* The target graph has $#graph-num-vertices(pic_mcbc.target.instance)$ vertices and $#graph-num-edges(pic_mcbc.target.instance)$ edges. Because the source has two directed edge copies, the construction adds the gadgets $Q_(0,1)$ and $Q_(1,0)$, plus the side cliques $L^*$ and $R^*$. The threshold is $K' = K + 2m + 2 = #(pic_mcbc.source.instance.num_cliques + 2 * graph-num-edges(pic_mcbc.source.instance) + 2)$. + *Step 2 -- Orlin construction.* The target graph has $#graph-num-vertices(pic_mcbc.target.instance.inner)$ vertices and $#graph-num-edges(pic_mcbc.target.instance.inner)$ edges. Because the source has two directed edge copies, the construction adds the gadgets $Q_(0,1)$ and $Q_(1,0)$, plus the side cliques $L^*$ and $R^*$. The threshold is $K' = K + 2m + 2 = #(pic_mcbc.source.instance.num_cliques + 2 * graph-num-edges(pic_mcbc.source.instance) + 2)$. *Step 3 -- Verify the witness.* The target witness labels $#pic_mcbc_sol.target_config.len()$ target edges with 6 clique IDs, corresponding to $D_1 = {x_0, x_1, y_0, y_1}$, $D_2 = {x_2, y_2}$, $Q_(0,1)$, $Q_(1,0)$, $L^*$, and $R^*$. Reading only the labels on the matching edges $x_i y_i$ recovers the source partition $(#fmt-values(pic_mcbc_sol.source_config))$ #sym.checkmark. @@ -18913,9 +18889,9 @@ The following table shows concrete target-variable counts for example instances, ] // 12. Partition → SequencingToMinimizeTardyTaskWeight (#471) -#let part_stw = load-example("Partition", "SequencingToMinimizeTardyTaskWeight") +#let part_stw = load-example("Partition", "DecisionSequencingToMinimizeTardyTaskWeight") #let part_stw_sol = part_stw.solutions.at(0) -#reduction-rule("Partition", "SequencingToMinimizeTardyTaskWeight", +#reduction-rule("Partition", "DecisionSequencingToMinimizeTardyTaskWeight", example: true, example-caption: [#part_stw.source.instance.sizes.len() elements, total $= #part_stw.source.instance.sizes.sum()$], extra: [ @@ -18927,9 +18903,9 @@ The following table shows concrete target-variable counts for example instances, ) #{ - let lengths = part_stw.target.instance.lengths - let weights = part_stw.target.instance.weights - let deadline = part_stw.target.instance.deadlines.at(0) + let lengths = part_stw.target.instance.inner.lengths + let weights = part_stw.target.instance.inner.weights + let deadline = part_stw.target.instance.inner.deadlines.at(0) let on-time-sum = part_stw_sol.source_config.enumerate().filter(((i, x)) => not x).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum() let tardy-sum = part_stw_sol.source_config.enumerate().filter(((i, x)) => x).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum() [ @@ -18970,11 +18946,11 @@ The following table shows concrete target-variable counts for example instances, ] // 12. Partition → OpenShopScheduling (#481) -#let part_oss = load-example("Partition", "OpenShopScheduling") +#let part_oss = load-example("Partition", "DecisionOpenShopScheduling") #let part_oss_sol = part_oss.solutions.at(0) -#reduction-rule("Partition", "OpenShopScheduling", +#reduction-rule("Partition", "DecisionOpenShopScheduling", example: true, - example-caption: [#part_oss.source.instance.sizes.len() elements, $m = #part_oss.target.instance.num_machines$ machines], + example-caption: [#part_oss.source.instance.sizes.len() elements, $m = #part_oss.target.instance.inner.num_machines$ machines], extra: [ #pred-commands( "pred create --example " + problem-spec(part_oss.source) + " -o partition.json", @@ -18985,7 +18961,7 @@ The following table shows concrete target-variable counts for example instances, #{ let q = part_oss.source.instance.sizes.sum() / 2 - let p = part_oss.target.instance.processing_times + let p = part_oss.target.instance.inner.processing_times let left-sum = part_oss_sol.source_config.enumerate().filter(((i, x)) => not x).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum() let right-sum = part_oss_sol.source_config.enumerate().filter(((i, x)) => x).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum() [ @@ -19024,9 +19000,9 @@ The following table shows concrete target-variable counts for example instances, _Aggregation and extraction._ Map a finite optimum equal to $D$ to true and all other values to false. Validate a target configuration once, apply this same certificate, then identify the middle machine and select its element jobs completing by $Q$. Reject invalid schedules and feasible schedules that do not attain the certificate. The existing checked target constructor validates its total horizon $3(S+Q)$ before computing $D$, so the smaller nonnegative certificate is representable. Target construction failures retain their formal error type. ] // 13. NAESatisfiability → MaxCut (#166) -#let nae_mc = load-example("NAESatisfiability", "MaxCut") +#let nae_mc = load-example("NAESatisfiability", "DecisionMaxCut") #let nae_mc_sol = nae_mc.solutions.at(0) -#reduction-rule("NAESatisfiability", "MaxCut", +#reduction-rule("NAESatisfiability", "DecisionMaxCut", example: true, example-caption: [$n = #nae_mc.source.instance.num_vars$ variables, $m = #sat-num-clauses(nae_mc.source.instance)$ clauses, $M = #(sat-num-clauses(nae_mc.source.instance) + 1)$], extra: [ @@ -19041,12 +19017,12 @@ The following table shows concrete target-variable counts for example instances, let n = nae_mc.source.instance.num_vars let m = sat-num-clauses(nae_mc.source.instance) let big-m = m + 1 - let clause-edge-count = graph-num-edges(nae_mc.target.instance) - n + let clause-edge-count = graph-num-edges(nae_mc.target.instance.inner) - n let cut-value = n * big-m + 2 * m [ *Step 1 -- Source instance.* NAE-SAT with $n = #n$ variables and $m = #m$ clauses. The implementation uses forcing weight $M = m + 1 = #big-m$. - *Step 2 -- Construct the weighted graph.* Variable gadgets contribute #n heavy edges of weight $M$. Because the canonical fixture has 3 literals per clause, each clause contributes one unit-weight triangle, so the target has #clause-edge-count unit-weight clause edges and $#graph-num-edges(nae_mc.target.instance)$ edges total on $#graph-num-vertices(nae_mc.target.instance)$ vertices. + *Step 2 -- Construct the weighted graph.* Variable gadgets contribute #n heavy edges of weight $M$. Because the canonical fixture has 3 literals per clause, each clause contributes one unit-weight triangle, so the target has #clause-edge-count unit-weight clause edges and $#graph-num-edges(nae_mc.target.instance.inner)$ edges total on $#graph-num-vertices(nae_mc.target.instance.inner)$ vertices. *Step 3 -- Verify the canonical witness.* Source assignment $(#fmt-values(nae_mc_sol.source_config))$ induces target cut $(#fmt-values(nae_mc_sol.target_config))$. All #n heavy edges are cut, and each of the #m clause triangles has a 1-2 split contributing 2, so the total cut weight is $#cut-value$ #sym.checkmark. ] @@ -19516,9 +19492,9 @@ The following table shows concrete target-variable counts for example instances, ] // 17. HamiltonianPathBetweenTwoVertices → LongestPath (#359) -#let hpbtv_lp = load-example("HamiltonianPathBetweenTwoVertices", "LongestPath") +#let hpbtv_lp = load-example("HamiltonianPathBetweenTwoVertices", "DecisionLongestPath") #let hpbtv_lp_sol = hpbtv_lp.solutions.at(0) -#reduction-rule("HamiltonianPathBetweenTwoVertices", "LongestPath", +#reduction-rule("HamiltonianPathBetweenTwoVertices", "DecisionLongestPath", example: true, example-caption: [$n = #graph-num-vertices(hpbtv_lp.source.instance)$ vertices, $s = #hpbtv_lp.source.instance.source_vertex$, $t = #hpbtv_lp.source.instance.target_vertex$], extra: [ diff --git a/docs/src/cli-commands.md b/docs/src/cli-commands.md index 97eb52217..651dbe8eb 100644 --- a/docs/src/cli-commands.md +++ b/docs/src/cli-commands.md @@ -93,10 +93,25 @@ For a problem file, JSON inspection includes `parameter_values`, the model's act pred path MIS QUBO --json -o paths.json python3 -c 'import json; print(json.dumps(json.load(open("paths.json"))["paths"][0]))' > path.json pred reduce problem.json --via path.json -o reduced.json -pred extract reduced.json --config '[1,0,1,0]' +pred extract reduced.json --result target-result.json -o source-result.json ``` -The bundle contains the source instance, the target instance, and the variant-level path; keep it whole to preserve solution recovery. `--via` replays one route extracted from the `paths` envelope, whose source variant must match the input. `extract` maps a target-space configuration back to the source. +The bundle contains the source instance, the target instance, and the variant-level path; keep it whole to preserve solution recovery. `--via` replays one route extracted from the `paths` envelope, whose source variant must match the input. + +`extract` accepts the target problem's `pred solve` JSON output directly, or an +external result with an explicit `status`: + +| Result | JSON | +| --- | --- | +| Feasible solution, not necessarily optimal | `{"status":"feasible","solution":[true,false]}` | +| Optimal solution | `{"status":"optimal","solution":[true,false]}` | +| No solution | `{"status":"infeasible"}` | +| Complete count or determined objective value | `{"status":"complete","value":12}` | + +An optional `evaluation` must match the supplied solution. Value-only results +require value mappings along the entire route and return no witness. Conflicting +fields or unsupported mappings are errors. Extraction runs no solver and does +not establish optimality or infeasibility. ## Solve diff --git a/docs/src/design.md b/docs/src/design.md index 49f67c464..b1ebf1215 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -338,7 +338,10 @@ its witness representation remain model constraints. ### Result mappings -Rules follow mathematical contracts, without mandatory category tags: +Rules follow mathematical contracts, without mandatory category tags. +When a target asks whether an objective meets a bound, construct `Decision

`: +the target owns the bound and evaluates the predicate; the rule only decodes +YES witnesses and maps completed `Or` answers identically. | Reduction | Completed-result workflow | Example | | --- | --- | --- | diff --git a/examples/chained_reduction_factoring_to_spinglass.rs b/examples/chained_reduction_factoring_to_spinglass.rs index 48067cd3a..c07f0faa8 100644 --- a/examples/chained_reduction_factoring_to_spinglass.rs +++ b/examples/chained_reduction_factoring_to_spinglass.rs @@ -27,7 +27,9 @@ pub fn run() -> std::result::Result<(), Box> { ); let rpath = paths .iter() - .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .find(|path| { + path.type_names() == ["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"] + }) .expect("explicit Factoring -> CircuitSAT -> SpinGlass route"); println!(" {}", rpath); // ANCHOR_END: step1 diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index a51e2dcf3..1f44f9c5f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -209,29 +209,20 @@ Examples: Inspect(InspectArgs), /// Solve a problem instance Solve(SolveArgs), - /// Recover a source solution, completed result, or aggregate from a reduction bundle + /// Recover a source result from a target result JSON file #[command(after_help = "\ Examples: - pred extract bundle.json --config '[1,0,1,0]' - pred extract bundle.json --config '[1,0,1,0]' -o source.json - pred extract bundle.json --result optimum.json - pred extract bundle.json --value '2' - cat bundle.json | pred extract - --config '[1,0,1,0]' - -Use this when an external solver has solved the bundle's target problem -(e.g. a QUBO sampler, a neutral-atom platform, a QAOA runtime) and you want -the corresponding solution in the original source problem space without -having to shell back into `pred solve`. - ---config recovers a candidate only. --result requires a completed result: - {\"status\":\"optimal\",\"solution\":[true,false],\"evaluation\":\"Min(1)\"} - {\"status\":\"infeasible\"} -Evaluation is optional, but must match when supplied. The external solver must -establish optimality or infeasibility under its own numerical contract. ---value maps an exact aggregate through value-capable edges, without a witness. - -Input: a reduction bundle JSON (from `pred reduce`). Use - to read from stdin. ---config is the target problem's solution encoded as JSON (e.g. '[1,0,1,0]').")] + pred extract bundle.json --result target-result.json + pred extract bundle.json --result target-result.json -o source-result.json + +Result JSON (status is required): + {\"status\":\"feasible\",\"solution\":[true,false]} feasible, not necessarily optimal + {\"status\":\"optimal\",\"solution\":[true,false]} solver-reported optimum + {\"status\":\"infeasible\"} no solution + {\"status\":\"complete\",\"value\":12} full count or determined objective + +Accepts pred solve JSON output directly. An optional evaluation must match the +solution. Extraction does not establish optimality or infeasibility.")] Extract(ExtractArgs), /// Start MCP (Model Context Protocol) server for AI assistant integration #[cfg(feature = "mcp")] @@ -340,26 +331,18 @@ pub struct ReduceArgs { /// Explicit reduction route selected from a path-set entry. #[arg(long, required = true)] pub via: PathBuf, - /// Execute value mappings; recover the external aggregate with `pred extract --value`. + /// Execute value mappings; supply a complete value result to `pred extract`. #[arg(long)] pub aggregate: bool, } #[derive(clap::Args)] -#[group(skip)] -#[command(group(clap::ArgGroup::new("recovery_input").required(true).args(["config", "result", "value"])))] pub struct ExtractArgs { - /// Reduction bundle JSON (from `pred reduce`). Use - for stdin. + /// Reduction bundle JSON (from pred reduce). Use - for stdin. pub input: PathBuf, - /// Target problem solution encoded as JSON (for example, [1,0,1,0]) - #[arg(long)] - pub config: Option, - /// JSON file containing a completed target result (optimal or infeasible). - #[arg(long)] - pub result: Option, - /// Exact target aggregate encoded as JSON; uses only value mappings. + /// Target result JSON file with an explicit status. Use - for stdin. #[arg(long)] - pub value: Option, + pub result: PathBuf, } #[derive(clap::Args)] @@ -580,4 +563,25 @@ mod tests { assert_eq!(create.get_subcommands().count(), 0); assert!(create.is_allow_external_subcommands_set()); } + + #[test] + fn extract_requires_a_result_file() { + assert!( + Cli::try_parse_from(["pred", "extract", "bundle.json", "--result", "result.json"]) + .is_ok() + ); + assert!(Cli::try_parse_from(["pred", "extract", "bundle.json"]).is_err()); + for flag in ["--config", "--value"] { + assert!(Cli::try_parse_from([ + "pred", + "extract", + "bundle.json", + "--result", + "result.json", + flag, + "2" + ]) + .is_err()); + } + } } diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index a11f3549d..8fac8a558 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -1,22 +1,22 @@ use crate::cli::ExtractArgs; -use crate::dispatch::{read_input, BundleReplay, ReductionBundle}; +use crate::dispatch::{extract_bundle_value, read_input, BundleReplay, ReductionBundle}; use crate::output::OutputConfig; use anyhow::{Context, Result}; -use problemreductions::rules::ReductionMode; use problemreductions::solvers::SolveOutcome; +use serde_json::Value; +use std::path::Path; #[derive(serde::Deserialize)] -#[serde(tag = "status", rename_all = "snake_case")] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] enum ExternalResult { - Optimal { solution: serde_json::Value }, - Infeasible, + Feasible { solution: Value }, + Optimal { solution: Value }, + Infeasible {}, + Complete { value: Value }, } -/// Recover a candidate, completed result, or aggregate through a bundle. -/// `--result` accepts solve-output metadata, but validates any supplied evaluation. -/// Optimality and infeasibility follow the external solver's numerical contract. -pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { - let content = read_input(&args.input)?; +fn load_bundle(input: &Path) -> Result { + let content = read_input(input)?; let json: serde_json::Value = serde_json::from_str(&content).context("Input is not valid JSON")?; @@ -24,7 +24,7 @@ pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { { anyhow::bail!( "Input is not a reduction bundle.\n\ - `pred extract` requires a bundle produced by `pred reduce`.\n\ + Extraction requires a bundle produced by `pred reduce`.\n\ Got a plain problem file; did you mean `pred evaluate`?" ); } @@ -32,88 +32,105 @@ pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { let bundle: ReductionBundle = serde_json::from_value(json).context("Failed to parse reduction bundle")?; - if let Some(value) = &args.value { - let replay = BundleReplay::prepare(&bundle, ReductionMode::Aggregate)?; - let value = replay.extract_value( - serde_json::from_str(value).context("Target aggregate is not valid JSON")?, - )?; - return out.emit( - || format!("Problem: {}\nAggregate: {value}", replay.source_name), - || Ok(serde_json::json!({"problem": replay.source_name, "aggregate": value})), - ); + Ok(bundle) +} + +/// Recover the explicitly stated target result, without upgrading its guarantee. +pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { + let bundle = load_bundle(&args.input)?; + let mut json: serde_json::Map = + serde_json::from_str(&read_input(&args.result)?).context("Invalid target result")?; + let evaluation = json.remove("evaluation"); + // These fields describe pred output; they do not change the recovery contract. + for metadata in ["problem", "solver", "reduced_to", "intermediate"] { + json.remove(metadata); } - let replay = BundleReplay::prepare(&bundle, ReductionMode::Witness)?; - if let Some(path) = &args.result { - let json: serde_json::Value = - serde_json::from_str(&read_input(path)?).context("Invalid completed target result")?; - let evaluation = json.get("evaluation").cloned(); - let external: ExternalResult = - serde_json::from_value(json).context("Invalid completed target result")?; - let target = match external { - ExternalResult::Optimal { solution } => { - let actual = replay.target.evaluate_dyn(&solution)?; - if evaluation - .as_ref() - .is_some_and(|value| value != &serde_json::json!(actual)) - { - anyhow::bail!("target evaluation does not match the witness") - } - SolveOutcome::Optimal { - solution, - evaluation: actual, - } + let external: ExternalResult = + serde_json::from_value(Value::Object(json)).context("Invalid target result")?; + match external { + ExternalResult::Complete { value } => { + if evaluation.is_some() { + anyhow::bail!("complete value results do not have a witness evaluation"); } - ExternalResult::Infeasible => { - if evaluation.is_some() { - anyhow::bail!("infeasible results do not have a witness evaluation") - } - SolveOutcome::Infeasible + let source_value = extract_bundle_value(&bundle, value.clone())?; + out.emit( + || { + format!( + "Problem: {}\nStatus: complete\nValue: {source_value}", + bundle.source.problem_type + ) + }, + || { + Ok(serde_json::json!({ + "problem": bundle.source.problem_type, + "status": "complete", + "value": source_value, + "intermediate": {"status": "complete", "value": value}, + })) + }, + ) + } + ExternalResult::Infeasible {} => { + if evaluation.is_some() { + anyhow::bail!("infeasible results do not have a witness evaluation"); } - }; - let source = replay.extract_result(&target)?; - return out.emit( - || format!("Problem: {}\nResult: {source:?}", replay.source_name), - || { - let mut json = serde_json::to_value(&source)?; - json["problem"] = serde_json::json!(replay.source_name); - json["intermediate"] = serde_json::to_value(&target)?; - Ok(json) - }, - ); + let replay = BundleReplay::prepare(&bundle)?; + emit_completed(&replay, SolveOutcome::Infeasible, out) + } + ExternalResult::Feasible { ref solution } | ExternalResult::Optimal { ref solution } => { + let replay = BundleReplay::prepare(&bundle)?; + let actual = replay.target.evaluate_dyn(solution)?; + if evaluation + .as_ref() + .is_some_and(|value| value != &serde_json::json!(actual)) + { + anyhow::bail!("target evaluation does not match the witness"); + } + if matches!(external, ExternalResult::Optimal { .. }) { + return emit_completed( + &replay, + SolveOutcome::Optimal { + solution: solution.clone(), + evaluation: actual, + }, + out, + ); + } + let (source_solution, source_evaluation) = replay.extract(solution)?; + out.emit( + || format!("Problem: {}\nStatus: feasible\nSolution: {source_solution}\nEvaluation: {source_evaluation}", replay.source_name), + || Ok(serde_json::json!({ + "problem": replay.source_name, + "solver": "external", + "reduced_to": replay.target_name, + "status": "feasible", + "solution": source_solution, + "evaluation": source_evaluation, + "intermediate": { + "problem": replay.target_name, + "status": "feasible", + "solution": solution, + "evaluation": actual, + }, + })), + ) + } } - let config_str = args - .config - .as_deref() - .context("provide --config, --result, or --value")?; - let target_config: serde_json::Value = - serde_json::from_str(config_str).context("Target config is not valid JSON")?; - - let target_eval = replay.target.evaluate_dyn(&target_config)?; - - let (source_config, source_eval) = replay.extract(&target_config)?; +} +fn emit_completed(replay: &BundleReplay, target: SolveOutcome, out: &OutputConfig) -> Result<()> { + let source = replay.extract_result(&target)?; out.emit( || { - format!( - "Problem: {}\nSolver: external (via {})\nSolution: {:?}\nEvaluation: {}", - replay.source_name, replay.target_name, source_config, source_eval, - ) + let mut text = format!("Problem: {}", replay.source_name); + super::solve::append_outcome_text(&mut text, &source); + text }, || { - // Schema aligned with `pred solve` on a bundle. `solver` is "external" - // because pred did not run the solver that produced the target config. - Ok(serde_json::json!({ - "problem": replay.source_name, - "solver": "external", - "reduced_to": replay.target_name, - "solution": source_config, - "evaluation": source_eval, - "intermediate": { - "problem": replay.target_name, - "solution": target_config, - "evaluation": target_eval, - }, - })) + let mut json = serde_json::to_value(&source)?; + json["problem"] = serde_json::json!(replay.source_name); + json["intermediate"] = serde_json::to_value(&target)?; + Ok(json) }, ) } diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs index 853a9615c..6f9bb2c04 100644 --- a/problemreductions-cli/src/commands/reduce.rs +++ b/problemreductions-cli/src/commands/reduce.rs @@ -4,7 +4,7 @@ use crate::dispatch::{ }; use crate::output::OutputConfig; use anyhow::{Context, Result}; -use problemreductions::rules::{ReductionGraph, ReductionMode, ReductionPath, ReductionStep}; +use problemreductions::rules::{ReductionGraph, ReductionPath, ReductionStep}; use std::collections::BTreeMap; use std::path::Path; @@ -61,11 +61,10 @@ pub(crate) fn parse_path_json(content: &str) -> Result { Ok(ReductionPath { steps }) } -pub(crate) fn execute_route( - problem_json: ProblemJson, - reduction_path: ReductionPath, - mode: ReductionMode, -) -> Result { +fn load_route_source( + problem_json: &ProblemJson, + reduction_path: &ReductionPath, +) -> Result { let source = load_problem( &problem_json.problem_type, &problem_json.variant, @@ -87,39 +86,19 @@ pub(crate) fn execute_route( ); } - let graph = ReductionGraph::new(); - let target_step = reduction_path - .steps - .last() - .expect("route parser requires at least one edge"); - let target_data = match mode { - ReductionMode::Witness => { - let chain = graph - .reduce_along_path(&reduction_path, source.as_any())? - .ok_or_else(|| { - anyhow::anyhow!("Reduction bundles require witness-capable paths") - })?; - serialize_any_problem( - &target_step.name, - &target_step.variant, - chain.target_problem_any(), - )? - } - ReductionMode::Aggregate => { - let chain = graph - .reduce_aggregate_along_path(&reduction_path, source.as_any())? - .ok_or_else(|| { - anyhow::anyhow!("Reduction bundle requires an aggregate-capable path") - })?; - serialize_any_problem( - &target_step.name, - &target_step.variant, - chain.target_problem_any(), - )? - } - ReductionMode::Turing => anyhow::bail!("Turing reductions are not executable"), - }; + Ok(source) +} +fn make_bundle( + problem_json: ProblemJson, + reduction_path: ReductionPath, + source: &crate::dispatch::LoadedProblem, + target: &dyn std::any::Any, +) -> Result { + let source_name = source.problem_name(); + let source_variant = source.variant_map(); + let target_step = reduction_path.steps.last().expect("route has a target"); + let target_data = serialize_any_problem(&target_step.name, &target_step.variant, target)?; Ok(ReductionBundle { source: ProblemJsonOutput { problem_type: source_name.to_string(), @@ -142,18 +121,49 @@ pub(crate) fn execute_route( }) } +pub(crate) fn execute_route( + problem_json: ProblemJson, + reduction_path: ReductionPath, +) -> Result { + let source = load_route_source(&problem_json, &reduction_path)?; + let chain = ReductionGraph::new() + .reduce_along_path(&reduction_path, source.as_any())? + .context("Reduction bundle requires a witness-capable path")?; + make_bundle( + problem_json, + reduction_path, + &source, + chain.target_problem_any(), + ) +} + +pub(crate) fn execute_aggregate_route( + problem_json: ProblemJson, + reduction_path: ReductionPath, +) -> Result { + let source = load_route_source(&problem_json, &reduction_path)?; + let chain = ReductionGraph::new() + .reduce_aggregate_along_path(&reduction_path, source.as_any())? + .context("Reduction bundle requires an aggregate-capable path")?; + make_bundle( + problem_json, + reduction_path, + &source, + chain.target_problem_any(), + ) +} + pub fn reduce(input: &Path, via: &Path, aggregate: bool, out: &OutputConfig) -> Result<()> { let content = read_input(input)?; let problem_json: ProblemJson = serde_json::from_str(&content)?; let reduction_path = load_path_file(via)?; let route_len = reduction_path.len(); let route_text = reduction_path.to_string(); - let mode = if aggregate { - ReductionMode::Aggregate + let bundle = if aggregate { + execute_aggregate_route(problem_json, reduction_path)? } else { - ReductionMode::Witness + execute_route(problem_json, reduction_path)? }; - let bundle = execute_route(problem_json, reduction_path, mode)?; out.emit( || { diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 625fd66d3..3387c0db2 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -52,7 +52,7 @@ fn solve_result_text(problem: &str, result: &SolveResult) -> String { text } -fn append_outcome_text(text: &mut String, outcome: &SolveOutcome) { +pub(super) fn append_outcome_text(text: &mut String, outcome: &SolveOutcome) { match outcome { SolveOutcome::Optimal { solution, @@ -136,7 +136,7 @@ fn solve_problem( /// Solve a reduction bundle: solve the target problem, then map the solution back. fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputConfig) -> Result<()> { - let replay = BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Witness)?; + let replay = BundleReplay::prepare(&bundle)?; let result = replay.solve(request).map_err(add_solver_hint)?; let emitted = out.emit( diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 422550dd3..ddc74a2bb 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -197,12 +197,7 @@ pub struct BundleReplay { pub(crate) source_name: String, pub(crate) target: LoadedProblem, pub(crate) target_name: String, - chain: BundleChain, -} - -enum BundleChain { - Witness(Vec), - Aggregate(problemreductions::rules::AggregateReductionChain), + steps: Vec, } struct WitnessStep { @@ -210,6 +205,91 @@ struct WitnessStep { source_variant: &'static problemreductions::registry::VariantEntry, } +fn load_bundle_endpoints( + bundle: &ReductionBundle, +) -> Result<( + LoadedProblem, + LoadedProblem, + problemreductions::rules::ReductionPath, +)> { + if bundle.path.len() < 2 { + anyhow::bail!( + "Malformed bundle: `path` must contain at least two steps (source and target), got {}", + bundle.path.len() + ); + } + let first = bundle.path.first().unwrap(); + let last = bundle.path.last().unwrap(); + if first.name != bundle.source.problem_type || first.variant != bundle.source.variant { + anyhow::bail!( + "Malformed bundle: path starts with {} but source is {}", + format_step(&first.name, &first.variant), + format_step(&bundle.source.problem_type, &bundle.source.variant), + ); + } + if last.name != bundle.target.problem_type || last.variant != bundle.target.variant { + anyhow::bail!( + "Malformed bundle: path ends with {} but target is {}", + format_step(&last.name, &last.variant), + format_step(&bundle.target.problem_type, &bundle.target.variant), + ); + } + + let source = load_problem( + &bundle.source.problem_type, + &bundle.source.variant, + bundle.source.data.clone(), + )?; + + let target = load_problem( + &bundle.target.problem_type, + &bundle.target.variant, + bundle.target.data.clone(), + )?; + + let reduction_path = problemreductions::rules::ReductionPath { + steps: bundle + .path + .iter() + .map(|s| problemreductions::rules::ReductionStep { + name: s.name.clone(), + variant: s.variant.clone(), + }) + .collect(), + }; + + Ok((source, target, reduction_path)) +} + +fn validate_replayed_target(bundle: &ReductionBundle, target_any: &dyn Any) -> Result<()> { + let replayed_target_data = serialize_any_problem( + &bundle.target.problem_type, + &bundle.target.variant, + target_any, + )?; + if replayed_target_data != bundle.target.data { + anyhow::bail!( + "Malformed bundle: `target.data` does not match the result of replaying \ + `source` along `path`. The bundle is tampered or was produced by \ + incompatible code." + ); + } + + Ok(()) +} + +pub(crate) fn extract_bundle_value( + bundle: &ReductionBundle, + value: serde_json::Value, +) -> Result { + let (source, _target, path) = load_bundle_endpoints(bundle)?; + let chain = ReductionGraph::new() + .reduce_aggregate_along_path(&path, source.as_any())? + .context("Bundle requires an aggregate-capable reduction path")?; + validate_replayed_target(bundle, chain.target_problem_any())?; + Ok(chain.extract_value(value)?) +} + impl BundleReplay { /// Validate the bundle and replay the reduction chain. /// @@ -222,119 +302,36 @@ impl BundleReplay { /// `reduce_along_path` actually produced are rejected) /// /// Returns an error (not a panic) for malformed bundles or paths without witness extraction. - pub fn prepare( - bundle: &ReductionBundle, - mode: problemreductions::rules::ReductionMode, - ) -> Result { - if bundle.path.len() < 2 { - anyhow::bail!( - "Malformed bundle: `path` must contain at least two steps (source and target), got {}", - bundle.path.len() - ); - } - let first = bundle.path.first().unwrap(); - let last = bundle.path.last().unwrap(); - if first.name != bundle.source.problem_type || first.variant != bundle.source.variant { - anyhow::bail!( - "Malformed bundle: path starts with {} but source is {}", - format_step(&first.name, &first.variant), - format_step(&bundle.source.problem_type, &bundle.source.variant), - ); - } - if last.name != bundle.target.problem_type || last.variant != bundle.target.variant { - anyhow::bail!( - "Malformed bundle: path ends with {} but target is {}", - format_step(&last.name, &last.variant), - format_step(&bundle.target.problem_type, &bundle.target.variant), - ); - } - - let source = load_problem( - &bundle.source.problem_type, - &bundle.source.variant, - bundle.source.data.clone(), - )?; - let source_name = source.problem_name().to_string(); - - let target = load_problem( - &bundle.target.problem_type, - &bundle.target.variant, - bundle.target.data.clone(), - )?; - let target_name = target.problem_name().to_string(); - - let reduction_path = problemreductions::rules::ReductionPath { - steps: bundle - .path - .iter() - .map(|s| problemreductions::rules::ReductionStep { - name: s.name.clone(), - variant: s.variant.clone(), - }) - .collect(), - }; - + pub fn prepare(bundle: &ReductionBundle) -> Result { + let (source, target, reduction_path) = load_bundle_endpoints(bundle)?; let graph = ReductionGraph::new(); - let chain = match mode { - problemreductions::rules::ReductionMode::Witness => { - let mut steps: Vec = Vec::new(); - for edge in reduction_path.steps.windows(2) { - let input = steps - .last() - .map_or(source.as_any(), |step| step.chain.target_problem_any()); - let path = problemreductions::rules::ReductionPath { - steps: edge.to_vec(), - }; - let chain = graph.reduce_along_path(&path, input)?.ok_or_else(|| { - anyhow::anyhow!("Bundle requires a witness-capable reduction path") - })?; - let source_variant = problemreductions::registry::find_variant_entry( - &edge[0].name, - &edge[0].variant, - ) + let mut steps: Vec = Vec::new(); + for edge in reduction_path.steps.windows(2) { + let input = steps + .last() + .map_or(source.as_any(), |step| step.chain.target_problem_any()); + let path = problemreductions::rules::ReductionPath { + steps: edge.to_vec(), + }; + let chain = graph.reduce_along_path(&path, input)?.ok_or_else(|| { + anyhow::anyhow!("Bundle requires a witness-capable reduction path") + })?; + let source_variant = + problemreductions::registry::find_variant_entry(&edge[0].name, &edge[0].variant) .context("missing intermediate problem registration")?; - steps.push(WitnessStep { - chain, - source_variant, - }); - } - BundleChain::Witness(steps) - } - problemreductions::rules::ReductionMode::Aggregate => BundleChain::Aggregate( - graph - .reduce_aggregate_along_path(&reduction_path, source.as_any())? - .ok_or_else(|| { - anyhow::anyhow!("Bundle requires an aggregate-capable reduction path") - })?, - ), - problemreductions::rules::ReductionMode::Turing => { - anyhow::bail!("Turing reductions are not executable") - } - }; - - // Coherence check: `bundle.target.data` must equal what replaying - // `source` along `path` actually produces. Without this, a caller - // could solve/validate against the bundle's stated target but then - // extract through a completely different chain target. - let target_any = match &chain { - BundleChain::Witness(steps) => steps.last().unwrap().chain.target_problem_any(), - BundleChain::Aggregate(chain) => chain.target_problem_any(), - }; - let replayed_target_data = serialize_any_problem(&last.name, &last.variant, target_any)?; - if replayed_target_data != bundle.target.data { - anyhow::bail!( - "Malformed bundle: `target.data` does not match the result of replaying \ - `source` along `path`. The bundle is tampered or was produced by \ - incompatible code." - ); + steps.push(WitnessStep { + chain, + source_variant, + }); } + validate_replayed_target(bundle, steps.last().unwrap().chain.target_problem_any())?; Ok(Self { + source_name: source.problem_name().to_string(), + target_name: target.problem_name().to_string(), source, - source_name, target, - target_name, - chain, + steps, }) } @@ -343,10 +340,8 @@ impl BundleReplay { &self, target_config: &serde_json::Value, ) -> Result<(serde_json::Value, String)> { - let BundleChain::Witness(steps) = &self.chain else { - anyhow::bail!("value-only reductions do not recover witnesses") - }; - let source_config = steps + let source_config = self + .steps .iter() .rev() .try_fold(target_config.clone(), |solution, step| { @@ -361,21 +356,12 @@ impl BundleReplay { Ok((source_config, source_eval)) } - pub fn extract_value(&self, value: serde_json::Value) -> Result { - let BundleChain::Aggregate(chain) = &self.chain else { - anyhow::bail!("value recovery requires an aggregate-capable path") - }; - Ok(chain.extract_value(value)?) - } - /// Execute recovery of a completed result. The caller establishes optimality /// or infeasibility under its solver contract, including numerical tolerances; /// evaluating a candidate cannot establish it. pub(crate) fn extract_result(&self, result: &SolveOutcome) -> Result { use problemreductions::rules::ExtractionError; - let BundleChain::Witness(steps) = &self.chain else { - anyhow::bail!("value-only reductions require an aggregate value") - }; + let steps = &self.steps; let (mut witness, mut value) = match result { SolveOutcome::Optimal { solution, @@ -561,7 +547,7 @@ mod tests { source: &P, targets: Vec, ) -> BundleReplay { - use problemreductions::rules::{ReductionMode, ReductionPath}; + use problemreductions::rules::ReductionPath; let mut steps = vec![problem_step::

()]; steps.extend(targets); let bundle = crate::commands::reduce::execute_route( @@ -571,10 +557,9 @@ mod tests { data: serde_json::to_value(source).unwrap(), }, ReductionPath { steps }, - ReductionMode::Witness, ) .unwrap(); - BundleReplay::prepare(&bundle, ReductionMode::Witness).unwrap() + BundleReplay::prepare(&bundle).unwrap() } #[test] @@ -651,6 +636,9 @@ mod tests { &source, vec![ problem_step::(), + problem_step::< + problemreductions::models::decision::Decision>, + >(), problem_step::>(), ], ); @@ -722,6 +710,11 @@ mod tests { &source, vec![ problem_step::>(), + problem_step::< + problemreductions::models::decision::Decision< + MinimumVertexCover, + >, + >(), problem_step::>(), ], ); @@ -751,7 +744,7 @@ mod tests { #[test] fn aggregate_only_bundle_executes_and_recovers_without_witnesses() { - use problemreductions::rules::{ReductionMode, ReductionPath, ReductionStep}; + use problemreductions::rules::{ReductionPath, ReductionStep}; let bundle = crate::test_support::aggregate_bundle(); let path = ReductionPath { steps: bundle @@ -768,18 +761,14 @@ mod tests { variant: bundle.source.variant.clone(), data: bundle.source.data.clone(), }; - let executed = - crate::commands::reduce::execute_route(source, path, ReductionMode::Aggregate).unwrap(); + let executed = crate::commands::reduce::execute_aggregate_route(source, path).unwrap(); assert_eq!(executed.target.data, serde_json::json!({"base":14})); - let replay = BundleReplay::prepare(&executed, ReductionMode::Aggregate).unwrap(); assert_eq!( - replay.extract_value(serde_json::json!(12)).unwrap(), + extract_bundle_value(&executed, serde_json::json!(12)).unwrap(), serde_json::json!(12) ); - assert!(replay.extract_value(serde_json::json!(true)).is_err()); - assert!(replay.extract(&serde_json::json!([true])).is_err()); - assert!(replay.extract_result(&SolveOutcome::Infeasible).is_err()); - assert!(BundleReplay::prepare(&executed, ReductionMode::Turing).is_err()); + assert!(extract_bundle_value(&executed, serde_json::json!(true)).is_err()); + assert!(BundleReplay::prepare(&executed).is_err()); } #[test] @@ -805,27 +794,15 @@ mod tests { let route = crate::commands::reduce::parse_path_json( r#"{"path":[{ "from":{"name":"KSatisfiability","variant":{"k":"K3"}}, + "to":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}} + },{ + "from":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}}, "to":{"name":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}} }]}"#, ).unwrap(); - let bundle = crate::commands::reduce::execute_route( - source, - route, - problemreductions::rules::ReductionMode::Witness, - ) - .unwrap(); - let replay = - BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Witness) - .unwrap(); - assert!(replay.extract_value(serde_json::json!(1)).is_err()); - let aggregate = - BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Aggregate) - .unwrap(); - assert!(BundleReplay::prepare( - &bundle, - problemreductions::rules::ReductionMode::Turing - ) - .is_err()); + let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); + let replay = BundleReplay::prepare(&bundle).unwrap(); + { let source = ProblemJson { problem_type: bundle.source.problem_type.clone(), @@ -842,20 +819,14 @@ mod tests { }) .collect(), }; - assert!(crate::commands::reduce::execute_route( - source, - route, - problemreductions::rules::ReductionMode::Aggregate - ) - .is_ok()); + assert!(crate::commands::reduce::execute_aggregate_route(source, route).is_ok()); } let result = replay.solve(SolverRequest::BruteForce).unwrap(); let SolveOutcome::Optimal { solution, .. } = &result.target_outcome else { panic!("vertex cover always has a feasible target solution") }; assert_eq!( - aggregate - .extract_value(replay.target.evaluate_json(solution).unwrap()) + extract_bundle_value(&bundle, replay.target.evaluate_json(solution).unwrap()) .unwrap(), serde_json::json!(feasible) ); @@ -891,26 +862,17 @@ mod tests { }]}"#, ) .unwrap(); - let bundle = crate::commands::reduce::execute_route( - source, - route, - problemreductions::rules::ReductionMode::Witness, - ) - .unwrap(); + let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); let encoded = serde_json::to_vec(&bundle).unwrap(); let mut restored: ReductionBundle = serde_json::from_slice(&encoded).unwrap(); assert_eq!(restored.source.data, bundle.source.data); assert_eq!(restored.target.data, bundle.target.data); - BundleReplay::prepare(&restored, problemreductions::rules::ReductionMode::Witness) - .expect("an unchanged JSON bundle must replay exactly"); + BundleReplay::prepare(&restored).expect("an unchanged JSON bundle must replay exactly"); // A one-ULP change remains tampering; replay must not use a float tolerance. let coefficient = restored.target.data["objective"][0][1].as_f64().unwrap(); restored.target.data["objective"][0][1] = json!(f64::from_bits(coefficient.to_bits() + 1)); - let error = - BundleReplay::prepare(&restored, problemreductions::rules::ReductionMode::Witness) - .err() - .unwrap(); + let error = BundleReplay::prepare(&restored).err().unwrap(); assert!(error .to_string() .contains("does not match the result of replaying")); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index b61b97b5b..0c3da5a08 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -426,11 +426,7 @@ impl McpServer { pub fn reduce_inner(&self, problem_json: &str, path_json: &str) -> anyhow::Result { let pj: ProblemJson = serde_json::from_str(problem_json)?; let reduction_path = crate::commands::reduce::parse_path_json(path_json)?; - let bundle = crate::commands::reduce::execute_route( - pj, - reduction_path, - problemreductions::rules::ReductionMode::Witness, - )?; + let bundle = crate::commands::reduce::execute_route(pj, reduction_path)?; Ok(serde_json::to_string_pretty(&bundle)?) } @@ -701,7 +697,7 @@ fn solve_problem_inner( /// Solve a reduction bundle: solve the target, then map the solution back. fn solve_bundle_inner(bundle: ReductionBundle, request: SolverRequest) -> anyhow::Result { - let replay = BundleReplay::prepare(&bundle, problemreductions::rules::ReductionMode::Witness)?; + let replay = BundleReplay::prepare(&bundle)?; Ok(serde_json::to_string_pretty( &replay.solve(request)?.to_json(), )?) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7eac5e1ad..5c7bf7fc7 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -25,6 +25,34 @@ fn pred() -> Command { Command::new(env!("CARGO_BIN_EXE_pred")) } +fn extract_target_result( + bundle: &std::path::Path, + result: serde_json::Value, +) -> std::process::Output { + use std::io::Write; + use std::process::Stdio; + let mut child = pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--result", + "-", + "--json", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(result.to_string().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + fn write_named_route(source: &str, target: &str, names: &[&str], output: &std::path::Path) { let command = pred() .args(["path", source, target, "--limit", "all", "--json"]) @@ -137,7 +165,7 @@ fn test_list_json_respects_category_filter() { assert!(output.status.success()); let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); let variants = json["variants"].as_array().unwrap(); - assert_eq!(json["num_types"], 9); + assert_eq!(json["num_types"], 10); assert!(variants .iter() .all(|variant| variant["name"] != "MaximumIndependentSet")); @@ -9652,6 +9680,44 @@ fn test_completed_decision_recovery_and_aggregate_cli() { let expected = if bound == 1 { "infeasible" } else { "optimal" }; assert_eq!(solved["status"], expected); + let target_file = dir.join("target.json"); + let bundle_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&bundle).unwrap()).unwrap(); + std::fs::write(&target_file, bundle_json["target"].to_string()).unwrap(); + let target_solve = pred() + .args([ + "solve", + target_file.to_str().unwrap(), + "--solver", + "brute-force", + "-o", + result.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + target_solve.status.success(), + "{}", + String::from_utf8_lossy(&target_solve.stderr) + ); + let recovered = pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--result", + result.to_str().unwrap(), + "--json", + ]) + .output() + .unwrap(); + assert!( + recovered.status.success(), + "{}", + String::from_utf8_lossy(&recovered.stderr) + ); + let recovered: serde_json::Value = serde_json::from_slice(&recovered.stdout).unwrap(); + assert_eq!(recovered["status"], expected); + let numerical = pred() .args([ "solve", @@ -9699,36 +9765,42 @@ fn test_completed_decision_recovery_and_aggregate_cli() { let recovered: serde_json::Value = serde_json::from_slice(&extracted.stdout).unwrap(); assert_eq!(recovered["status"], expected); } - let value = pred() - .args([ - "extract", - bundle.to_str().unwrap(), - "--value", - "2", - "--json", - ]) - .output() - .unwrap(); + let value = + extract_target_result(&bundle, serde_json::json!({"status":"complete","value":2})); assert!( value.status.success(), "{}", String::from_utf8_lossy(&value.stderr) ); let value: serde_json::Value = serde_json::from_slice(&value.stdout).unwrap(); - assert_eq!(value["aggregate"], json!(bound == 2)); - let candidate = pred() - .args([ - "extract", - bundle.to_str().unwrap(), - "--config", - "[true,true,false]", - "--json", - ]) - .output() - .unwrap(); + assert_eq!(value["status"], "complete"); + assert_eq!(value["value"], json!(bound == 2)); + let candidate = extract_target_result( + &bundle, + serde_json::json!({"status":"feasible","solution":[true,true,false],"evaluation":"Min(2)"}), + ); assert_eq!(candidate.status.success(), bound == 2); + if bound == 2 { + let recovered: serde_json::Value = serde_json::from_slice(&candidate.stdout).unwrap(); + assert_eq!(recovered["status"], "feasible"); + assert_eq!(recovered["evaluation"], "Or(true)"); + } } for invalid in [ + json!({"solution":[true,true,false]}), + json!({"value":2}), + json!({"status":"feasible"}), + json!({"status":"feasible", "solution":[true,true,false], "value":2}), + json!({"status":"feasible", "solution":[true,true,false], "evaluation":"Min(99)"}), + json!({"status":"feasible", "solution":[true,true,false], "evaluation":null}), + json!({"status":"optimal", "solution":[true,true,false], "value":2}), + json!({"status":"infeasible", "solution":[true,true,false]}), + json!({"status":"infeasible", "value":2}), + json!({"status":"complete"}), + json!({"status":"complete", "value":2, "solution":[true,true,false]}), + json!({"status":"complete", "value":2, "evaluation":"Min(2)"}), + json!({"status":"complete", "value":true}), + json!({"status":"complete", "value":2, "unexpected":true}), json!({"status":"optimal", "solution":[true,true,false], "evaluation":"Min(99)"}), json!({"status":"optimal", "solution":[true,true,false], "evaluation":99}), json!({"status":"optimal", "solution":[true,true,false], "evaluation":null}), @@ -9861,16 +9933,10 @@ fn test_extract_roundtrip_mis_to_qubo() { // independent of the reduction path selected by the graph search. let (target_cfg, expected_source_eval) = extract_test_solve_bundle(&bundle_file); - let extract_out = pred() - .args([ - "--json", - "extract", - bundle_file.to_str().unwrap(), - "--config", - &target_cfg, - ]) - .output() - .unwrap(); + let extract_out = extract_target_result( + &bundle_file, + serde_json::json!({"status":"feasible","solution":serde_json::from_str::(&target_cfg).unwrap()}), + ); assert!( extract_out.status.success(), "extract stderr: {}", @@ -9947,15 +10013,10 @@ fn test_extract_rejects_structurally_invalid_one_hot_config() { String::from_utf8_lossy(&reduce_out.stderr) ); - let extract_out = pred() - .args([ - "extract", - bundle_file.to_str().unwrap(), - "--config", - "[false,false,false,false,false,false,false,false,false]", - ]) - .output() - .unwrap(); + let extract_out = extract_target_result( + &bundle_file, + serde_json::json!({"status":"feasible","solution":[false,false,false,false,false,false,false,false,false]}), + ); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -9984,15 +10045,10 @@ fn test_extract_rejects_plain_problem_file() { .unwrap(); assert!(create_out.status.success()); - let extract_out = pred() - .args([ - "extract", - problem_file.to_str().unwrap(), - "--config", - "[false,true,false]", - ]) - .output() - .unwrap(); + let extract_out = extract_target_result( + &problem_file, + serde_json::json!({"status":"feasible","solution":[false,true,false]}), + ); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -10033,15 +10089,10 @@ fn test_extract_rejects_wrong_config_length() { &bundle_file, ); - let extract_out = pred() - .args([ - "extract", - bundle_file.to_str().unwrap(), - "--config", - "[false,true]", - ]) - .output() - .unwrap(); + let extract_out = extract_target_result( + &bundle_file, + serde_json::json!({"status":"feasible","solution":[false,true]}), + ); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -10090,15 +10141,10 @@ fn test_extract_rejects_non_boolean_solution_value() { bad_cfg.as_array_mut().unwrap()[0] = serde_json::json!(9); let bad_cfg = bad_cfg.to_string(); - let extract_out = pred() - .args([ - "extract", - bundle_file.to_str().unwrap(), - "--config", - &bad_cfg, - ]) - .output() - .unwrap(); + let extract_out = extract_target_result( + &bundle_file, + serde_json::json!({"status":"feasible","solution":serde_json::from_str::(&bad_cfg).unwrap()}), + ); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -10150,15 +10196,10 @@ fn test_extract_rejects_malformed_bundle_path_source_mismatch() { let mut f = std::fs::File::create(&tampered_file).unwrap(); f.write_all(bundle.to_string().as_bytes()).unwrap(); - let extract_out = pred() - .args([ - "extract", - tampered_file.to_str().unwrap(), - "--config", - "[false,true,false]", - ]) - .output() - .unwrap(); + let extract_out = extract_target_result( + &tampered_file, + serde_json::json!({"status":"feasible","solution":[false,true,false]}), + ); assert!( !extract_out.status.success(), "expected failure on malformed bundle; stdout: {}", @@ -10220,15 +10261,10 @@ fn test_extract_rejects_tampered_target_data() { // Any config long enough to reach the coherence check; it must fail before // config validation kicks in because prepare() runs first. let (target_cfg, _) = extract_test_solve_bundle(&bundle_file); - let extract_out = pred() - .args([ - "extract", - tampered_file.to_str().unwrap(), - "--config", - &target_cfg, - ]) - .output() - .unwrap(); + let extract_out = extract_target_result( + &tampered_file, + serde_json::json!({"status":"feasible","solution":serde_json::from_str::(&target_cfg).unwrap()}), + ); assert!( !extract_out.status.success(), "expected failure on tampered target.data; stdout: {}", @@ -10298,8 +10334,18 @@ fn test_extract_reads_bundle_from_stdin() { let (target_cfg, _) = extract_test_solve_bundle(&bundle_file); let bundle_text = std::fs::read_to_string(&bundle_file).unwrap(); + let result_file = bundle_file.with_extension("result.json"); + std::fs::write(&result_file, serde_json::json!({ + "status":"feasible", "solution":serde_json::from_str::(&target_cfg).unwrap(), + }).to_string()).unwrap(); let mut child = pred() - .args(["--json", "extract", "-", "--config", &target_cfg]) + .args([ + "--json", + "extract", + "-", + "--result", + result_file.to_str().unwrap(), + ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -10312,6 +10358,7 @@ fn test_extract_reads_bundle_from_stdin() { .write_all(bundle_text.as_bytes()) .unwrap(); let output = child.wait_with_output().unwrap(); + std::fs::remove_file(&result_file).unwrap(); assert!( output.status.success(), "stderr: {}", diff --git a/src/models/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index dd433b4dd..c8e4e0632 100644 --- a/src/models/algebraic/closest_vector_problem.rs +++ b/src/models/algebraic/closest_vector_problem.rs @@ -220,3 +220,45 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Basis matrix as semicolon-separated column vectors." }, + crate::registry::FieldInfo { name: "target_vec", type_name: "Vec", description: "Target vector." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + } +} +crate::declare_variants! { + default crate::models::decision::Decision => "2^(num_basis_vectors * log(num_basis_vectors))" create crate::models::decision::DecisionCreateSpec, +} +crate::register_decision_variant!(@edges ClosestVectorProblem, "DecisionClosestVectorProblem"); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_closest_vector_problem_to_closest_vector_problem", + build: || { + let source = crate::models::decision::Decision::new( + ClosestVectorProblem::new(vec![vec![2, 0], vec![1, 2]], vec![3_i64, 2]) + .expect("canonical closest-vector instance must be valid"), + 0, + ); + let witness = serde_json::json!(vec![1, 1]); + crate::example_db::specs::rule_example_with_witness::<_, ClosestVectorProblem>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/algebraic/quadratic_assignment.rs b/src/models/algebraic/quadratic_assignment.rs index f4a9d08df..b993f7792 100644 --- a/src/models/algebraic/quadratic_assignment.rs +++ b/src/models/algebraic/quadratic_assignment.rs @@ -246,3 +246,52 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Flow/cost matrix between facilities" }, + crate::registry::FieldInfo { name: "distance_matrix", type_name: "Vec>", description: "Distance matrix between locations" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_quadratic_assignment_to_quadratic_assignment", + build: || { + let source = crate::models::decision::Decision::new( + QuadraticAssignment::new( + vec![ + vec![0, 5, 2, 0], + vec![5, 0, 0, 3], + vec![2, 0, 0, 4], + vec![0, 3, 4, 0], + ], + vec![ + vec![0, 4, 1, 1], + vec![4, 0, 3, 4], + vec![1, 3, 0, 4], + vec![1, 4, 4, 0], + ], + ), + 56, + ); + let witness = serde_json::json!(vec![3, 0, 1, 2]); + crate::example_db::specs::rule_example_with_witness::<_, QuadraticAssignment>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 1c145bd6e..5cbc12e71 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -242,3 +242,38 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionQUBO"); +crate::register_decision_variant!( + QUBO, "DecisionQUBO", "2^num_vars", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Algebraic, + dims: [VariantDimension::new("weight", "i64", &["i64"])], + fields: [ + crate::registry::FieldInfo { name: "matrix", type_name: "Vec>", description: "Q matrix; the number of variables is its row count." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_qubo_to_qubo", + build: || { + let source = crate::models::decision::Decision::new( + QUBO::from_matrix(vec![vec![-1, 2, 0], vec![0, -1, 2], vec![0, 0, -1]]).unwrap(), + -2, + ); + let witness = serde_json::json!(vec![true, false, true]); + crate::example_db::specs::rule_example_with_witness::<_, QUBO>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/formula/maximum_2_satisfiability.rs b/src/models/formula/maximum_2_satisfiability.rs index 5085b21ff..343a0f55e 100644 --- a/src/models/formula/maximum_2_satisfiability.rs +++ b/src/models/formula/maximum_2_satisfiability.rs @@ -192,3 +192,50 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Collection of 2-literal clauses" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_maximum_2_satisfiability_to_maximum_2_satisfiability", + build: || { + let source = crate::models::decision::Decision::new( + Maximum2Satisfiability::new( + 4, + vec![ + CNFClause::new(vec![1, 2]), + CNFClause::new(vec![1, -2]), + CNFClause::new(vec![-1, 3]), + CNFClause::new(vec![-1, -3]), + CNFClause::new(vec![2, 4]), + CNFClause::new(vec![-3, -4]), + CNFClause::new(vec![3, 4]), + ], + ), + 6, + ); + let witness = serde_json::json!(vec![true, true, false, true]); + crate::example_db::specs::rule_example_with_witness::<_, Maximum2Satisfiability>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index d42bc6461..4002c4662 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -360,3 +360,62 @@ crate::register_brute_force! { #[cfg(test)] #[path = "../../unit_tests/models/graph/longest_circuit.rs"] mod tests; + +crate::decision_problem_meta!(LongestCircuit, "DecisionLongestCircuit"); +crate::register_decision_variant!( + LongestCircuit, "DecisionLongestCircuit", "2^num_vertices * num_vertices^2", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_longest_circuit_to_longest_circuit", + build: || { + let source = crate::models::decision::Decision::new( + LongestCircuit::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + (2, 5), + (3, 5), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 1, 2], + ), + 18, + ); + let witness = serde_json::json!(vec![ + true, false, true, false, true, false, true, true, true, false + ]); + crate::example_db::specs::rule_example_with_witness::<_, LongestCircuit>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index a30acd830..3cb7946e1 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -327,3 +327,44 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionLongestPath"); +crate::register_decision_variant!( + LongestPath, "DecisionLongestPath", "num_vertices * 2^num_vertices", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "One", &["One"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "source_vertex", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "target_vertex", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_longest_path_to_longest_path", + build: || { + let source = crate::models::decision::Decision::new( + LongestPath::new(SimpleGraph::path(3), vec![crate::types::One; 2], 0, 2), + 2, + ); + let witness = serde_json::json!(vec![true, true]); + crate::example_db::specs::rule_example_with_witness::<_, LongestPath>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 45f1c4759..152f7dfb6 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -348,3 +348,46 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMaxCut"); +crate::register_decision_variant!( + MaxCut, "DecisionMaxCut", "2^(2.372 * num_vertices / 3)", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_max_cut_to_max_cut", + build: || { + let source = crate::models::decision::Decision::new( + MaxCut::<_, i64>::unweighted(SimpleGraph::new( + 5, + vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)], + )), + 5, + ); + let witness = serde_json::json!(vec![true, false, false, true, false]); + crate::example_db::specs::rule_example_with_witness::<_, MaxCut>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 4f34acd5d..986d0fbac 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -416,3 +416,54 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMinMaxMulticenter"); +crate::register_decision_variant!( + MinMaxMulticenter, "DecisionMinMaxMulticenter", "1.4969^num_vertices", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "One", &["One"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "k", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_min_max_multicenter_to_min_max_multicenter", + build: || { + let source = crate::models::decision::Decision::new( + MinMaxMulticenter::new( + SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5), (1, 4)], + ), + vec![crate::types::One; 6], + vec![crate::types::One; 7], + 2, + ), + 1, + ); + let witness = serde_json::json!(vec![false, true, false, false, true, false]); + crate::example_db::specs::rule_example_with_witness::< + _, + MinMaxMulticenter, + >( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/minimum_covering_by_cliques.rs b/src/models/graph/minimum_covering_by_cliques.rs index 1f59f2bae..0b8f2da3d 100644 --- a/src/models/graph/minimum_covering_by_cliques.rs +++ b/src/models/graph/minimum_covering_by_cliques.rs @@ -223,3 +223,59 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + "DecisionMinimumCoveringByCliques" +); +crate::register_decision_variant!( + MinimumCoveringByCliques, "DecisionMinimumCoveringByCliques", "2^num_edges", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], + fields: [ +crate::registry::FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, +crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, +], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_minimum_covering_by_cliques_to_minimum_covering_by_cliques", + build: || { + let source = crate::models::decision::Decision::new( + MinimumCoveringByCliques::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (0, 2), + (4, 0), + (4, 1), + (5, 2), + (5, 3), + ], + )), + 4, + ); + let witness = serde_json::json!(vec![0, 0, 1, 1, 0, 2, 2, 3, 3]); + crate::example_db::specs::rule_example_with_witness::< + _, + MinimumCoveringByCliques, + >( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 327bf3f3e..66c718ee2 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -421,3 +421,65 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMinimumSumMulticenter"); +crate::register_decision_variant!( + MinimumSumMulticenter, "DecisionMinimumSumMulticenter", "2^num_vertices", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "k", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_minimum_sum_multicenter_to_minimum_sum_multicenter", + build: || { + let source = crate::models::decision::Decision::new( + MinimumSumMulticenter::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (0, 6), + (2, 5), + ], + ), + vec![1i64; 7], + vec![1i64; 8], + 2, + ), + 6, + ); + let witness = serde_json::json!(vec![false, false, true, false, false, true, false]); + crate::example_db::specs::rule_example_with_witness::< + _, + MinimumSumMulticenter, + >( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 703f98593..19f0d2561 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -418,3 +418,57 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionRuralPostman"); +crate::register_decision_variant!( + RuralPostman, "DecisionRuralPostman", "2^num_vertices * num_vertices^2", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "required_edges", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_rural_postman_to_rural_postman", + build: || { + let graph = SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + ], + ); + let source = crate::models::decision::Decision::new( + RuralPostman::new(graph, vec![1, 1, 1, 1, 1, 1, 2, 2], vec![0, 2, 4]), + 6, + ); + let witness = serde_json::json!(vec![1, 1, 1, 1, 1, 1, 0, 0]); + crate::example_db::specs::rule_example_with_witness::<_, RuralPostman>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index b99974051..353df1fc8 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -444,3 +444,56 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionSpinGlass"); +crate::register_decision_variant!( + SpinGlass, "DecisionSpinGlass", "2^num_spins", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Undirected interaction graph edges." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Vertex count, needed to preserve isolated spins." }, + crate::registry::FieldInfo { name: "couplings", type_name: "Vec", description: "Pairwise couplings; defaults to one per edge." }, + crate::registry::FieldInfo { name: "fields", type_name: "Vec", description: "On-site fields; defaults to zero per vertex." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| SpinGlass::::config_to_spins(&indices).expect("enumerated spin bits are valid") +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_spin_glass_to_spin_glass", + build: || { + let source = crate::models::decision::Decision::new( + SpinGlass::::without_fields( + 5, + vec![ + ((0, 1), 1), + ((1, 2), 1), + ((3, 4), 1), + ((0, 3), 1), + ((1, 3), 1), + ((1, 4), 1), + ((2, 4), 1), + ], + ) + .unwrap(), + -3, + ); + let witness = serde_json::json!(vec![1, -1, 1, 1, -1]); + crate::example_db::specs::rule_example_with_witness::<_, SpinGlass>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 46a4fbbde..e18f75d24 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -155,7 +155,7 @@ mod multiprocessor_scheduling; mod non_liveness_free_petri_net; mod numerical_3_dimensional_matching; mod numerical_matching_with_target_sums; -mod open_shop_scheduling; +pub(crate) mod open_shop_scheduling; pub(crate) mod optimum_communication_spanning_tree; pub(crate) mod paintshop; pub(crate) mod partially_ordered_knapsack; @@ -169,7 +169,7 @@ pub(crate) mod resource_constrained_scheduling; mod scheduling_to_minimize_weighted_completion_time; mod scheduling_with_individual_deadlines; mod sequencing_to_minimize_maximum_cumulative_cost; -mod sequencing_to_minimize_tardy_task_weight; +pub(crate) mod sequencing_to_minimize_tardy_task_weight; mod sequencing_to_minimize_weighted_completion_time; mod sequencing_to_minimize_weighted_tardiness; mod sequencing_with_deadlines_and_set_up_times; @@ -178,7 +178,7 @@ mod sequencing_within_intervals; pub(crate) mod shortest_common_supersequence; pub(crate) mod shortest_common_superstring; mod square_tiling; -mod stacker_crane; +pub(crate) mod stacker_crane; mod staff_scheduling; pub(crate) mod string_to_string_correction; mod subset_product; diff --git a/src/models/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 30680b8cc..04f7a236e 100644 --- a/src/models/misc/open_shop_scheduling.rs +++ b/src/models/misc/open_shop_scheduling.rs @@ -329,3 +329,42 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Processing time of each job on each machine (n x m)." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_open_shop_scheduling_to_open_shop_scheduling", + build: || { + let source = crate::models::decision::Decision::new( + OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + ), + 8, + ); + let witness = serde_json::json!(vec![0, 3, 4, 3, 0, 6, 5, 6, 0, 6, 4, 3]); + crate::example_db::specs::rule_example_with_witness::<_, OpenShopScheduling>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs index c60474b40..fe93f562e 100644 --- a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -260,3 +260,41 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Lengths" }, +crate::registry::FieldInfo { name: "weights", type_name: "Option>", description: "Weights" }, +crate::registry::FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadlines" }, +crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, +], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_sequencing_to_minimize_tardy_task_weight_to_sequencing_to_minimize_tardy_task_weight", + build: || { + let source = crate::models::decision::Decision::new(SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ), 3); + let witness = serde_json::json!(vec![3, 0, 4, 2, 1]); + crate::example_db::specs::rule_example_with_witness::<_, SequencingToMinimizeTardyTaskWeight>( + source, + crate::export::SolutionPair { source_config: witness.clone(), target_config: witness }, + ) + }, + }] +} diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index 6d56ca1b8..4c1c51a5a 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -423,3 +423,48 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Required directed arcs." }, + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Undirected connector edges." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Vertex count, needed to preserve isolated vertices." }, + crate::registry::FieldInfo { name: "arc_lengths", type_name: "Vec", description: "Required-arc lengths; defaults to one per arc." }, + crate::registry::FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Connector-edge lengths; defaults to one per edge." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_stacker_crane_to_stacker_crane", + build: || { + let source = crate::models::decision::Decision::new( + StackerCrane::new( + 6, + vec![(0, 4), (2, 5), (5, 1), (3, 0), (4, 3)], + vec![(0, 1), (1, 2), (2, 3), (3, 5), (4, 5), (0, 3), (1, 5)], + vec![3, 4, 2, 5, 3], + vec![2, 1, 3, 2, 1, 4, 3], + ), + 20, + ); + let witness = serde_json::json!(vec![0, 2, 1, 4, 3]); + crate::example_db::specs::rule_example_with_witness::<_, StackerCrane>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/rules/circuit_spinglass.rs b/src/rules/circuit_spinglass.rs index b9f97a94b..570aa2c2d 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -6,6 +6,7 @@ //! Each logic gate is encoded as a SpinGlass Hamiltonian where the ground //! states correspond to valid input/output combinations. +use crate::models::decision::Decision; use crate::models::formula::{Assignment, BooleanExpr, BooleanOp, CircuitSAT}; use crate::models::graph::SpinGlass; use crate::reduction; @@ -209,18 +210,16 @@ where #[derive(Debug, Clone)] pub struct ReductionCircuitToSG { /// The target SpinGlass problem. - target: SpinGlass, + target: Decision>, /// Mapping from source variable names to spin indices. variable_map: HashMap, /// Source variable names in order. source_variables: Vec, - /// Sum of the individual gate and equality ground energies. - zero_penalty_energy: i64, } impl ReductionResult for ReductionCircuitToSG { type Source = CircuitSAT; - type Target = SpinGlass; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -232,7 +231,7 @@ impl ReductionResult for ReductionCircuitToSG { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "SpinGlass energy does not meet the circuit zero-penalty threshold", )); @@ -249,14 +248,14 @@ impl ReductionResult for ReductionCircuitToSG { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionCircuitToSG { type Source = CircuitSAT; - type Target = SpinGlass; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.zero_penalty_energy)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -497,7 +496,7 @@ fn process_assignment( num_interactions = "6 * num_expression_nodes + num_assignment_outputs", } )] -impl ReduceTo> for CircuitSAT { +impl ReduceTo>> for CircuitSAT { type Result = ReductionCircuitToSG; fn reduce_to(&self) -> Result { @@ -508,21 +507,23 @@ impl ReduceTo> for CircuitSAT { process_assignment(assignment, &mut builder).map_err( crate::rules::ReductionError::construction::< CircuitSAT, - SpinGlass, + Decision>, >, )?; } let (target, variable_map, zero_penalty_energy) = builder.build().map_err( - crate::rules::ReductionError::construction::>, + crate::rules::ReductionError::construction::< + CircuitSAT, + Decision>, + >, )?; let source_variables = self.variable_names().to_vec(); Ok(ReductionCircuitToSG { - target, + target: Decision::new(target, zero_penalty_energy), variable_map, source_variables, - zero_penalty_energy, }) } } @@ -561,7 +562,10 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( + crate::example_db::specs::rule_example_with_witness::< + _, + Decision>, + >( full_adder_circuit_sat(), SolutionPair { source_config: serde_json::json!(vec![ diff --git a/src/rules/coloring_qubo.rs b/src/rules/coloring_qubo.rs index 5433e7b47..876d0fcb3 100644 --- a/src/rules/coloring_qubo.rs +++ b/src/rules/coloring_qubo.rs @@ -9,6 +9,7 @@ //! QUBO has n*K variables. use crate::models::algebraic::QUBO; +use crate::models::decision::Decision; use crate::models::graph::KColoring; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -18,16 +19,15 @@ use crate::variant::{KValue, K2, K3, KN}; /// Result of reducing KColoring to QUBO. #[derive(Debug, Clone)] pub struct ReductionKColoringToQUBO { - target: QUBO, + target: Decision>, num_vertices: usize, num_colors: usize, - feasible_energy: i64, _phantom: std::marker::PhantomData, } impl ReductionResult for ReductionKColoringToQUBO { type Source = KColoring; - type Target = QUBO; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -40,7 +40,7 @@ impl ReductionResult for ReductionKColoringToQUBO { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target QUBO configuration does not certify a proper coloring", )); @@ -68,14 +68,14 @@ crate::register_aggregate_reduction!(ReductionKColoringToQUBO); impl crate::rules::AggregateReductionResult for ReductionKColoringToQUBO { type Source = KColoring; - type Target = QUBO; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.feasible_energy)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -85,9 +85,10 @@ fn coloring_qubo_parameters( k: usize, ) -> Result<(usize, i64, i64), crate::rules::ReductionError> { let overflow = |operation| { - crate::rules::ReductionError::integer_overflow::, QUBO>( - operation, - ) + crate::rules::ReductionError::integer_overflow::< + KColoring, + Decision>, + >(operation) }; let nq = n .checked_mul(k) @@ -114,9 +115,10 @@ fn reduce_kcoloring_to_qubo( let n = problem.graph().num_vertices(); let edges = problem.graph().edges(); let overflow = |operation| { - crate::rules::ReductionError::integer_overflow::, QUBO>( - operation, - ) + crate::rules::ReductionError::integer_overflow::< + KColoring, + Decision>, + >(operation) }; let (nq, penalty, feasible_energy) = coloring_qubo_parameters::(n, k)?; @@ -171,14 +173,18 @@ fn reduce_kcoloring_to_qubo( } Ok(ReductionKColoringToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { - crate::rules::ReductionError::construction::, QUBO>( - message, - ) - })?, + target: Decision::new( + QUBO::from_matrix(matrix).map_err(|message| { + crate::rules::ReductionError::construction::< + KColoring, + Decision>, + >(message) + })?, + feasible_energy, + ), num_vertices: n, num_colors: k, - feasible_energy, + _phantom: std::marker::PhantomData, }) } @@ -189,7 +195,7 @@ fn reduce_kcoloring_to_qubo( num_vars = "num_vertices * num_colors", } )] -impl ReduceTo> for KColoring { +impl ReduceTo>> for KColoring { type Result = ReductionKColoringToQUBO; fn reduce_to(&self) -> Result { @@ -200,7 +206,7 @@ impl ReduceTo> for KColoring { // Additional concrete impls for tests (not registered in reduction graph) macro_rules! impl_kcoloring_to_qubo { ($($ktype:ty),+) => {$( - impl ReduceTo> for KColoring<$ktype, SimpleGraph> { + impl ReduceTo>> for KColoring<$ktype, SimpleGraph> { type Result = ReductionKColoringToQUBO<$ktype>; fn reduce_to(&self) -> Result { reduce_kcoloring_to_qubo(self) @@ -221,7 +227,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::with_k(SimpleGraph::new(n, edges), 3); - crate::example_db::specs::rule_example_with_witness::<_, QUBO>( + crate::example_db::specs::rule_example_with_witness::<_, Decision>>( source, SolutionPair { source_config: serde_json::json!(vec![1, 2, 2, 1, 0]), diff --git a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 3ee7ab92c..d644f4b72 100644 --- a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -10,19 +10,18 @@ use crate::models::graph::{MinimumDominatingSet, MinimumSumMulticenter}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; -use crate::types::{Min, One, Or}; +use crate::types::One; /// Result of reducing DecisionMinimumDominatingSet to MinimumSumMulticenter. #[derive(Debug, Clone)] pub struct ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { - target: MinimumSumMulticenter, + target: Decision>, source_num_vertices: usize, - threshold: i64, } impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { type Source = Decision>; - type Target = MinimumSumMulticenter; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -34,7 +33,7 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMultic ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target placement does not certify a dominating set within the source bound", )); @@ -49,21 +48,21 @@ impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { type Source = Decision>; - type Target = MinimumSumMulticenter; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Min) -> Or { - Or(target_value.0 == Some(self.threshold)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } #[reduction( transform = upper_bound { num_vertices = "num_vertices + 2", num_edges = "num_edges" } )] -impl ReduceTo> +impl ReduceTo>> for Decision> { type Result = ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter; @@ -80,9 +79,8 @@ impl ReduceTo> ); Ok( ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { - target, + target: Decision::new(target, threshold), source_num_vertices: n, - threshold, }, ) } @@ -95,7 +93,7 @@ fn multicenter_parameters( bound: i64, ) -> Result<(usize, usize, i64), crate::rules::ReductionError> { type Source = Decision>; - type Target = MinimumSumMulticenter; + type Target = Decision>; let overflow = || { crate::rules::ReductionError::integer_overflow::( "encoding multicenter construction parameters", @@ -124,7 +122,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( Decision::new( MinimumDominatingSet::new( diff --git a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs index 331a0d29c..fc89de810 100644 --- a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -9,18 +9,18 @@ use crate::models::graph::{MinMaxMulticenter, MinimumDominatingSet}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; -use crate::types::{Min, One, Or}; +use crate::types::One; /// The source vertices precede the two mandatory auxiliary centers. #[derive(Debug, Clone)] pub struct ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { - target: MinMaxMulticenter, + target: Decision>, source_num_vertices: usize, } impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { type Source = Decision>; - type Target = MinMaxMulticenter; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -32,7 +32,7 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticente ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target placement does not certify a dominating set: radius must be at most one", )); @@ -46,14 +46,14 @@ impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { type Source = Decision>; - type Target = MinMaxMulticenter; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Min) -> Or { - Or(target_value.0.is_some_and(|radius| radius <= 1)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -63,7 +63,7 @@ impl crate::rules::AggregateReductionResult num_edges = "num_edges", } )] -impl ReduceTo> +impl ReduceTo>> for Decision> { type Result = ReductionDecisionMinimumDominatingSetToMinMaxMulticenter; @@ -79,7 +79,7 @@ impl ReduceTo> centers, ); Ok(ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { - target, + target: Decision::new(target, 1), source_num_vertices: n, }) } @@ -91,7 +91,7 @@ fn multicenter_parameters( bound: i64, ) -> Result<(usize, usize), crate::rules::ReductionError> { type Source = Decision>; - type Target = MinMaxMulticenter; + type Target = Decision>; let overflow = || { crate::rules::ReductionError::integer_overflow::( "encoding min-max multicenter parameters", @@ -115,7 +115,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( Decision::new( MinimumDominatingSet::new( diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs index 989ba505c..fa1372314 100644 --- a/src/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -4,6 +4,7 @@ //! with unit edge weights. A Hamiltonian circuit exists iff the optimal circuit //! length equals |V|. +use crate::models::decision::Decision; use crate::models::graph::{HamiltonianCircuit, LongestCircuit}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -12,12 +13,12 @@ use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to LongestCircuit. #[derive(Debug, Clone)] pub struct ReductionHamiltonianCircuitToLongestCircuit { - target: LongestCircuit, + target: Decision>, } impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { type Source = HamiltonianCircuit; - type Target = LongestCircuit; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -29,31 +30,30 @@ impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target circuit does not certify a Hamiltonian circuit", )); } - crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) + crate::rules::graph_helpers::edges_to_cycle_order( + self.target.inner().graph(), + target_solution, + ) } } #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLongestCircuit { type Source = HamiltonianCircuit; - type Target = LongestCircuit; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: crate::types::Max) -> crate::types::Or { - crate::types::Or( - target_value - .0 - .is_some_and(|length| usize::try_from(length) == Ok(self.target.num_vertices())), - ) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -63,14 +63,22 @@ impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLon num_edges = "num_edges", } )] -impl ReduceTo> for HamiltonianCircuit { +impl ReduceTo>> for HamiltonianCircuit { type Result = ReductionHamiltonianCircuitToLongestCircuit; fn reduce_to(&self) -> Result { let n = self.num_vertices(); let edges = self.graph().edges(); let target = LongestCircuit::new(SimpleGraph::new(n, edges), vec![1i64; self.num_edges()]); - Ok(ReductionHamiltonianCircuitToLongestCircuit { target }) + Ok(ReductionHamiltonianCircuitToLongestCircuit { + target: Decision::new( + target, + >>>::exact_i64( + n, + "encoding the circuit bound", + )?, + ), + }) } } @@ -82,7 +90,10 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( + crate::example_db::specs::rule_example_with_witness::< + _, + Decision>, + >( source, SolutionPair { source_config: serde_json::json!(vec![0, 1, 2, 3]), diff --git a/src/rules/hamiltoniancircuit_quadraticassignment.rs b/src/rules/hamiltoniancircuit_quadraticassignment.rs index 80574d405..db71da71d 100644 --- a/src/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -6,6 +6,7 @@ //! than three vertices map to a fixed positive-cost instance. use crate::models::algebraic::QuadraticAssignment; +use crate::models::decision::Decision; use crate::models::graph::HamiltonianCircuit; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -14,12 +15,12 @@ use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to QuadraticAssignment. #[derive(Debug, Clone)] pub struct ReductionHamiltonianCircuitToQuadraticAssignment { - target: QuadraticAssignment, + target: Decision, } impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { type Source = HamiltonianCircuit; - type Target = QuadraticAssignment; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target @@ -31,7 +32,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target assignment does not certify a Hamiltonian circuit", )); @@ -45,14 +46,14 @@ impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { type Source = HamiltonianCircuit; - type Target = QuadraticAssignment; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: crate::types::Min) -> crate::types::Or { - crate::types::Or(target_value == crate::types::Min(Some(0))) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -62,7 +63,7 @@ impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQua num_locations = "num_vertices + 3", } )] -impl ReduceTo for HamiltonianCircuit { +impl ReduceTo> for HamiltonianCircuit { type Result = ReductionHamiltonianCircuitToQuadraticAssignment; fn reduce_to(&self) -> Result { @@ -82,7 +83,9 @@ impl ReduceTo for HamiltonianCircuit { .collect(); let target = QuadraticAssignment::new(cost_matrix, distance_matrix); - Ok(ReductionHamiltonianCircuitToQuadraticAssignment { target }) + Ok(ReductionHamiltonianCircuitToQuadraticAssignment { + target: Decision::new(target, 0), + }) } } @@ -94,7 +97,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( + crate::example_db::specs::rule_example_with_witness::<_, Decision>( source, SolutionPair { source_config: serde_json::json!(vec![0, 1, 2, 3]), diff --git a/src/rules/hamiltoniancircuit_ruralpostman.rs b/src/rules/hamiltoniancircuit_ruralpostman.rs index 56e70adc9..28ab7cb5d 100644 --- a/src/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -23,6 +23,7 @@ //! b-vertices and a-vertices does not admit a perfect matching corresponding //! to a Hamiltonian circuit), so cost > 2n. +use crate::models::decision::Decision; use crate::models::graph::{HamiltonianCircuit, RuralPostman}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -31,7 +32,7 @@ use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to RuralPostman. #[derive(Debug, Clone)] pub struct ReductionHamiltonianCircuitToRuralPostman { - target: RuralPostman, + target: Decision>, /// Number of vertices in the original graph. n: usize, /// Edges of the original graph (for solution extraction). @@ -40,7 +41,7 @@ pub struct ReductionHamiltonianCircuitToRuralPostman { impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { type Source = HamiltonianCircuit; - type Target = RuralPostman; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -52,7 +53,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target witness does not certify a YES answer for the source", )); @@ -112,19 +113,14 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToRuralPostman { type Source = HamiltonianCircuit; - type Target = RuralPostman; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or( - self.n >= 3 - && value - .0 - .is_some_and(|cost| i128::from(cost) == 2 * self.n as i128), - ) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -135,7 +131,7 @@ impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToRur num_required_edges = "num_vertices", } )] -impl ReduceTo> for HamiltonianCircuit { +impl ReduceTo>> for HamiltonianCircuit { type Result = ReductionHamiltonianCircuitToRuralPostman; fn reduce_to(&self) -> Result { @@ -170,7 +166,17 @@ impl ReduceTo> for HamiltonianCircuit>>>::exact_i64( + 2 * n, + "encoding the route bound", + )? + }, + ), n, source_edges, }) @@ -194,7 +200,10 @@ pub(crate) fn canonical_rule_example_specs() -> Vec0: bwd edge of source edge 2=(0,2), idx=8 // Required edges all have multiplicity 1. // target_config = [1, 1, 1, 1, 0, 1, 0, 0, 1] - crate::example_db::specs::rule_example_with_witness::<_, RuralPostman>( + crate::example_db::specs::rule_example_with_witness::< + _, + Decision>, + >( source, SolutionPair { source_config: serde_json::json!(vec![0, 1, 2]), diff --git a/src/rules/hamiltoniancircuit_stackercrane.rs b/src/rules/hamiltoniancircuit_stackercrane.rs index 5e512b0b0..15bdf65a6 100644 --- a/src/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -12,6 +12,7 @@ //! paths cost strictly more than single-hop ones. Only permutations attaining //! this lower bound certify a Hamiltonian circuit. +use crate::models::decision::Decision; use crate::models::graph::HamiltonianCircuit; use crate::models::misc::StackerCrane; use crate::reduction; @@ -21,12 +22,12 @@ use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to StackerCrane. #[derive(Debug, Clone)] pub struct ReductionHamiltonianCircuitToStackerCrane { - target: StackerCrane, + target: Decision, } impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { type Source = HamiltonianCircuit; - type Target = StackerCrane; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target @@ -38,7 +39,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target tour does not certify a Hamiltonian circuit", )); @@ -51,19 +52,14 @@ impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToStackerCrane { type Source = HamiltonianCircuit; - type Target = StackerCrane; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or( - self.target.num_arcs() >= 3 - && value - .0 - .is_some_and(|cost| usize::try_from(cost) == Ok(self.target.num_vertices())), - ) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -74,7 +70,7 @@ impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToSta num_edges = "2 * num_edges", } )] -impl ReduceTo for HamiltonianCircuit { +impl ReduceTo> for HamiltonianCircuit { type Result = ReductionHamiltonianCircuitToStackerCrane; fn reduce_to(&self) -> Result { @@ -103,9 +99,21 @@ impl ReduceTo for HamiltonianCircuit { let target = StackerCrane::try_new(target_num_vertices, arcs, edges, arc_lengths, edge_lengths) - .map_err(>::target_construction)?; - - Ok(ReductionHamiltonianCircuitToStackerCrane { target }) + .map_err(>>::target_construction)?; + + Ok(ReductionHamiltonianCircuitToStackerCrane { + target: Decision::new( + target, + if n < 3 { + -1 + } else { + >>::exact_i64( + target_num_vertices, + "encoding the route bound", + )? + }, + ), + }) } } @@ -116,7 +124,7 @@ fn split_graph_dimensions( ) -> Result<(usize, usize), crate::rules::ReductionError> { type Source = HamiltonianCircuit; let overflow = || { - crate::rules::ReductionError::integer_overflow::( + crate::rules::ReductionError::integer_overflow::>( "encoding split graph dimensions and route costs", ) }; @@ -125,7 +133,10 @@ fn split_graph_dimensions( // A shortest connector is simple and has at most 2n-1 unit steps. // The n services therefore cost at most n * (1 + (2n-1)). let cost_bound = n.checked_mul(vertices).ok_or_else(overflow)?; - >::exact_i64(cost_bound, "bounding split graph route costs")?; + >>::exact_i64( + cost_bound, + "bounding split graph route costs", + )?; Ok((vertices, edges)) } @@ -137,7 +148,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( + crate::example_db::specs::rule_example_with_witness::<_, Decision>( source, SolutionPair { source_config: serde_json::json!(vec![0, 1, 2, 3]), diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index ed0857867..cfe9b9197 100644 --- a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -5,6 +5,7 @@ //! source/target vertices, the longest path of length n-1 exactly corresponds //! to a Hamiltonian s-t path. +use crate::models::decision::Decision; use crate::models::graph::{HamiltonianPathBetweenTwoVertices, LongestPath}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -14,12 +15,12 @@ use crate::types::One; /// Result of reducing HamiltonianPathBetweenTwoVertices to LongestPath. #[derive(Debug, Clone)] pub struct ReductionHPBTVToLP { - target: LongestPath, + target: Decision>, } impl ReductionResult for ReductionHPBTVToLP { type Source = HamiltonianPathBetweenTwoVertices; - type Target = LongestPath; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -35,14 +36,17 @@ impl ReductionResult for ReductionHPBTVToLP { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target path does not certify a Hamiltonian source-target path", )); } - let mut adjacency = vec![Vec::new(); self.target.num_vertices()]; - for (&selected, (u, v)) in target_solution.iter().zip(self.target.graph().edges()) { + let mut adjacency = vec![Vec::new(); self.target.inner().num_vertices()]; + for (&selected, (u, v)) in target_solution + .iter() + .zip(self.target.inner().graph().edges()) + { if selected { adjacency[u].push(v); adjacency[v].push(u); @@ -52,9 +56,9 @@ impl ReductionResult for ReductionHPBTVToLP { // Target feasibility guarantees a single simple path with these endpoints. // Its certified n-1 edges visit every vertex; walking away from the // previous vertex terminates at the target without repetitions. - let mut current = self.target.source_vertex(); + let mut current = self.target.inner().source_vertex(); let mut previous = None; - let mut path = Vec::with_capacity(self.target.num_vertices()); + let mut path = Vec::with_capacity(self.target.inner().num_vertices()); path.push(current); while let Some(&next) = adjacency[current] .iter() @@ -71,19 +75,14 @@ impl ReductionResult for ReductionHPBTVToLP { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP { type Source = HamiltonianPathBetweenTwoVertices; - type Target = LongestPath; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Max) -> crate::types::Or { - // The source requires distinct valid endpoints, hence at least two vertices. - crate::types::Or( - value.0.is_some_and(|length| { - usize::try_from(length) == Ok(self.target.num_vertices() - 1) - }), - ) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -92,7 +91,9 @@ impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP { num_vertices = "num_vertices", num_edges = "num_edges", })] -impl ReduceTo> for HamiltonianPathBetweenTwoVertices { +impl ReduceTo>> + for HamiltonianPathBetweenTwoVertices +{ type Result = ReductionHPBTVToLP; fn reduce_to(&self) -> Result { @@ -107,7 +108,15 @@ impl ReduceTo> for HamiltonianPathBetweenTwoVertic self.target_vertex(), ); - Ok(ReductionHPBTVToLP { target }) + Ok(ReductionHPBTVToLP { + target: Decision::new( + target, + >>>::exact_i64( + self.num_vertices() - 1, + "encoding the path bound", + )?, + ), + }) } } @@ -124,7 +133,10 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( + crate::example_db::specs::rule_example_with_witness::< + _, + Decision>, + >( source, SolutionPair { source_config: serde_json::json!(vec![0, 1, 2, 3, 4]), diff --git a/src/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/rules/ksatisfiability_decisionminimumvertexcover.rs index 2e5fa8f3b..31e118eeb 100644 --- a/src/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -1,23 +1,29 @@ -//! Reduction from KSatisfiability (3-SAT) to Decision Minimum Vertex Cover. +//! Reduction from KSatisfiability (3-SAT) to Decision. //! -//! This wraps the classical Garey & Johnson Theorem 3.3 construction in the -//! `Decision>` wrapper, with threshold -//! `k = n + 2m` for `n` variables and `m` clauses. +//! Classical Garey & Johnson reduction (Theorem 3.3). For each variable u_i, +//! add two vertices {u_i, not-u_i} connected by a truth-setting edge. For each +//! clause c_j, add 3 vertices forming a satisfaction-testing triangle. For each +//! literal l_k in clause c_j, add a communication edge from the triangle vertex +//! j_k to the literal vertex l_k. +//! +//! The resulting graph has a vertex cover of size n + 2m if and only if the +//! 3-SAT formula is satisfiable (n = num_vars, m = num_clauses). +//! +//! Reference: Garey & Johnson, "Computers and Intractability", 1979, Theorem 3.3 use crate::models::decision::Decision; use crate::models::formula::KSatisfiability; use crate::models::graph::MinimumVertexCover; use crate::reduction; -use crate::rules::ksatisfiability_minimumvertexcover::Reduction3SATToMVC; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::SimpleGraph; use crate::variant::K3; -/// Result of reducing KSatisfiability to Decision>. +/// Result of reducing KSatisfiability to Decision. #[derive(Debug, Clone)] pub struct Reduction3SATToDecisionMVC { target: Decision>, - base_reduction: Reduction3SATToMVC, + source_num_vars: usize, } impl ReductionResult for Reduction3SATToDecisionMVC { @@ -28,11 +34,33 @@ impl ReductionResult for Reduction3SATToDecisionMVC { &self.target } + /// Extract a SAT assignment from a vertex cover solution. + /// + /// Vertex layout: indices 0..2n are literal vertices (even = positive, + /// odd = negated). For variable i, vertex 2*i is u_i and vertex 2*i+1 + /// is not-u_i. Each truth-setting edge forces exactly one of these two + /// into any cover meeting the target bound. If u_i is in the cover, set x_i = 1; + /// if not-u_i is in the cover, set x_i = 0. fn extract_solution( &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - self.base_reduction.extract_solution(target_solution) + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + if !value.0 { + return Err(crate::rules::ExtractionError::invalid( + "target witness does not certify a YES answer for the source", + )); + } + + Ok({ + (0..self.source_num_vars) + .map(|i| { + // u_i is at index 2*i, not-u_i is at index 2*i+1 + target_solution[2 * i] + }) + .collect() + }) } } @@ -40,9 +68,11 @@ impl ReductionResult for Reduction3SATToDecisionMVC { impl crate::rules::AggregateReductionResult for Reduction3SATToDecisionMVC { type Source = KSatisfiability; type Target = Decision>; + fn target_problem(&self) -> &Self::Target { &self.target } + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { value } @@ -58,25 +88,60 @@ impl ReduceTo>> for KSatisfiabilit type Result = Reduction3SATToDecisionMVC; fn reduce_to(&self) -> Result { - let base_reduction = as ReduceTo< - MinimumVertexCover, - >>::reduce_to(self)?; - let bound = self - .num_clauses() - .checked_mul(2) - .and_then(|value| value.checked_add(self.num_vars())) - .and_then(|value| i64::try_from(value).ok()) - .ok_or_else(|| { - crate::rules::ReductionError::integer_overflow::< - KSatisfiability, - Decision>, - >("computing the target cover bound") - })?; - let target = Decision::new(base_reduction.target_problem().clone(), bound); + let n = self.num_vars(); + let m = self.num_clauses(); + let total_vertices = 2 * n + 3 * m; + let mut edges: Vec<(usize, usize)> = Vec::with_capacity(n + 6 * m); + + // Step 1: Truth-setting components. + // For each variable i, add edge (2*i, 2*i+1) connecting u_i and not-u_i. + for i in 0..n { + edges.push((2 * i, 2 * i + 1)); + } + + // Step 2: Satisfaction-testing components (triangles) and communication edges. + // For each clause j, triangle vertices are at indices 2*n + 3*j, 2*n + 3*j + 1, 2*n + 3*j + 2. + for (j, clause) in self.clauses().iter().enumerate() { + let base = 2 * n + 3 * j; + + // Triangle edges within clause j + edges.push((base, base + 1)); + edges.push((base + 1, base + 2)); + edges.push((base, base + 2)); + + // Communication edges: connect triangle vertex k to the literal vertex + for k in 0..3 { + if clause.literals.is_empty() { + // All three clause vertices must be selected, exceeding + // the two-per-clause bound for an empty (false) clause. + edges.push((base + k, base + k)); + continue; + } + // Repeating a literal pads a short clause without changing it. + let lit = clause.literals[k % clause.literals.len()]; + let var_idx = lit.unsigned_abs() as usize - 1; // 0-indexed variable + let literal_vertex = if lit > 0 { + 2 * var_idx // positive literal vertex + } else { + 2 * var_idx + 1 // negated literal vertex + }; + edges.push((base + k, literal_vertex)); + } + } + + let graph = SimpleGraph::new(total_vertices, edges); + let weights = vec![1i64; total_vertices]; + let target = MinimumVertexCover::new(graph, weights); Ok(Reduction3SATToDecisionMVC { - target, - base_reduction, + target: Decision::new( + target, + >>>::exact_i64( + n + 2 * m, + "computing the cover bound", + )?, + ), + source_num_vars: n, }) } } @@ -102,7 +167,15 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( source, SolutionPair { + // x1=0, x2=0, x3=1 satisfies both clauses source_config: serde_json::json!(vec![false, false, true]), + // Literal vertices: u1(0), ~u1(1), u2(2), ~u2(3), u3(4), ~u3(5) + // Clause 0 triangle: v6, v7, v8 (literals x1, x2, x3) + // Clause 1 triangle: v9, v10, v11 (literals ~x1, ~x2, x3) + // VC: from truth-setting, pick ~u1(1), ~u2(3), u3(4) + // Clause 0: u1,u2 not in cover -> pick v6,v7; u3 in cover -> v8 free + // Clause 1: ~u1,~u2,u3 all in cover -> pick any 2: v9,v10 + // Total cover size = 3 + 2 + 2 = 7 = n + 2m target_config: serde_json::json!(vec![ false, true, false, true, true, false, true, true, false, true, true, false ]), diff --git a/src/rules/ksatisfiability_minimumvertexcover.rs b/src/rules/ksatisfiability_minimumvertexcover.rs deleted file mode 100644 index db5ef064a..000000000 --- a/src/rules/ksatisfiability_minimumvertexcover.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! Reduction from KSatisfiability (3-SAT) to MinimumVertexCover. -//! -//! Classical Garey & Johnson reduction (Theorem 3.3). For each variable u_i, -//! add two vertices {u_i, not-u_i} connected by a truth-setting edge. For each -//! clause c_j, add 3 vertices forming a satisfaction-testing triangle. For each -//! literal l_k in clause c_j, add a communication edge from the triangle vertex -//! j_k to the literal vertex l_k. -//! -//! The resulting graph has a vertex cover of size n + 2m if and only if the -//! 3-SAT formula is satisfiable (n = num_vars, m = num_clauses). -//! -//! Reference: Garey & Johnson, "Computers and Intractability", 1979, Theorem 3.3 - -use crate::models::formula::KSatisfiability; -use crate::models::graph::MinimumVertexCover; -use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::topology::SimpleGraph; -use crate::variant::K3; - -/// Result of reducing KSatisfiability to MinimumVertexCover. -#[derive(Debug, Clone)] -pub struct Reduction3SATToMVC { - target: MinimumVertexCover, - source_num_vars: usize, - cover_bound: i64, -} - -impl ReductionResult for Reduction3SATToMVC { - type Source = KSatisfiability; - type Target = MinimumVertexCover; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - /// Extract a SAT assignment from a vertex cover solution. - /// - /// Vertex layout: indices 0..2n are literal vertices (even = positive, - /// odd = negated). For variable i, vertex 2*i is u_i and vertex 2*i+1 - /// is not-u_i. Each truth-setting edge forces exactly one of these two - /// into any minimum vertex cover. If u_i is in the cover, set x_i = 1; - /// if not-u_i is in the cover, set x_i = 0. - fn extract_solution( - &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } - - Ok({ - (0..self.source_num_vars) - .map(|i| { - // u_i is at index 2*i, not-u_i is at index 2*i+1 - target_solution[2 * i] - }) - .collect() - }) - } -} - -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToMVC { - type Source = KSatisfiability; - type Target = MinimumVertexCover; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.cover_bound)) - } -} - -#[reduction( - transform = exact { - num_vertices = "2 * num_vars + 3 * num_clauses", - num_edges = "num_vars + 6 * num_clauses", - } -)] -impl ReduceTo> for KSatisfiability { - type Result = Reduction3SATToMVC; - - fn reduce_to(&self) -> Result { - let n = self.num_vars(); - let m = self.num_clauses(); - let total_vertices = 2 * n + 3 * m; - let mut edges: Vec<(usize, usize)> = Vec::with_capacity(n + 6 * m); - - // Step 1: Truth-setting components. - // For each variable i, add edge (2*i, 2*i+1) connecting u_i and not-u_i. - for i in 0..n { - edges.push((2 * i, 2 * i + 1)); - } - - // Step 2: Satisfaction-testing components (triangles) and communication edges. - // For each clause j, triangle vertices are at indices 2*n + 3*j, 2*n + 3*j + 1, 2*n + 3*j + 2. - for (j, clause) in self.clauses().iter().enumerate() { - let base = 2 * n + 3 * j; - - // Triangle edges within clause j - edges.push((base, base + 1)); - edges.push((base + 1, base + 2)); - edges.push((base, base + 2)); - - // Communication edges: connect triangle vertex k to the literal vertex - for k in 0..3 { - if clause.literals.is_empty() { - // All three clause vertices must be selected, exceeding - // the two-per-clause bound for an empty (false) clause. - edges.push((base + k, base + k)); - continue; - } - // Repeating a literal pads a short clause without changing it. - let lit = clause.literals[k % clause.literals.len()]; - let var_idx = lit.unsigned_abs() as usize - 1; // 0-indexed variable - let literal_vertex = if lit > 0 { - 2 * var_idx // positive literal vertex - } else { - 2 * var_idx + 1 // negated literal vertex - }; - edges.push((base + k, literal_vertex)); - } - } - - let graph = SimpleGraph::new(total_vertices, edges); - let weights = vec![1i64; total_vertices]; - let target = MinimumVertexCover::new(graph, weights); - - Ok(Reduction3SATToMVC { - target, - source_num_vars: n, - cover_bound: >>::exact_i64( - n + 2 * m, - "computing the cover bound", - )?, - }) - } -} - -#[cfg(feature = "example-db")] -pub(crate) fn canonical_rule_example_specs() -> Vec { - use crate::export::SolutionPair; - use crate::models::formula::CNFClause; - - vec![crate::example_db::specs::RuleExampleSpec { - id: "ksatisfiability_to_minimumvertexcover", - build: || { - let source = KSatisfiability::::new( - 3, - vec![ - CNFClause::new(vec![1, 2, 3]), - CNFClause::new(vec![-1, -2, 3]), - ], - ); - crate::example_db::specs::rule_example_with_witness::< - _, - MinimumVertexCover, - >( - source, - SolutionPair { - // x1=0, x2=0, x3=1 satisfies both clauses - source_config: serde_json::json!(vec![false, false, true]), - // Literal vertices: u1(0), ~u1(1), u2(2), ~u2(3), u3(4), ~u3(5) - // Clause 0 triangle: v6, v7, v8 (literals x1, x2, x3) - // Clause 1 triangle: v9, v10, v11 (literals ~x1, ~x2, x3) - // VC: from truth-setting, pick ~u1(1), ~u2(3), u3(4) - // Clause 0: u1,u2 not in cover -> pick v6,v7; u3 in cover -> v8 free - // Clause 1: ~u1,~u2,u3 all in cover -> pick any 2: v9,v10 - // Total cover size = 3 + 2 + 2 = 7 = n + 2m - target_config: serde_json::json!(vec![ - false, true, false, true, true, false, true, true, false, true, true, false - ]), - }, - ) - }, - }] -} - -#[cfg(test)] -#[path = "../unit_tests/rules/ksatisfiability_minimumvertexcover.rs"] -mod tests; diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index d653c0ddd..8d0a76d76 100644 --- a/src/rules/ksatisfiability_qubo.rs +++ b/src/rules/ksatisfiability_qubo.rs @@ -13,6 +13,7 @@ //! CNFClause uses 1-indexed signed integers: positive = variable, negative = negated. use crate::models::algebraic::QUBO; +use crate::models::decision::Decision; use crate::models::formula::KSatisfiability; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -20,14 +21,13 @@ use crate::variant::{K2, K3}; /// Result of reducing KSatisfiability to QUBO. #[derive(Debug, Clone)] pub struct ReductionKSatToQUBO { - target: QUBO, + target: Decision>, source_num_vars: usize, - zero_penalty_energy: i64, } impl ReductionResult for ReductionKSatToQUBO { type Source = KSatisfiability; - type Target = QUBO; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -39,7 +39,7 @@ impl ReductionResult for ReductionKSatToQUBO { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "QUBO energy does not meet the SAT zero-penalty threshold", )); @@ -51,14 +51,13 @@ impl ReductionResult for ReductionKSatToQUBO { /// Result of reducing `KSatisfiability` to QUBO. #[derive(Debug, Clone)] pub struct Reduction3SATToQUBO { - target: QUBO, + target: Decision>, source_num_vars: usize, - zero_penalty_energy: i64, } impl ReductionResult for Reduction3SATToQUBO { type Source = KSatisfiability; - type Target = QUBO; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -70,7 +69,7 @@ impl ReductionResult for Reduction3SATToQUBO { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "QUBO energy does not meet the SAT zero-penalty threshold", )); @@ -329,24 +328,24 @@ fn build_qubo_matrix( #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO { type Source = KSatisfiability; - type Target = QUBO; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.zero_penalty_energy)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO { type Source = KSatisfiability; - type Target = QUBO; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.zero_penalty_energy)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -355,25 +354,30 @@ impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO { num_vars = "num_vars", } )] -impl ReduceTo> for KSatisfiability { +impl ReduceTo>> for KSatisfiability { type Result = ReductionKSatToQUBO; fn reduce_to(&self) -> Result { let n = self.num_vars(); - let (matrix, constant) = build_qubo_matrix(n, self.clauses(), 0).map_err(|operation| { - crate::rules::ReductionError::integer_overflow::, QUBO>( - operation, - ) - })?; + let (matrix, constant) = + build_qubo_matrix(n, self.clauses(), 0).map_err(|operation| { + crate::rules::ReductionError::integer_overflow::< + KSatisfiability, + Decision>, + >(operation) + })?; Ok(ReductionKSatToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { - crate::rules::ReductionError::construction::, QUBO>( - message, - ) - })?, + target: Decision::new( + QUBO::from_matrix(matrix).map_err(|message| { + crate::rules::ReductionError::construction::< + KSatisfiability, + Decision>, + >(message) + })?, + -constant, + ), source_num_vars: n, - zero_penalty_energy: -constant, }) } } @@ -383,26 +387,30 @@ impl ReduceTo> for KSatisfiability { num_vars = "num_vars + num_clauses", } )] -impl ReduceTo> for KSatisfiability { +impl ReduceTo>> for KSatisfiability { type Result = Reduction3SATToQUBO; fn reduce_to(&self) -> Result { let n = self.num_vars(); let (matrix, constant) = build_qubo_matrix(n, self.clauses(), self.num_clauses()).map_err(|operation| { - crate::rules::ReductionError::integer_overflow::, QUBO>( - operation, - ) + crate::rules::ReductionError::integer_overflow::< + KSatisfiability, + Decision>, + >(operation) })?; Ok(Reduction3SATToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { - crate::rules::ReductionError::construction::, QUBO>( - message, - ) - })?, + target: Decision::new( + QUBO::from_matrix(matrix).map_err(|message| { + crate::rules::ReductionError::construction::< + KSatisfiability, + Decision>, + >(message) + })?, + -constant, + ), source_num_vars: n, - zero_penalty_energy: -constant, }) } } @@ -426,7 +434,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( + crate::example_db::specs::rule_example_with_witness::<_, Decision>>( source, SolutionPair { source_config: serde_json::json!(vec![false, true, false, true]), @@ -450,7 +458,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( + crate::example_db::specs::rule_example_with_witness::<_, Decision>>( source, SolutionPair { source_config: serde_json::json!(vec![false, false, false, false, false]), diff --git a/src/rules/mod.rs b/src/rules/mod.rs index fea4372ce..10d115bc6 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -65,7 +65,6 @@ pub(crate) mod ksatisfiability_directedtwocommodityintegralflow; pub(crate) mod ksatisfiability_feasibleregisterassignment; pub(crate) mod ksatisfiability_kclique; pub(crate) mod ksatisfiability_kernel; -pub(crate) mod ksatisfiability_minimumvertexcover; pub(crate) mod ksatisfiability_monochromatictriangle; pub(crate) mod ksatisfiability_oneinthreesatisfiability; pub(crate) mod ksatisfiability_preemptivescheduling; @@ -352,7 +351,6 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec, + target: Decision>, source_num_vars: usize, - feasible_cut: i64, } impl ReductionResult for ReductionNAESATToMaxCut { type Source = NAESatisfiability; - type Target = MaxCut; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -44,7 +44,7 @@ impl ReductionResult for ReductionNAESATToMaxCut { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target cut does not certify a satisfying NAE assignment", )); @@ -61,14 +61,14 @@ impl ReductionResult for ReductionNAESATToMaxCut { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionNAESATToMaxCut { type Source = NAESatisfiability; - type Target = MaxCut; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Max) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.feasible_cut)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -78,9 +78,10 @@ fn nae_maxcut_parameters( lengths: impl ExactSizeIterator, ) -> Result<(usize, usize, i64, i64), crate::rules::ReductionError> { let overflow = |operation| { - crate::rules::ReductionError::integer_overflow::>( - operation, - ) + crate::rules::ReductionError::integer_overflow::< + NAESatisfiability, + Decision>, + >(operation) }; let weight = i64::try_from(lengths.len()) .ok() @@ -150,7 +151,7 @@ fn nae_maxcut_parameters( num_edges = "num_vars + 4 * num_literals - 7 * num_clauses", } )] -impl ReduceTo> for NAESatisfiability { +impl ReduceTo>> for NAESatisfiability { type Result = ReductionNAESATToMaxCut; fn reduce_to(&self) -> Result { @@ -173,7 +174,7 @@ impl ReduceTo> for NAESatisfiability { let index = usize::try_from(literal.unsigned_abs()).map_err(|_| { crate::rules::ReductionError::integer_overflow::< NAESatisfiability, - MaxCut, + Decision>, >("converting a literal index") })? - 1; // Validated literals are in 1..=n, and 2*total_variables was checked. @@ -200,9 +201,11 @@ impl ReduceTo> for NAESatisfiability { } Ok(ReductionNAESATToMaxCut { - target: MaxCut::new(SimpleGraph::new(total_vertices, edges), weights), + target: Decision::new( + MaxCut::new(SimpleGraph::new(total_vertices, edges), weights), + feasible_cut, + ), source_num_vars: self.num_vars(), - feasible_cut, }) } } @@ -226,7 +229,10 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( + crate::example_db::specs::rule_example_with_witness::< + _, + Decision>, + >( source, SolutionPair { // x1=T(1), x2=F(0), x3=T(1) diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index e584876f0..112030f5b 100644 --- a/src/rules/partition_openshopscheduling.rs +++ b/src/rules/partition_openshopscheduling.rs @@ -1,18 +1,18 @@ //! Reduction from Partition to Open Shop Scheduling. +use crate::models::decision::Decision; use crate::models::misc::{OpenShopScheduling, Partition}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; #[derive(Debug, Clone)] pub struct ReductionPartitionToOpenShopScheduling { - target: OpenShopScheduling, - feasible_makespan: i64, + target: Decision, } impl ReductionResult for ReductionPartitionToOpenShopScheduling { type Source = Partition; - type Target = OpenShopScheduling; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target @@ -24,16 +24,16 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target schedule does not certify a balanced partition", )); } Ok({ - let num_elements = self.target.num_jobs() - 1; + let num_elements = self.target.inner().num_jobs() - 1; let mut source_config = vec![false; num_elements]; - let m = self.target.num_machines(); + let m = self.target.inner().num_machines(); let start_times = target_solution .chunks_exact(m) .map(|times| { @@ -50,7 +50,7 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { }) .collect::, _>>()?; let special_job = num_elements; - let half_sum = self.target.processing_times()[special_job][0]; + let half_sum = self.target.inner().processing_times()[special_job][0]; // Find the middle machine where the special job starts at half_sum let middle_machine = (0..m) @@ -64,7 +64,7 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { for (job, slot) in source_config.iter_mut().enumerate() { let completion = start_times[job][middle_machine] - .checked_add(self.target.processing_times()[job][middle_machine]) + .checked_add(self.target.inner().processing_times()[job][middle_machine]) .ok_or_else(|| { crate::rules::ExtractionError::invalid("target schedule time overflows i64") })?; @@ -81,14 +81,14 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopScheduling { type Source = Partition; - type Target = OpenShopScheduling; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.feasible_makespan)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -101,7 +101,7 @@ impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopSche schedule_horizon = "depends on the numeric partition sizes, which are not represented by source size parameters", } )] -impl ReduceTo for Partition { +impl ReduceTo> for Partition { type Result = ReductionPartitionToOpenShopScheduling; fn reduce_to(&self) -> Result { @@ -111,12 +111,11 @@ impl ReduceTo for Partition { processing_times.push(vec![half_sum; 3]); let target = OpenShopScheduling::try_new(3, processing_times) - .map_err(>::target_construction)?; + .map_err(>>::target_construction)?; // The validated nonnegative schedule horizon includes these three terms. let feasible_makespan = 3 * half_sum; Ok(ReductionPartitionToOpenShopScheduling { - target, - feasible_makespan, + target: Decision::new(target, feasible_makespan), }) } } @@ -128,7 +127,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( + crate::example_db::specs::rule_example_with_witness::<_, Decision>( Partition::new(vec![1, 2, 3]).unwrap(), SolutionPair { source_config: serde_json::json!(vec![true, true, false]), diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index 913173f36..c7c032867 100644 --- a/src/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/rules/partition_sequencingtominimizetardytaskweight.rs @@ -1,5 +1,6 @@ //! Reduction from Partition to Sequencing to Minimize Tardy Task Weight. +use crate::models::decision::Decision; use crate::models::misc::{Partition, SequencingToMinimizeTardyTaskWeight}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -7,12 +8,12 @@ use crate::rules::traits::{ReduceTo, ReductionResult}; /// Result of reducing Partition to SequencingToMinimizeTardyTaskWeight. #[derive(Debug, Clone)] pub struct ReductionPartitionToSequencingToMinimizeTardyTaskWeight { - target: SequencingToMinimizeTardyTaskWeight, + target: Decision, } impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { type Source = Partition; - type Target = SequencingToMinimizeTardyTaskWeight; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target @@ -24,25 +25,25 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target schedule does not certify a balanced partition", )); } Ok({ - let mut source_config = vec![true; self.target.num_tasks()]; + let mut source_config = vec![true; self.target.inner().num_tasks()]; let mut completion_time = 0i64; for &task in target_solution { completion_time = completion_time - .checked_add(self.target.lengths()[task]) + .checked_add(self.target.inner().lengths()[task]) .ok_or_else(|| { crate::rules::ExtractionError::invalid( "target schedule completion time overflows i64", ) })?; - if completion_time <= self.target.deadlines()[task] { + if completion_time <= self.target.inner().deadlines()[task] { source_config[task] = false; } } @@ -57,15 +58,14 @@ impl crate::rules::AggregateReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { type Source = Partition; - type Target = SequencingToMinimizeTardyTaskWeight; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - // The source is nonempty, so the common deadline always exists. - crate::types::Or(value.0 == Some(self.target.deadlines()[0])) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -73,7 +73,7 @@ impl crate::rules::AggregateReductionResult transform = exact { num_tasks = "num_elements", })] -impl ReduceTo for Partition { +impl ReduceTo> for Partition { type Result = ReductionPartitionToSequencingToMinimizeTardyTaskWeight; fn reduce_to(&self) -> Result { @@ -83,7 +83,10 @@ impl ReduceTo for Partition { let deadlines = vec![common_deadline; self.num_elements()]; Ok(ReductionPartitionToSequencingToMinimizeTardyTaskWeight { - target: SequencingToMinimizeTardyTaskWeight::new(lengths, weights, deadlines), + target: Decision::new( + SequencingToMinimizeTardyTaskWeight::new(lengths, weights, deadlines), + common_deadline, + ), }) } } @@ -97,7 +100,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, >( Partition::new(vec![3, 1, 1, 2, 2, 1]).unwrap(), SolutionPair { diff --git a/src/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/rules/partitionintocliques_minimumcoveringbycliques.rs index 155c3a5a4..85e36e54b 100644 --- a/src/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -8,11 +8,11 @@ //! where q counts distinct directed non-loop adjacencies. Each side includes //! a private vertex, so its forced clique exists even for an empty source. +use crate::models::decision::Decision; use crate::models::graph::{MinimumCoveringByCliques, PartitionIntoCliques}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; -use crate::types::{Min, OptimizationValue, Or}; use std::collections::BTreeMap; #[derive(Debug, Clone)] @@ -87,7 +87,7 @@ impl OrlinLayout { let overflow = |operation: &str| { crate::rules::ReductionError::integer_overflow::< PartitionIntoCliques, - MinimumCoveringByCliques, + Decision>, >(operation) }; // The two sides each have n+q+1 vertices, including their private @@ -106,10 +106,9 @@ impl OrlinLayout { .and_then(|s| s.checked_add(n)) .and_then(|s| q.checked_mul(4).and_then(|cross| s.checked_add(cross))) .ok_or_else(|| overflow("counting target edges"))?; - as ReduceTo>>::exact_i64( - target_edges, - "representing every target cover value", - )?; + as ReduceTo< + Decision>, + >>::exact_i64(target_edges, "representing every target cover value")?; Ok((target_vertices, target_edges)) } } @@ -132,7 +131,7 @@ fn target_clique_bound( .ok_or_else(|| { crate::rules::ReductionError::integer_overflow::< PartitionIntoCliques, - MinimumCoveringByCliques, + Decision>, >("computing target clique bound") }) } @@ -140,15 +139,13 @@ fn target_clique_bound( /// Result of reducing PartitionIntoCliques to MinimumCoveringByCliques. #[derive(Debug, Clone)] pub struct ReductionPartitionIntoCliquesToMinimumCoveringByCliques { - target: MinimumCoveringByCliques, + target: Decision>, num_source_vertices: usize, - source_num_cliques: usize, - target_bound: i64, } impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques { type Source = PartitionIntoCliques; - type Target = MinimumCoveringByCliques; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -160,7 +157,7 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !Min::meets_bound(&value, &self.target_bound) { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target cover does not certify the source clique bound", )); @@ -168,7 +165,7 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques Ok({ let n = self.num_source_vertices; - let target_edges = self.target.graph().edges(); + let target_edges = self.target.inner().graph().edges(); let mut matching_labels = vec![None; n]; for ((u, v), &label) in target_edges.iter().zip(target_solution.iter()) { let matching_index = if *u < n && *v == n + *u { @@ -198,14 +195,6 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques }) .collect::>>()?; - if label_map.len() > self.source_num_cliques { - return Err(crate::rules::ExtractionError::invalid(format!( - "target cover uses {} cliques, exceeding source bound {}", - label_map.len(), - self.source_num_cliques - ))); - } - // Equal matching-edge labels imply pairwise source adjacency. // The target certificate leaves at most K labels for these edges. extracted @@ -218,14 +207,14 @@ impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques { type Source = PartitionIntoCliques; - type Target = MinimumCoveringByCliques; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Min) -> Or { - Or(Min::meets_bound(&target_value, &self.target_bound)) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -235,7 +224,9 @@ impl crate::rules::AggregateReductionResult num_edges = "(num_vertices + 2 * num_edges)^2 + 4 * num_vertices + 14 * num_edges + 2", } )] -impl ReduceTo> for PartitionIntoCliques { +impl ReduceTo>> + for PartitionIntoCliques +{ type Result = ReductionPartitionIntoCliquesToMinimumCoveringByCliques; fn reduce_to(&self) -> Result { @@ -243,14 +234,16 @@ impl ReduceTo> for PartitionIntoCliques>>::exact_i64( - self.num_cliques().min(n), - "converting effective clique bound", - )?; - let directed_pairs = >>::exact_i64( - q, - "converting gadget count", - )?; + let source_bound = + >>>::exact_i64( + self.num_cliques().min(n), + "converting effective clique bound", + )?; + let directed_pairs = + >>>::exact_i64( + q, + "converting gadget count", + )?; let target_bound = target_clique_bound(source_bound, directed_pairs)?; let left_vertices = layout.left_vertices(); let right_vertices = layout.right_vertices(); @@ -285,10 +278,8 @@ impl ReduceTo> for PartitionIntoCliques Vec>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let layout = OrlinLayout::new(source.graph()); let target_config = edge_labels_from_clique_cover( - reduction.target_problem().graph(), + reduction.target_problem().inner().graph(), &[ vec![layout.x(0), layout.x(1), layout.y(0), layout.y(1)], vec![layout.x(2), layout.y(2)], @@ -351,7 +343,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( source, SolutionPair { diff --git a/src/rules/sat_maximumindependentset.rs b/src/rules/sat_maximumindependentset.rs index dd539cf7c..da7fc262d 100644 --- a/src/rules/sat_maximumindependentset.rs +++ b/src/rules/sat_maximumindependentset.rs @@ -8,12 +8,13 @@ //! A satisfying assignment corresponds to an independent set of size = num_clauses, //! where we pick exactly one literal from each clause. +use crate::models::decision::Decision; use crate::models::formula::Satisfiability; use crate::models::graph::MaximumIndependentSet; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::SimpleGraph; -use crate::types::{Max, One, Or}; +use crate::types::One; /// A literal in the SAT problem, representing a variable or its negation. #[derive(Debug, Clone, PartialEq, Eq)] @@ -54,20 +55,18 @@ impl BoolVar { #[derive(Debug, Clone)] pub struct ReductionSATToIS { /// The target MaximumIndependentSet problem. - target: MaximumIndependentSet, + target: Decision>, /// Mapping from vertex index to the literal it represents. literals: Vec, /// The number of variables in the source SAT problem. num_source_variables: usize, /// The number of clauses in the source SAT problem. num_clauses: usize, - /// Exact independent-set cardinality certifying satisfiability. - target_size: i64, } impl ReductionResult for ReductionSATToIS { type Source = Satisfiability; - type Target = MaximumIndependentSet; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -84,8 +83,7 @@ impl ReductionResult for ReductionSATToIS { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target independent set does not certify satisfiability", )); @@ -104,14 +102,14 @@ impl ReductionResult for ReductionSATToIS { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSATToIS { type Source = Satisfiability; - type Target = MaximumIndependentSet; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Max) -> Or { - Or(target_value == Max(Some(self.target_size))) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -133,14 +131,15 @@ impl ReductionSATToIS { num_edges = "num_literals^2", } )] -impl ReduceTo> for Satisfiability { +impl ReduceTo>> for Satisfiability { type Result = ReductionSATToIS; fn reduce_to(&self) -> Result { - let target_size = >>::exact_i64( - self.num_clauses(), - "representing the satisfying independent-set cardinality", - )?; + let target_size = + >>>::exact_i64( + self.num_clauses(), + "representing the satisfying independent-set cardinality", + )?; let mut literals: Vec = Vec::new(); let mut edges: Vec<(usize, usize)> = Vec::new(); @@ -180,11 +179,10 @@ impl ReduceTo> for Satisfiability { ); Ok(ReductionSATToIS { - target, + target: Decision::new(target, target_size), literals, num_source_variables: self.num_vars(), num_clauses: self.num_clauses(), - target_size, }) } } @@ -214,7 +212,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( sat_seven_clause_example(), SolutionPair { diff --git a/src/rules/sat_minimumdominatingset.rs b/src/rules/sat_minimumdominatingset.rs index 21074ea36..8083a1514 100644 --- a/src/rules/sat_minimumdominatingset.rs +++ b/src/rules/sat_minimumdominatingset.rs @@ -14,13 +14,13 @@ //! - Selecting the negative literal vertex means the variable is false //! - Selecting the dummy vertex means the variable may be assigned either value +use crate::models::decision::Decision; use crate::models::formula::Satisfiability; use crate::models::graph::MinimumDominatingSet; use crate::reduction; use crate::rules::sat_maximumindependentset::BoolVar; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::SimpleGraph; -use crate::types::{Min, Or}; use std::collections::BTreeMap; /// Result of reducing Satisfiability to MinimumDominatingSet. @@ -32,20 +32,18 @@ use std::collections::BTreeMap; #[derive(Debug, Clone)] pub struct ReductionSATToDS { /// The target MinimumDominatingSet problem. - target: MinimumDominatingSet, + target: Decision>, /// The number of variables in the source SAT problem. num_literals: usize, /// The number of clauses in the source SAT problem. num_clauses: usize, /// Original variable indices mapped to dense triangle indices. variables: BTreeMap, - /// Exact minimum size certifying satisfiability. - target_size: i64, } impl ReductionResult for ReductionSATToDS { type Source = Satisfiability; - type Target = MinimumDominatingSet; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -64,8 +62,7 @@ impl ReductionResult for ReductionSATToDS { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target dominating set does not certify satisfiability", )); @@ -83,14 +80,14 @@ impl ReductionResult for ReductionSATToDS { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSATToDS { type Source = Satisfiability; - type Target = MinimumDominatingSet; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Min) -> Or { - Or(target_value == Min(Some(self.target_size))) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -106,20 +103,21 @@ impl ReductionSATToDS { .ok_or_else(|| { crate::rules::ReductionError::integer_overflow::< Satisfiability, - MinimumDominatingSet, + Decision>, >("counting dominating-set vertices") })?; // All vertices may be selected, so every count up to this total must // fit the target objective, not only the optimum certificate. - >>::exact_i64( + >>>::exact_i64( num_vertices, "representing all dominating-set weights", )?; - let target_size = - >>::exact_i64( - num_variables, - "representing the satisfying dominating-set cardinality", - )?; + let target_size = >, + >>::exact_i64( + num_variables, + "representing the satisfying dominating-set cardinality", + )?; Ok((num_vertices, target_size)) } @@ -140,7 +138,7 @@ impl ReductionSATToDS { num_edges = "3 * num_vars + num_literals", } )] -impl ReduceTo> for Satisfiability { +impl ReduceTo>> for Satisfiability { type Result = ReductionSATToDS; fn reduce_to(&self) -> Result { @@ -196,11 +194,10 @@ impl ReduceTo> for Satisfiability { ); Ok(ReductionSATToDS { - target, + target: Decision::new(target, target_size), num_literals: self.num_vars(), num_clauses, variables, - target_size, }) } } @@ -227,7 +224,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( source, SolutionPair { diff --git a/src/rules/satisfiability_maximum2satisfiability.rs b/src/rules/satisfiability_maximum2satisfiability.rs index 79c94b577..f11f2557c 100644 --- a/src/rules/satisfiability_maximum2satisfiability.rs +++ b/src/rules/satisfiability_maximum2satisfiability.rs @@ -1,22 +1,21 @@ //! Reduction from Satisfiability to Maximum 2-Satisfiability. +use crate::models::decision::Decision; use crate::models::formula::{CNFClause, Maximum2Satisfiability, Satisfiability}; use crate::reduction; use crate::rules::sat_helpers::SatVariableAllocator; use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::types::{Max, Or}; /// Result of reducing SAT to MAX-2-SAT. #[derive(Debug, Clone)] pub struct ReductionSatisfiabilityToMaximum2Satisfiability { - target: Maximum2Satisfiability, + target: Decision, source_num_vars: usize, - target_score: i64, } impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { type Source = Satisfiability; - type Target = Maximum2Satisfiability; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target @@ -28,8 +27,7 @@ impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target assignment does not certify satisfiability", )); @@ -42,14 +40,14 @@ impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { type Source = Satisfiability; - type Target = Maximum2Satisfiability; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, value: Max) -> Or { - Or(value == Max(Some(self.target_score))) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -127,7 +125,7 @@ fn add_gjs_gadget(clause: &CNFClause, w: i64, target_clauses: &mut Vec for Satisfiability { +impl ReduceTo> for Satisfiability { type Result = ReductionSatisfiabilityToMaximum2Satisfiability; fn reduce_to(&self) -> Result { @@ -137,7 +135,7 @@ impl ReduceTo for Satisfiability { .map_err( crate::rules::ReductionError::construction::< Satisfiability, - Maximum2Satisfiability, + Decision, >, )?; @@ -145,19 +143,18 @@ impl ReduceTo for Satisfiability { add_normalized_clause(clause, &mut variables, &mut normalized).map_err( crate::rules::ReductionError::construction::< Satisfiability, - Maximum2Satisfiability, + Decision, >, )?; } - let capacity = - normalized.len().checked_mul(10).ok_or_else(|| { - crate::rules::ReductionError::integer_overflow::< - Satisfiability, - Maximum2Satisfiability, - >("computing the target clause count") - })?; - let clause_count = >::exact_i64( + let capacity = normalized.len().checked_mul(10).ok_or_else(|| { + crate::rules::ReductionError::integer_overflow::< + Satisfiability, + Decision, + >("computing the target clause count") + })?; + let clause_count = >>::exact_i64( capacity, "representing every satisfied-clause count", )?; @@ -167,23 +164,21 @@ impl ReduceTo for Satisfiability { let target_score = (clause_count / 10) * 7; let mut target_clauses = Vec::with_capacity(capacity); for clause in &normalized { - let w = - variables.allocate().map_err( - crate::rules::ReductionError::construction::< - Satisfiability, - Maximum2Satisfiability, - >, - )?; + let w = variables.allocate().map_err( + crate::rules::ReductionError::construction::< + Satisfiability, + Decision, + >, + )?; add_gjs_gadget(clause, w, &mut target_clauses); } let target = Maximum2Satisfiability::try_new(variables.num_vars(), target_clauses) - .map_err(>::target_construction)?; + .map_err(>>::target_construction)?; Ok(ReductionSatisfiabilityToMaximum2Satisfiability { - target, + target: Decision::new(target, target_score), source_num_vars: self.num_vars(), - target_score, }) } } @@ -199,7 +194,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( + crate::example_db::specs::rule_example_with_witness::<_, Decision>( source, SolutionPair { source_config: serde_json::json!(vec![true, true, true]), diff --git a/src/rules/subsetsum_closestvectorproblem.rs b/src/rules/subsetsum_closestvectorproblem.rs index c8e89c08f..79f13cd1c 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -1,22 +1,21 @@ //! Reduction from Subset Sum to CVP using binary carry equations. use crate::models::algebraic::ClosestVectorProblem; +use crate::models::decision::Decision; use crate::models::misc::SubsetSum; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::types::{Min, Or}; /// Result of reducing SubsetSum to ClosestVectorProblem. #[derive(Debug, Clone)] pub struct ReductionSubsetSumToClosestVectorProblem { - target: ClosestVectorProblem, + target: Decision, num_elements: usize, - target_squared_distance: i64, } impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { type Source = SubsetSum; - type Target = ClosestVectorProblem; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target @@ -28,8 +27,7 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { ) -> crate::rules::ExtractionResult<::Solution> { let value = crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.0 { + if !value.0 { return Err(crate::rules::ExtractionError::invalid( "target lattice vector does not certify a subset sum", )); @@ -44,14 +42,14 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionSubsetSumToClosestVectorProblem { type Source = SubsetSum; - type Target = ClosestVectorProblem; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Min) -> Or { - Or(target_value == Min(Some(self.target_squared_distance))) + fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { + value } } @@ -62,9 +60,10 @@ impl ReductionSubsetSumToClosestVectorProblem { bit_width: u64, ) -> Result<(usize, usize, usize), crate::rules::ReductionError> { let overflow = || { - crate::rules::ReductionError::integer_overflow::( - "sizing the binary-carry lattice", - ) + crate::rules::ReductionError::integer_overflow::< + SubsetSum, + Decision, + >("sizing the binary-carry lattice") }; let bits = usize::try_from(bit_width).map_err(|_| overflow())?; let carries = bits.checked_sub(1).ok_or_else(overflow)?; @@ -86,7 +85,7 @@ impl ReductionSubsetSumToClosestVectorProblem { num_basis_vectors = "n+b-1 depends on input bit length b, which is not a registered SubsetSum parameter", }, )] -impl ReduceTo for SubsetSum { +impl ReduceTo> for SubsetSum { type Result = ReductionSubsetSumToClosestVectorProblem; fn reduce_to(&self) -> Result { @@ -124,16 +123,16 @@ impl ReduceTo for SubsetSum { for bit in 0..bits { target[rows - 1 - bit] = i64::from(self.target().bit(bit as u64)); } - let target_squared_distance = >::exact_i64( - n, - "representing the subset-sum squared-distance threshold", - )?; + let target_squared_distance = + >>::exact_i64( + n, + "representing the subset-sum squared-distance threshold", + )?; let target = ClosestVectorProblem::new(basis, target) - .map_err(>::target_construction)?; + .map_err(>>::target_construction)?; Ok(ReductionSubsetSumToClosestVectorProblem { - target, + target: Decision::new(target, target_squared_distance), num_elements: n, - target_squared_distance, }) } } @@ -145,7 +144,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( + crate::example_db::specs::rule_example_with_witness::<_, Decision>( SubsetSum::new(vec![3u32, 7, 1, 8], 11u32), SolutionPair { source_config: serde_json::json!(vec![true, false, false, true]), diff --git a/src/solvers/customized/solver.rs b/src/solvers/customized/solver.rs index 480ff20e9..6d8bc66db 100644 --- a/src/solvers/customized/solver.rs +++ b/src/solvers/customized/solver.rs @@ -98,6 +98,17 @@ register_customized_solver!( |problem| super::closest_vector_problem::solve(problem).map(Some) ); +register_customized_solver!( + crate::models::decision::Decision, + "cvp-sphere-enumeration", + |problem: &crate::models::decision::Decision< + crate::models::algebraic::ClosestVectorProblem, + >| { + let solution = super::closest_vector_problem::solve(problem.inner())?; + Ok(problem.evaluate(&solution)?.0.then_some(solution)) + } +); + register_customized_solver!( crate::models::graph::KColoring, "bipartite-coloring", diff --git a/src/solvers/pipelines.rs b/src/solvers/pipelines.rs index d9674cd78..d69fceb77 100644 --- a/src/solvers/pipelines.rs +++ b/src/solvers/pipelines.rs @@ -125,6 +125,7 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("DecisionMinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } @@ -205,6 +206,7 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("HamiltonianCircuit", [("graph", "SimpleGraph")]), + ("DecisionLongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 2a78f8f23..da098cd5b 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -973,7 +973,7 @@ fn test_find_rule_example_ksatisfiability_to_minimumvertexcover() { variant: BTreeMap::from([("k".to_string(), "K3".to_string())]), }; let target = ProblemRef { - name: "MinimumVertexCover".to_string(), + name: "DecisionMinimumVertexCover".to_string(), variant: BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "i64".to_string()), @@ -981,7 +981,7 @@ fn test_find_rule_example_ksatisfiability_to_minimumvertexcover() { }; let example = find_rule_example(&source, &target).unwrap(); assert_eq!(example.source.problem, "KSatisfiability"); - assert_eq!(example.target.problem, "MinimumVertexCover"); + assert_eq!(example.target.problem, "DecisionMinimumVertexCover"); } #[test] @@ -1057,12 +1057,12 @@ fn test_find_rule_example_hamiltoniancircuit_to_stackercrane() { variant: BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), }; let target = ProblemRef { - name: "StackerCrane".to_string(), + name: "DecisionStackerCrane".to_string(), variant: BTreeMap::new(), }; let example = find_rule_example(&source, &target).unwrap(); assert_eq!(example.source.problem, "HamiltonianCircuit"); - assert_eq!(example.target.problem, "StackerCrane"); + assert_eq!(example.target.problem, "DecisionStackerCrane"); } #[test] @@ -1072,7 +1072,7 @@ fn test_find_rule_example_hamiltoniancircuit_to_ruralpostman() { variant: BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), }; let target = ProblemRef { - name: "RuralPostman".to_string(), + name: "DecisionRuralPostman".to_string(), variant: BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "i64".to_string()), @@ -1080,7 +1080,7 @@ fn test_find_rule_example_hamiltoniancircuit_to_ruralpostman() { }; let example = find_rule_example(&source, &target).unwrap(); assert_eq!(example.source.problem, "HamiltonianCircuit"); - assert_eq!(example.target.problem, "RuralPostman"); + assert_eq!(example.target.problem, "DecisionRuralPostman"); } #[test] @@ -1108,12 +1108,12 @@ fn test_find_rule_example_hamiltoniancircuit_to_quadraticassignment() { variant: BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), }; let target = ProblemRef { - name: "QuadraticAssignment".to_string(), + name: "DecisionQuadraticAssignment".to_string(), variant: BTreeMap::new(), }; let example = find_rule_example(&source, &target).unwrap(); assert_eq!(example.source.problem, "HamiltonianCircuit"); - assert_eq!(example.target.problem, "QuadraticAssignment"); + assert_eq!(example.target.problem, "DecisionQuadraticAssignment"); } // PR #804 rules @@ -1179,7 +1179,7 @@ fn test_find_rule_example_hamiltoniancircuit_to_longestcircuit() { variant: BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), }; let target = ProblemRef { - name: "LongestCircuit".to_string(), + name: "DecisionLongestCircuit".to_string(), variant: BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "i64".to_string()), @@ -1187,7 +1187,7 @@ fn test_find_rule_example_hamiltoniancircuit_to_longestcircuit() { }; let example = find_rule_example(&source, &target).unwrap(); assert_eq!(example.source.problem, "HamiltonianCircuit"); - assert_eq!(example.target.problem, "LongestCircuit"); + assert_eq!(example.target.problem, "DecisionLongestCircuit"); } #[test] @@ -1343,7 +1343,7 @@ fn test_find_rule_example_naesatisfiability_to_maxcut() { variant: BTreeMap::new(), }; let target = ProblemRef { - name: "MaxCut".to_string(), + name: "DecisionMaxCut".to_string(), variant: BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "i64".to_string()), @@ -1351,7 +1351,7 @@ fn test_find_rule_example_naesatisfiability_to_maxcut() { }; let example = find_rule_example(&source, &target).unwrap(); assert_eq!(example.source.problem, "NAESatisfiability"); - assert_eq!(example.target.problem, "MaxCut"); + assert_eq!(example.target.problem, "DecisionMaxCut"); } #[test] diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 8d162aff6..c503cf423 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -56,7 +56,7 @@ fn symbolic_composition_propagates_num_colors_across_multiple_edges() { variant: ReductionGraph::variant_to_map(&KColoring::::variant()), }, ReductionStep { - name: QUBO::::NAME.to_string(), + name: Decision::>::NAME.to_string(), variant: ReductionGraph::variant_to_map(&QUBO::::variant()), }, ], @@ -151,7 +151,7 @@ fn test_reduction_graph_discovers_registered_reductions() { // Specific reductions should exist assert!(graph.has_direct_reduction_by_name("MaximumIndependentSet", "MinimumVertexCover")); assert!(graph.has_direct_reduction_by_name("MaxCut", "SpinGlass")); - assert!(graph.has_direct_reduction_by_name("Satisfiability", "MaximumIndependentSet")); + assert!(graph.has_direct_reduction_by_name("Satisfiability", "DecisionMaximumIndependentSet")); } #[test] @@ -197,12 +197,14 @@ fn test_multi_step_path() { let path = graph .find_all_paths("Factoring", &src, "SpinGlass", &dst) .into_iter() - .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .find(|path| { + path.type_names() == ["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"] + }) .expect("explicit CircuitSAT route should exist"); - assert_eq!(path.len(), 2, "Should be a 2-step path"); + assert_eq!(path.len(), 3, "Should include the explicit decision target"); assert_eq!( path.type_names(), - vec!["Factoring", "CircuitSAT", "SpinGlass"] + vec!["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"] ); } @@ -416,7 +418,9 @@ fn test_reduction_path_display() { let path = graph .find_all_paths("Factoring", &src_var, "SpinGlass", &dst_var) .into_iter() - .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .find(|path| { + path.type_names() == ["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"] + }) .expect("explicit CircuitSAT route"); let s = format!("{path}"); @@ -900,15 +904,15 @@ fn test_decision_minimum_dominating_set_to_minmax_multicenter_has_direct_witness assert!(graph.has_direct_reduction_mode::< Decision>, - MinMaxMulticenter, + Decision>, >(ReductionMode::Witness)); assert!(graph.has_direct_reduction_mode::< Decision>, - MinMaxMulticenter, + Decision>, >(ReductionMode::Aggregate)); assert!(!graph.has_direct_reduction_mode::< Decision>, - MinMaxMulticenter, + Decision>, >(ReductionMode::Turing)); let entries = crate::rules::registry::reduction_entries(); let variant = Decision::>::variant(); @@ -916,7 +920,7 @@ fn test_decision_minimum_dominating_set_to_minmax_multicenter_has_direct_witness .iter() .find(|e| { e.source_name == "DecisionMinimumDominatingSet" - && e.target_name == "MinMaxMulticenter" + && e.target_name == "DecisionMinMaxMulticenter" && (e.source_variant_fn)() == variant && (e.target_variant_fn)() == variant }) @@ -944,15 +948,15 @@ fn test_decision_minimum_dominating_set_to_minimum_sum_multicenter_has_direct_wi assert!(graph.has_direct_reduction_mode::< Decision>, - MinimumSumMulticenter, + Decision>, >(ReductionMode::Witness)); assert!(graph.has_direct_reduction_mode::< Decision>, - MinimumSumMulticenter, + Decision>, >(ReductionMode::Aggregate)); assert!(!graph.has_direct_reduction_mode::< Decision>, - MinimumSumMulticenter, + Decision>, >(ReductionMode::Turing)); } diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index a1d9c09f0..5b5504ce3 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -438,6 +438,10 @@ fn unit_variants_construct_without_unit_inputs() { graph => panic!("missing construction case for {graph}"), }, "DecisionMaximumIndependentSet" => json!({"graph":[[0,1],[1,2]],"bound":2}), + "DecisionLongestPath" => { + json!({"graph":[[0,1],[1,2]],"source_vertex":0,"target_vertex":2,"bound":2}) + } + "DecisionMinMaxMulticenter" => json!({"graph":[[0,1],[1,2]],"k":1,"bound":1}), "DecisionMinimumDominatingSet" => json!({"graph":graph,"bound":1}), "MaxCut" => json!({"graph":[[0,1],[1,2]]}), "LongestPath" => json!({"graph":[[0,1],[1,2]],"source_vertex":0,"target_vertex":2}), diff --git a/src/unit_tests/rules/aggregate_contracts.rs b/src/unit_tests/rules/aggregate_contracts.rs index bddf431ab..8339a9e24 100644 --- a/src/unit_tests/rules/aggregate_contracts.rs +++ b/src/unit_tests/rules/aggregate_contracts.rs @@ -34,9 +34,9 @@ fn decision_graph_encodings_preserve_small_and_native_graph_cases() { check_decision::<_, StrongConnectivityAugmentation>(&HamiltonianCircuit::new( graph.clone(), )); - check_decision::<_, RuralPostman>(&HamiltonianCircuit::new( - graph.clone(), - )); + check_decision::<_, crate::models::decision::Decision>>( + &HamiltonianCircuit::new(graph.clone()), + ); check_decision::<_, Clustering>(&KColoring::::new(graph.clone())); check_decision::<_, PartitionIntoCliques>(&KColoring::::with_k( graph.clone(), @@ -243,7 +243,9 @@ fn sat_cover_threshold_supports_short_and_empty_clauses() { 1, clauses.into_iter().map(CNFClause::new).collect(), ); - check_decision::<_, MinimumVertexCover>(&source); + check_decision::<_, crate::models::decision::Decision>>( + &source, + ); check_decision::<_, crate::models::graph::KClique>(&source); check_decision::<_, crate::models::graph::Kernel>(&source); check_decision::<_, crate::models::misc::SubsetSum>(&source); diff --git a/src/unit_tests/rules/circuit_spinglass.rs b/src/unit_tests/rules/circuit_spinglass.rs index d44aaec9d..02f8dde17 100644 --- a/src/unit_tests/rules/circuit_spinglass.rs +++ b/src/unit_tests/rules/circuit_spinglass.rs @@ -1,6 +1,6 @@ use super::*; use crate::models::formula::Circuit; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; use crate::traits::Problem; use crate::types::{NumericSize, WeightElement}; @@ -147,9 +147,12 @@ fn test_constant_true() { BooleanExpr::constant(true), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = + ReduceTo::>>::reduce_to( + &problem, + ) .expect("reduction should succeed"); - let sg = reduction.target_problem(); + let sg = reduction.target_problem().inner(); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(sg).unwrap(); @@ -175,9 +178,12 @@ fn test_constant_false() { BooleanExpr::constant(false), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = + ReduceTo::>>::reduce_to( + &problem, + ) .expect("reduction should succeed"); - let sg = reduction.target_problem(); + let sg = reduction.target_problem().inner(); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(sg).unwrap(); @@ -207,9 +213,12 @@ fn test_multi_input_and() { ]), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = + ReduceTo::>>::reduce_to( + &problem, + ) .expect("reduction should succeed"); - let sg = reduction.target_problem(); + let sg = reduction.target_problem().inner(); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(sg).unwrap(); @@ -241,11 +250,14 @@ fn test_reduction_result_methods() { BooleanExpr::var("x"), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = + ReduceTo::>>::reduce_to( + &problem, + ) .expect("reduction should succeed"); // Test target_problem and extract_solution work - let sg = reduction.target_problem(); + let sg = reduction.target_problem().inner(); assert!(sg.num_spins() >= 2); // At least c and x } @@ -253,9 +265,12 @@ fn test_reduction_result_methods() { fn test_empty_circuit() { let circuit = Circuit::new(vec![]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = + ReduceTo::>>::reduce_to( + &problem, + ) .expect("reduction should succeed"); - let sg = reduction.target_problem(); + let sg = reduction.target_problem().inner(); // Empty circuit should result in empty SpinGlass assert_eq!(sg.num_spins(), 0); @@ -268,7 +283,10 @@ fn test_solution_extraction() { BooleanExpr::and(vec![BooleanExpr::var("x"), BooleanExpr::var("y")]), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = + ReduceTo::>>::reduce_to( + &problem, + ) .expect("reduction should succeed"); // The source variables are c, x, y (sorted) @@ -276,7 +294,7 @@ fn test_solution_extraction() { // Test extraction with a mock target solution // Need to know the mapping to construct proper test - let sg = reduction.target_problem(); + let sg = reduction.target_problem().inner(); assert!(sg.num_spins() >= 3); // At least c, x, y } @@ -299,9 +317,12 @@ fn test_jl_parity_circuitsat_to_spinglass() { Assignment::new(vec!["z".to_string()], z_expr), ]); let source = CircuitSAT::new(circuit); - let result = ReduceTo::>::reduce_to(&source) + let result = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, "CircuitSAT->SpinGlass parity", @@ -340,8 +361,11 @@ fn test_circuit_spinglass_all_threshold_witnesses_native_domain() { vec![output.into()], expr.clone(), )])); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let target = AggregateReductionResult::target_problem(&reduction); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = AggregateReductionResult::target_problem(&reduction).inner(); let expected: BTreeSet<_> = BruteForce::new() .find_all_witnesses(&source) .unwrap() @@ -353,8 +377,16 @@ fn test_circuit_spinglass_all_threshold_witnesses_native_domain() { .map(|i| if mask >> i & 1 == 0 { -1 } else { 1 }) .collect(); let energy = target.evaluate(&spins).unwrap(); - assert!(energy.0.unwrap() >= reduction.zero_penalty_energy); - if reduction.extract_value(energy).0 { + assert!(energy.0.unwrap() >= *reduction.target.bound()); + if reduction + .extract_value(crate::types::Or( + crate::types::OptimizationValue::meets_bound( + &(energy), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + ), + )) + .0 + { let decoded = reduction.extract_solution(&spins).unwrap(); assert!(source.evaluate(&decoded).unwrap().0); actual.insert(decoded); @@ -363,7 +395,16 @@ fn test_circuit_spinglass_all_threshold_witnesses_native_domain() { } } assert_eq!(actual, expected, "expression {expr:?}, output {output}"); - assert!(!reduction.extract_value(crate::types::Min(None)).0); + assert!( + !reduction + .extract_value(crate::types::Or( + crate::types::OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + ) + )) + .0 + ); } } } @@ -375,11 +416,24 @@ fn test_circuit_spinglass_unsat_threshold_and_invalid_spins() { vec!["x".into()], BooleanExpr::not(BooleanExpr::var("x")), )])); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - assert_eq!(reduction.zero_penalty_energy, -5); - assert!(!reduction.extract_value(crate::types::Min(Some(-3))).0); + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) + .unwrap(); + assert_eq!(*reduction.target.bound(), -5); + assert!( + !reduction + .extract_value(crate::types::Or( + crate::types::OptimizationValue::meets_bound( + &(crate::types::Min(Some(-3))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + ) + )) + .0 + ); for witness in BruteForce::new() - .find_all_witnesses(ReductionResult::target_problem(&reduction)) + .find_all_witnesses(ReductionResult::target_problem(&reduction).inner()) .unwrap() { assert!(reduction.extract_solution(&witness).is_err()); @@ -388,8 +442,21 @@ fn test_circuit_spinglass_unsat_threshold_and_invalid_spins() { assert!(reduction.extract_solution(&bad).is_err()); } let empty = CircuitSAT::new(Circuit::new(vec![])); - let reduction = ReduceTo::>::reduce_to(&empty).unwrap(); - assert!(reduction.extract_value(crate::types::Min(Some(0))).0); + let reduction = + ReduceTo::>>::reduce_to( + &empty, + ) + .unwrap(); + assert!( + reduction + .extract_value(crate::types::Or( + crate::types::OptimizationValue::meets_bound( + &(crate::types::Min(Some(0))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + ) + )) + .0 + ); assert_eq!( reduction.extract_solution(&vec![]).unwrap(), Vec::::new() @@ -420,8 +487,12 @@ fn test_circuit_spinglass_variadic_constant_overhead() { let args = vec![BooleanExpr::constant(false); width]; let expr = BooleanExpr::xor(args); let source = CircuitSAT::new(Circuit::new(vec![Assignment::new(vec![], expr)])); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) + .unwrap(); + let target = reduction.target_problem().inner(); let expected = if width == 0 { 1 } else { diff --git a/src/unit_tests/rules/coloring_qubo.rs b/src/unit_tests/rules/coloring_qubo.rs index 2b4ea3793..7732b8a8f 100644 --- a/src/unit_tests/rules/coloring_qubo.rs +++ b/src/unit_tests/rules/coloring_qubo.rs @@ -8,8 +8,9 @@ use crate::variant::{K2, K3}; fn test_kcoloring_to_qubo_closed_loop() { // Triangle K3, 3 colors → exactly 6 valid colorings (3! permutations) let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); - let reduction = ReduceTo::>::reduce_to(&kc).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&kc) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -28,8 +29,9 @@ fn test_kcoloring_to_qubo_closed_loop() { fn test_kcoloring_to_qubo_path() { // Path graph: 0-1-2, 2 colors let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])); - let reduction = ReduceTo::>::reduce_to(&kc).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&kc) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -48,8 +50,9 @@ fn test_kcoloring_to_qubo_reversed_edges() { // Edge (2, 0) triggers the idx_v < idx_u swap branch (line 104). // Path: 2-0-1 with reversed edge ordering let kc = KColoring::::new(SimpleGraph::new(3, vec![(2, 0), (0, 1)])); - let reduction = ReduceTo::>::reduce_to(&kc).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&kc) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -66,10 +69,11 @@ fn test_kcoloring_to_qubo_reversed_edges() { #[test] fn test_kcoloring_to_qubo_sizes() { let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); - let reduction = ReduceTo::>::reduce_to(&kc).expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&kc) + .expect("reduction should succeed"); // QUBO should have n*K = 3*3 = 9 variables - assert_eq!(reduction.target_problem().num_variables(), 9); + assert_eq!(reduction.target_problem().inner().num_variables(), 9); } #[test] @@ -87,8 +91,10 @@ fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { .collect(); for k in 0..=3 { let source = KColoring::::with_k(SimpleGraph::new(n, edges.clone()), k); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let target = AggregateReductionResult::target_problem(&reduction); + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); + let target = AggregateReductionResult::target_problem(&reduction).inner(); assert_eq!(target.num_vars(), n * k); let mut minimum = i64::MAX; let mut any_coloring = false; @@ -113,7 +119,14 @@ fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { minimum = minimum.min(value.0.unwrap()); let expected = residual == 0; assert_eq!( - AggregateReductionResult::extract_value(&reduction, value).0, + AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0, expected ); match reduction.extract_solution(&config) { @@ -128,13 +141,23 @@ fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { assert_eq!( AggregateReductionResult::extract_value( &reduction, - crate::types::Min(Some(minimum)) + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(crate::types::Min(Some(minimum))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) ) .0, any_coloring ); assert!( - !AggregateReductionResult::extract_value(&reduction, crate::types::Min(None)).0 + !AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 ); assert!(reduction.extract_solution(&vec![false; n * k + 1]).is_err()); } diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 5f39c738d..5e5a780dd 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -27,11 +27,15 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_structure() { &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (4, 5)], 2, ); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); assert_eq!( - crate::rules::AggregateReductionResult::target_problem(&reduction).k(), + crate::rules::AggregateReductionResult::target_problem(&reduction) + .inner() + .k(), target.k() ); @@ -52,9 +56,11 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_yes_in &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (4, 5)], 2, ); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let target_solutions = BruteForce::new().find_all_witnesses(target).unwrap(); assert!( @@ -77,9 +83,11 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (4, 5)], 1, ); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let target_solutions = BruteForce::new().find_all_witnesses(target).unwrap(); assert!( @@ -97,7 +105,10 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - Min(Some(target_value)) + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(Some(target_value))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) ), Or(false) ); @@ -126,10 +137,11 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_all_small_graphs() .chain(0..=i64::try_from(n).unwrap() + 1); for bound in bounds { let source = decision_mds(n, &edges, bound); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = reduction.target_problem().inner(); assert!(target.num_vertices() <= n + 2); assert_eq!(target.num_edges(), edges.len()); let source_yes = BruteForce::new().solve(&source).unwrap().is_some(); @@ -142,8 +154,14 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_all_small_graphs() if let Some(cost) = value.0 { optimum = Some(optimum.map_or(cost, |previous: i64| previous.min(cost))); } - let accepted = - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0; + let accepted = crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + )), + ) + .0; match reduction.extract_solution(&placement) { Ok(witness) => { assert!(accepted); @@ -153,7 +171,13 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_all_small_graphs() } } assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Min(optimum)), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(optimum)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(source_yes), "n={n}, edges={edges:?}, K={bound}" ); diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs index 9e15b4e34..b6572bafc 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -1,6 +1,7 @@ use super::*; use crate::solvers::BruteForce; use crate::traits::Problem; +use crate::types::{Min, Or}; fn decision_mds( n: usize, @@ -16,8 +17,11 @@ fn decision_mds( #[test] fn test_decisionminimumdominatingset_to_minmaxmulticenter_closed_loop() { let source = decision_mds(3, &[(0, 1), (1, 2)], 1); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = reduction.target_problem().inner(); assert_eq!(target.num_vertices(), 5); assert_eq!(target.num_edges(), 2); assert_eq!(target.k(), 3); @@ -31,15 +35,28 @@ fn test_decisionminimumdominatingset_to_minmaxmulticenter_closed_loop() { ); } let source = decision_mds(4, &[(0, 1), (1, 2), (2, 3)], 1); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); let witness = BruteForce::new() - .solve(reduction.target_problem()) + .solve(reduction.target_problem().inner()) .unwrap() .unwrap(); - let optimum = reduction.target_problem().evaluate(&witness).unwrap(); + let optimum = reduction + .target_problem() + .inner() + .evaluate(&witness) + .unwrap(); assert_eq!(optimum, Min(Some(2))); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, optimum), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(optimum), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(false) ); assert!(reduction.extract_solution(&witness).is_err()); @@ -58,9 +75,11 @@ fn test_multicenter_all_small_graphs_bounds_and_placements() { let n_i64 = i64::try_from(n).unwrap(); for bound in [i64::MIN, -1, 0, 1, n_i64, n_i64 + 1, i64::MAX] { let source = decision_mds(n, &edges, bound); - let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = reduction.target_problem().inner(); assert_eq!(target.graph().edges(), edges); assert_eq!(target.num_vertices(), n + 2); assert_eq!(target.vertex_weights(), vec![One; n + 2]); @@ -101,7 +120,13 @@ fn test_multicenter_all_small_graphs_bounds_and_placements() { } } assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Min(optimum)), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(optimum)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(source_yes) ); } @@ -112,7 +137,10 @@ fn test_multicenter_all_small_graphs_bounds_and_placements() { #[test] fn test_multicenter_duplicate_edges_and_malformed_witness() { let source = decision_mds(3, &[(0, 0), (0, 1), (0, 1)], 2); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); let witness = vec![true, false, true, true, true]; assert_eq!( reduction.extract_solution(&witness).unwrap(), @@ -122,7 +150,13 @@ fn test_multicenter_duplicate_edges_and_malformed_witness() { assert!(reduction.extract_solution(&bad).is_err()); } assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Min(None)), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(false) ); } diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 9b59dc234..92d3fbfc2 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -131,6 +131,7 @@ fn solution_and_aggregate_chains_map_values_through_multiple_steps() { steps: vec![ problem_step::(), problem_step::(), + problem_step::>>(), problem_step::>(), ], }; @@ -1215,7 +1216,8 @@ fn test_find_direct_path_variants() { assert!(graph .find_all_paths("Factoring", &src, "SpinGlass", &dst) .iter() - .any(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"])); + .any(|path| path.type_names() + == ["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"])); } #[test] @@ -1336,13 +1338,13 @@ fn test_sat_based_reductions() { let graph = ReductionGraph::new(); // SAT -> IS - assert!(graph.has_direct_reduction::>()); + assert!(graph.has_direct_reduction::>>()); // SAT -> KColoring assert!(graph.has_direct_reduction::>()); // SAT -> MinimumDominatingSet - assert!(graph.has_direct_reduction::>()); + assert!(graph.has_direct_reduction::>>()); } #[test] @@ -1357,7 +1359,7 @@ fn test_circuit_reductions() { assert!(graph.has_direct_reduction::()); // CircuitSAT -> SpinGlass - assert!(graph.has_direct_reduction::>()); + assert!(graph.has_direct_reduction::>>()); // Find path from Factoring to SpinGlass let src = ReductionGraph::variant_to_map(&Factoring::variant()); @@ -1366,7 +1368,8 @@ fn test_circuit_reductions() { assert!(!paths.is_empty()); assert!(paths .iter() - .any(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"])); + .any(|path| path.type_names() + == ["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"])); } #[test] @@ -1402,7 +1405,7 @@ fn test_ksat_reductions() { fn test_nae_sat_to_maxcut_reduction_registered() { let graph = ReductionGraph::new(); - assert!(graph.has_direct_reduction::>()); + assert!(graph.has_direct_reduction::>>()); } #[test] @@ -1848,9 +1851,14 @@ fn test_reduction_chain_with_variant_reductions() { ) .into_iter() .find(|path| { - path.len() == 4 + path.len() == 5 && path.type_names() - == ["KSatisfiability", "Satisfiability", "MaximumIndependentSet"] + == [ + "KSatisfiability", + "Satisfiability", + "DecisionMaximumIndependentSet", + "MaximumIndependentSet", + ] }) .expect("explicit SAT route"); diff --git a/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs index e7d230743..efca83cb3 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs @@ -1,5 +1,5 @@ use crate::models::graph::{HamiltonianCircuit, LongestCircuit}; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; @@ -13,29 +13,46 @@ fn cycle4_hc() -> HamiltonianCircuit { #[test] fn test_hamiltoniancircuit_aggregate_requires_a_spanning_cycle() { - let reduction = ReduceTo::>::reduce_to(&cycle4_hc()).unwrap(); + let reduction = + ReduceTo::>>::reduce_to( + &cycle4_hc(), + ) + .unwrap(); for (value, expected) in [ (Max(None), false), (Max(Some(3)), false), (Max(Some(4)), true), ] { assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), crate::types::Or(expected), ); } let short_cycle = HamiltonianCircuit::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2)])); - let reduction = ReduceTo::>::reduce_to(&short_cycle).unwrap(); + let reduction = + ReduceTo::>>::reduce_to( + &short_cycle, + ) + .unwrap(); assert!(reduction.extract_solution(&vec![true; 3]).is_err()); } #[test] fn test_hamiltoniancircuit_to_longestcircuit_closed_loop() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "HamiltonianCircuit -> LongestCircuit", @@ -45,9 +62,12 @@ fn test_hamiltoniancircuit_to_longestcircuit_closed_loop() { #[test] fn test_hamiltoniancircuit_to_longestcircuit_structure() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); // Same graph structure assert_eq!(target.graph().num_vertices(), 4); @@ -61,9 +81,12 @@ fn test_hamiltoniancircuit_to_longestcircuit_structure() { fn test_hamiltoniancircuit_to_longestcircuit_nonhamiltonian() { // Star graph on 4 vertices: no Hamiltonian circuit let source = HamiltonianCircuit::new(SimpleGraph::star(4)); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); let solver = BruteForce::new(); let witness = solver.solve(target).unwrap(); @@ -86,9 +109,12 @@ fn test_hamiltoniancircuit_to_longestcircuit_nonhamiltonian() { #[test] fn test_hamiltoniancircuit_to_longestcircuit_extract_solution() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); // All edges selected forms a Hamiltonian circuit on the cycle graph let target_solution = vec![true, true, true, true]; @@ -114,9 +140,11 @@ fn test_hamiltoniancircuit_extraction_matches_all_small_target_configurations() .filter_map(|(i, &edge)| ((graph_mask >> i) & 1 == 1).then_some(edge)) .collect(); let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); - let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); for mask in 0usize..(1 << target.num_edges()) { let config: Vec<_> = (0..target.num_edges()) .map(|i| (mask >> i) & 1 == 1) diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs index 084d666ac..952c42b58 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -1,6 +1,6 @@ use crate::models::algebraic::QuadraticAssignment; use crate::models::graph::HamiltonianCircuit; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; @@ -16,9 +16,10 @@ fn cycle4_hc() -> HamiltonianCircuit { fn test_hamiltoniancircuit_to_quadraticassignment_closed_loop() { let source = cycle4_hc(); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "HamiltonianCircuit -> QuadraticAssignment", @@ -29,8 +30,9 @@ fn test_hamiltoniancircuit_to_quadraticassignment_closed_loop() { fn test_hamiltoniancircuit_to_quadraticassignment_structure() { let source = cycle4_hc(); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); assert_eq!(target.num_facilities(), 4); assert_eq!(target.num_locations(), 4); @@ -58,8 +60,9 @@ fn test_hamiltoniancircuit_to_quadraticassignment_structure() { fn test_hamiltoniancircuit_to_quadraticassignment_optimal_cost_is_zero() { let source = cycle4_hc(); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); // The identity permutation [0,1,2,3] is a valid HC on a 4-cycle, // so the QAP optimum should be zero. @@ -76,8 +79,9 @@ fn test_hamiltoniancircuit_to_quadraticassignment_nonhamiltonian_cost_gap() { // Star graph on 4 vertices has no Hamiltonian circuit let source = HamiltonianCircuit::new(SimpleGraph::star(4)); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let best = BruteForce::new() .solve(target) @@ -99,7 +103,8 @@ fn test_hamiltoniancircuit_to_quadraticassignment_nonhamiltonian_cost_gap() { fn test_hamiltoniancircuit_to_quadraticassignment_extract_solution() { let source = cycle4_hc(); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); // Permutation [0,1,2,3] visits 0->1->2->3->0 on cycle4 let target_config = vec![0, 1, 2, 3]; @@ -131,9 +136,10 @@ fn test_prism_graph_hc_via_qap_ilp_roundtrip() { let hc = HamiltonianCircuit::new(SimpleGraph::new(6, edges)); // HC → QAP → ILP → solve → extract back - let r1 = ReduceTo::::reduce_to(&hc).expect("reduction should succeed"); - let r2 = - ReduceTo::>::reduce_to(r1.target_problem()).expect("reduction should succeed"); + let r1 = ReduceTo::>::reduce_to(&hc) + .expect("reduction should succeed"); + let r2 = ReduceTo::>::reduce_to(r1.target_problem().inner()) + .expect("reduction should succeed"); let ilp_sol = ILPSolver::new() .solve(r2.target_problem()) .expect("ILP should be feasible"); @@ -159,21 +165,34 @@ fn test_hamiltoniancircuit_to_quadraticassignment_small_graphs_are_no() { ] { let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); assert!(!source.evaluate(&(0..n).collect()).unwrap().0); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let target = reduction.target_problem().inner(); assert_eq!(target.num_facilities(), 3); assert_eq!(target.num_locations(), 3); let best = BruteForce::new().solve(target).unwrap().unwrap(); let value = target.evaluate(&best).unwrap(); assert_eq!(value, Min(Some(3))); - assert!(!crate::rules::AggregateReductionResult::extract_value(&reduction, value).0); + assert!( + !crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 + ); assert!(reduction.extract_solution(&best).is_err()); } } #[test] fn test_hamiltoniancircuit_to_quadraticassignment_rejects_invalid_certificates() { - let reduction = ReduceTo::::reduce_to(&cycle4_hc()).unwrap(); + let reduction = + ReduceTo::>::reduce_to(&cycle4_hc()) + .unwrap(); for config in [ vec![], vec![0, 1, 2], @@ -183,10 +202,28 @@ fn test_hamiltoniancircuit_to_quadraticassignment_rejects_invalid_certificates() ] { assert!(reduction.extract_solution(&config).is_err(), "{config:?}"); } - for value in [Min(None), Min(Some(-1)), Min(Some(1))] { - assert!(!crate::rules::AggregateReductionResult::extract_value(&reduction, value).0); + for value in [Min(None), Min(Some(1))] { + assert!( + !crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 + ); } - assert!(crate::rules::AggregateReductionResult::extract_value(&reduction, Min(Some(0))).0); + assert!( + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(Some(0))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 + ); } #[test] @@ -206,7 +243,11 @@ fn test_hamiltoniancircuit_to_quadraticassignment_all_small_graphs_and_orders() edges.extend((0..n).map(|v| (v, v))); edges.extend(edges.clone()); let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); + let reduction = + ReduceTo::>::reduce_to( + &source, + ) + .unwrap(); for mut encoded in 0..n.pow(u32::try_from(n).unwrap()) { let order: Vec<_> = (0..n) .map(|_| { @@ -221,7 +262,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_all_small_graphs_and_orders() .any(|(i, v)| order[..i].contains(v)) { assert_eq!( - reduction.target_problem().evaluate(&order).unwrap(), + reduction.target_problem().inner().evaluate(&order).unwrap(), Min(None) ); assert!(reduction.extract_solution(&order).is_err()); @@ -230,11 +271,18 @@ fn test_hamiltoniancircuit_to_quadraticassignment_all_small_graphs_and_orders() let missing = (0..n) .filter(|&i| !source.graph().has_edge(order[i], order[(i + 1) % n])) .count(); - let value = reduction.target_problem().evaluate(&order).unwrap(); + let value = reduction.target_problem().inner().evaluate(&order).unwrap(); assert_eq!(value, Min(Some(i64::try_from(missing).unwrap()))); let expected = source.evaluate(&order).unwrap().0; assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0, + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0, expected ); if expected { @@ -262,6 +310,10 @@ fn test_hamiltoniancircuit_to_quadraticassignment_registered_aggregate_path() { .map(|(key, value)| (key.to_string(), value.to_string())) .collect(), }, + ReductionStep { + name: "DecisionQuadraticAssignment".to_string(), + variant: Default::default(), + }, ReductionStep { name: QuadraticAssignment::NAME.to_string(), variant: Default::default(), diff --git a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs index b13ad1365..0fb4a1a55 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs @@ -1,5 +1,5 @@ use crate::models::graph::{HamiltonianCircuit, RuralPostman}; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; @@ -18,10 +18,13 @@ fn cycle4_hc() -> HamiltonianCircuit { #[test] fn test_hamiltoniancircuit_to_ruralpostman_closed_loop() { let source = triangle_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "HamiltonianCircuit -> RuralPostman (triangle)", @@ -31,10 +34,13 @@ fn test_hamiltoniancircuit_to_ruralpostman_closed_loop() { #[test] fn test_hamiltoniancircuit_to_ruralpostman_closed_loop_cycle4() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "HamiltonianCircuit -> RuralPostman (cycle4)", @@ -44,9 +50,12 @@ fn test_hamiltoniancircuit_to_ruralpostman_closed_loop_cycle4() { #[test] fn test_hamiltoniancircuit_to_ruralpostman_structure() { let source = triangle_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); // 3 vertices -> 6 vertices assert_eq!(target.num_vertices(), 6); @@ -65,9 +74,12 @@ fn test_hamiltoniancircuit_to_ruralpostman_structure() { #[test] fn test_hamiltoniancircuit_to_ruralpostman_structure_cycle4() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); // 4 vertices -> 8 vertices assert_eq!(target.num_vertices(), 8); @@ -81,9 +93,12 @@ fn test_hamiltoniancircuit_to_ruralpostman_structure_cycle4() { fn test_hamiltoniancircuit_to_ruralpostman_optimal_cost() { // Triangle has a Hamiltonian circuit, so optimal RPP cost should be 2n = 6 let source = triangle_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); let best = BruteForce::new() .solve(target) .unwrap() @@ -99,9 +114,12 @@ fn test_hamiltoniancircuit_to_ruralpostman_nonhamiltonian_cost_gap() { let source = HamiltonianCircuit::new(SimpleGraph::star(4)); let n = source.num_vertices(); assert_eq!(n, 4); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); // Verify source has no Hamiltonian circuit let source_witness = BruteForce::new().solve(&source).unwrap(); @@ -127,10 +145,13 @@ fn test_hamiltoniancircuit_to_ruralpostman_nonhamiltonian_cost_gap() { #[test] fn test_hamiltoniancircuit_to_ruralpostman_extract_solution() { let source = triangle_hc(); - let reduction = ReduceTo::>::reduce_to(&source) + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); let best = BruteForce::new() .solve(target) .unwrap() diff --git a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs index 58121a30f..0d00ac480 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs @@ -1,6 +1,6 @@ use crate::models::graph::HamiltonianCircuit; use crate::models::misc::StackerCrane; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; @@ -15,9 +15,10 @@ fn cycle4_hc() -> HamiltonianCircuit { #[test] fn test_hamiltoniancircuit_to_stackercrane_closed_loop() { let source = cycle4_hc(); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "HamiltonianCircuit -> StackerCrane", @@ -27,8 +28,9 @@ fn test_hamiltoniancircuit_to_stackercrane_closed_loop() { #[test] fn test_hamiltoniancircuit_to_stackercrane_structure() { let source = cycle4_hc(); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); // 4 vertices -> 8 target vertices (2 per original vertex) assert_eq!(target.num_vertices(), 8); @@ -51,8 +53,9 @@ fn test_hamiltoniancircuit_to_stackercrane_structure() { fn test_hamiltoniancircuit_to_stackercrane_optimal_cost() { // A 4-cycle has a Hamiltonian circuit; optimal StackerCrane cost = 2n = 8. let source = cycle4_hc(); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let witness = BruteForce::new() .solve(target) @@ -67,8 +70,9 @@ fn test_hamiltoniancircuit_to_stackercrane_non_hamiltonian() { // Star graph on 4 vertices: no Hamiltonian circuit. // The optimal StackerCrane cost should exceed 2n = 8. let source = HamiltonianCircuit::new(SimpleGraph::star(4)); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let witness = BruteForce::new().solve(target).unwrap(); match witness { @@ -88,7 +92,8 @@ fn test_hamiltoniancircuit_to_stackercrane_non_hamiltonian() { #[test] fn test_hamiltoniancircuit_to_stackercrane_extract_solution() { let source = cycle4_hc(); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); // The identity permutation [0, 1, 2, 3] traverses arcs in order, // corresponding to vertex order 0, 1, 2, 3 in the original graph. @@ -118,9 +123,10 @@ fn test_hamiltoniancircuit_to_stackercrane_prism_graph() { (2, 5), ]; let source = HamiltonianCircuit::new(SimpleGraph::new(6, edges)); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "HamiltonianCircuit -> StackerCrane (prism graph)", @@ -140,8 +146,10 @@ fn test_stackercrane_certificate_for_all_small_configurations() { .filter_map(|(i, &e)| ((mask >> i) & 1 == 1).then_some(e)) .collect(); let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let target = crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); // All coordinate configurations, including repeated arc indices. for mut code in 0..n.pow(n as u32) { let config: Vec<_> = (0..n) @@ -154,7 +162,14 @@ fn test_stackercrane_certificate_for_all_small_configurations() { let expected = source.evaluate(&config).unwrap().0; let value = target.evaluate(&config).unwrap(); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0, + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0, expected ); let decoded = reduction.extract_solution(&config); diff --git a/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs index 4e87892d5..09f5907ca 100644 --- a/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -1,6 +1,6 @@ use super::*; use crate::models::graph::{HamiltonianPathBetweenTwoVertices, LongestPath}; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::solvers::BruteForce; use crate::topology::SimpleGraph; @@ -14,16 +14,19 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_closed_loop() { 0, 4, ); - let result = ReduceTo::>::reduce_to(&source) + let result = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = result.target_problem(); + let target = result.target_problem().inner(); assert_eq!(target.num_vertices(), 5); assert_eq!(target.num_edges(), 6); assert_eq!(target.source_vertex(), 0); assert_eq!(target.target_vertex(), 4); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, "HamiltonianPathBetweenTwoVertices->LongestPath closed loop", @@ -38,10 +41,13 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_path_graph() { 0, 3, ); - let result = ReduceTo::>::reduce_to(&source) + let result = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, "HamiltonianPathBetweenTwoVertices->LongestPath path graph", @@ -58,11 +64,14 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_no_hamiltonian_path() { 1, 2, ); - let result = ReduceTo::>::reduce_to(&source) + let result = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); let solver = BruteForce::new(); let target_best = solver - .solve(result.target_problem()) + .solve(result.target_problem().inner()) .unwrap() .expect("LongestPath should have some valid path"); @@ -82,10 +91,13 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_complete_graph() { 0, 3, ); - let result = ReduceTo::>::reduce_to(&source) + let result = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, "HamiltonianPathBetweenTwoVertices->LongestPath complete K4", @@ -100,14 +112,17 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_triangle() { 0, 2, ); - let result = ReduceTo::>::reduce_to(&source) + let result = + ReduceTo::>>::reduce_to( + &source, + ) .expect("reduction should succeed"); - let target = result.target_problem(); + let target = result.target_problem().inner(); assert_eq!(target.num_vertices(), 3); assert_eq!(target.num_edges(), 3); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, "HamiltonianPathBetweenTwoVertices->LongestPath triangle", @@ -137,9 +152,12 @@ fn test_hamiltonian_path_extraction_for_all_small_graphs_and_endpoints() { start, end, ); - let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = + crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); for mask in 0usize..(1 << edges.len()) { let config: Vec<_> = (0..edges.len()).map(|i| (mask >> i) & 1 == 1).collect(); @@ -147,7 +165,12 @@ fn test_hamiltonian_path_extraction_for_all_small_graphs_and_endpoints() { let expected = value.0 == Some(n as i64 - 1); assert_eq!( crate::rules::AggregateReductionResult::extract_value( - &reduction, value + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction) + .bound() + )) ) .0, expected diff --git a/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs deleted file mode 100644 index 736120135..000000000 --- a/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs +++ /dev/null @@ -1,156 +0,0 @@ -use super::*; -use crate::models::formula::CNFClause; -use crate::models::graph::MinimumVertexCover; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; -use crate::solvers::BruteForce; -use crate::topology::SimpleGraph; -use crate::traits::Problem; -use crate::variant::K3; - -#[test] -fn test_ksatisfiability_to_minimumvertexcover_closed_loop() { - // (x1 v x2 v x3) ^ (~x1 v ~x2 v x3), n=3, m=2 - let ksat = KSatisfiability::::new( - 3, - vec![ - CNFClause::new(vec![1, 2, 3]), // x1 v x2 v x3 - CNFClause::new(vec![-1, -2, 3]), // ~x1 v ~x2 v x3 - ], - ); - let reduction = ReduceTo::>::reduce_to(&ksat) - .expect("reduction should succeed"); - let target = reduction.target_problem(); - - // Verify structure: 2*3 + 3*2 = 12 vertices - assert_eq!(target.num_vertices(), 12); - // Edges: 3 truth-setting + 6*2 = 15 - assert_eq!(target.num_edges(), 15); - - // Use the helper to verify full round-trip correctness - assert_satisfaction_round_trip_from_optimization_target( - &ksat, - &reduction, - "3SAT -> MVC closed loop", - ); -} - -#[test] -fn test_ksatisfiability_to_minimumvertexcover_unsatisfiable() { - // Unsatisfiable: (x1 v x1 v x1) ^ (~x1 v ~x1 v ~x1) ^ (x1 v x1 v x1) - let ksat = KSatisfiability::::new( - 1, - vec![ - CNFClause::new(vec![1, 1, 1]), - CNFClause::new(vec![-1, -1, -1]), - CNFClause::new(vec![1, 1, 1]), - ], - ); - let reduction = ReduceTo::>::reduce_to(&ksat) - .expect("reduction should succeed"); - let target = reduction.target_problem(); - - // n=1, m=3 -> 2 + 9 = 11 vertices, minimum VC should be > n + 2m = 7 - // if unsatisfiable. Actually MVC always has a solution (empty set is not valid - // for graphs with edges, but any superset works). The key property is: - // SAT is satisfiable iff MVC has size <= n + 2m. - let solver = BruteForce::new(); - let witness = solver.solve(target).unwrap(); - assert!(witness.is_some()); - let vc_config = witness.unwrap(); - let vc_size: usize = vc_config.iter().filter(|&&selected| selected).count(); - // Unsatisfiable -> minimum VC size > n + 2m = 1 + 6 = 7 - assert!(vc_size > 7); -} - -#[test] -fn test_ksatisfiability_to_minimumvertexcover_single_clause() { - // Single clause: (x1 v x2 v x3) — 7 out of 8 assignments satisfy it - let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, 3])]); - let reduction = ReduceTo::>::reduce_to(&ksat) - .expect("reduction should succeed"); - let target = reduction.target_problem(); - - // 2*3 + 3*1 = 9 vertices, 3 + 6 = 9 edges - assert_eq!(target.num_vertices(), 9); - assert_eq!(target.num_edges(), 9); - - assert_satisfaction_round_trip_from_optimization_target( - &ksat, - &reduction, - "3SAT single clause -> MVC", - ); -} - -#[test] -fn test_ksatisfiability_to_minimumvertexcover_extract_solution() { - // Verify specific extraction: x1=F, x2=F, x3=T - let ksat = KSatisfiability::::new( - 3, - vec![ - CNFClause::new(vec![1, 2, 3]), - CNFClause::new(vec![-1, -2, 3]), - ], - ); - let reduction = ReduceTo::>::reduce_to(&ksat) - .expect("reduction should succeed"); - - // Literal vertices: u1(0), ~u1(1), u2(2), ~u2(3), u3(4), ~u3(5) - // Clause 0 triangle: v6, v7, v8 - // Clause 1 triangle: v9, v10, v11 - // - // For x1=F, x2=F, x3=T: - // Truth-setting: pick ~u1(1), ~u2(3), u3(4) [the true literal] - // Clause 0 (1,2,3): communication edges (6,0), (7,2), (8,4). - // u1(0) not in cover -> must pick v6. u2(2) not in cover -> must pick v7. - // u3(4) in cover -> edge (8,4) covered. Triangle covered by v6 and v7. - // Clause 1 (-1,-2,3): communication edges (9,1), (10,3), (11,4). - // All three endpoints (~u1, ~u2, u3) in cover. Pick any 2 from triangle: v9, v10. - let vc_config = vec![ - false, true, false, true, true, false, true, true, false, true, true, false, - ]; - // Verify this is a valid vertex cover - assert!(reduction.target_problem().is_valid_solution(&vc_config)); - - let extracted = reduction.extract_solution(&vc_config).unwrap(); - assert_eq!(extracted, vec![false, false, true]); // x1=F, x2=F, x3=T - assert!(ksat.evaluate(&extracted).unwrap()); -} - -#[test] -fn test_ksatisfiability_to_minimumvertexcover_all_negated() { - // (~x1 v ~x2 v ~x3) — 7 satisfying assignments - let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![-1, -2, -3])]); - let reduction = ReduceTo::>::reduce_to(&ksat) - .expect("reduction should succeed"); - - assert_satisfaction_round_trip_from_optimization_target( - &ksat, - &reduction, - "3SAT all negated -> MVC", - ); -} - -#[test] -fn test_ksatisfiability_to_minimumvertexcover_structure() { - // Verify edge structure for a simple case - let ksat = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, -1, 2])]); - let reduction = ReduceTo::>::reduce_to(&ksat) - .expect("reduction should succeed"); - let target = reduction.target_problem(); - - // n=2, m=1 -> 4 + 3 = 7 vertices - assert_eq!(target.num_vertices(), 7); - // 2 truth-setting + 6*1 = 8 edges - assert_eq!(target.num_edges(), 8); - - // Minimum cover size for satisfiable formula = n + 2m = 2 + 2 = 4 - let solver = BruteForce::new(); - let witness = solver.solve(target).unwrap(); - assert!(witness.is_some()); - let vc_size: usize = witness - .unwrap() - .iter() - .filter(|&&selected| selected) - .count(); - assert_eq!(vc_size, 4); -} diff --git a/src/unit_tests/rules/ksatisfiability_qubo.rs b/src/unit_tests/rules/ksatisfiability_qubo.rs index 569e16edb..65677a0af 100644 --- a/src/unit_tests/rules/ksatisfiability_qubo.rs +++ b/src/unit_tests/rules/ksatisfiability_qubo.rs @@ -18,8 +18,9 @@ fn test_ksatisfiability_to_qubo_closed_loop() { CNFClause::new(vec![-2, -3]), // ¬x2 ∨ ¬x3 ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -35,8 +36,9 @@ fn test_ksatisfiability_to_qubo_closed_loop() { fn test_ksatisfiability_to_qubo_simple() { // 2 vars, 1 clause: (x1 ∨ x2) → 3 satisfying assignments let ksat = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, 2])]); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -59,8 +61,9 @@ fn test_ksatisfiability_to_qubo_contradiction() { CNFClause::new(vec![-1, -1]), // ¬x1 ∨ ¬x1 = ¬x1 ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -80,8 +83,9 @@ fn test_ksatisfiability_to_qubo_reversed_vars() { CNFClause::new(vec![1, 2]), ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -98,8 +102,9 @@ fn test_ksatisfiability_to_qubo_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); // QUBO should have at least the original variables assert!(qubo.num_variables() >= ksat.num_vars()); @@ -120,8 +125,9 @@ fn test_k3satisfiability_to_qubo_closed_loop() { CNFClause::new(vec![3, -4, -5]), // x3 ∨ ¬x4 ∨ ¬x5 ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); // QUBO should have 5 + 7 = 12 variables assert_eq!(qubo.num_variables(), 12); @@ -142,8 +148,9 @@ fn test_k3satisfiability_to_qubo_closed_loop() { fn test_k3satisfiability_to_qubo_single_clause() { // Single 3-SAT clause: (x1 ∨ x2 ∨ x3) — 7 satisfying assignments let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, 3])]); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); // 3 vars + 1 auxiliary = 4 total assert_eq!(qubo.num_variables(), 4); @@ -165,8 +172,9 @@ fn test_k3satisfiability_to_qubo_single_clause() { fn test_k3satisfiability_to_qubo_all_negated() { // All negated: (¬x1 ∨ ¬x2 ∨ ¬x3) — 7 satisfying assignments let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![-1, -2, -3])]); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); - let qubo = reduction.target_problem(); + let reduction = ReduceTo::>>::reduce_to(&ksat) + .expect("reduction should succeed"); + let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); @@ -201,8 +209,12 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { 1, vec![CNFClause::new(a.clone()), CNFClause::new(b.clone())], ); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let target = ReductionResult::target_problem(&reduction); + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) + .unwrap(); + let target = ReductionResult::target_problem(&reduction).inner(); let mut minimum = i64::MAX; for mask in 0..(1 << target.num_vars()) { let witness: Vec<_> = (0..target.num_vars()) @@ -226,9 +238,16 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { _ => unreachable!(), }; } - assert_eq!(energy - reduction.zero_penalty_energy, penalty); + assert_eq!(energy - *reduction.target.bound(), penalty); assert_eq!( - AggregateReductionResult::extract_value(&reduction, Min(Some(energy))), + AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(Some(energy))), + crate::rules::ReductionResult::target_problem(&reduction) + .bound() + )) + ), Or(penalty == 0) ); if penalty == 0 { @@ -243,11 +262,23 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { .into_iter() .any(|x| source.evaluate(&vec![x]).unwrap().0); assert_eq!( - AggregateReductionResult::extract_value(&reduction, Min(Some(minimum))), + AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(Some(minimum))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(sat) ); assert_eq!( - AggregateReductionResult::extract_value(&reduction, Min(None)), + AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(false) ); assert!(reduction.extract_solution(&vec![]).is_err()); @@ -258,7 +289,9 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { } for n in [0, 3] { let source = KSatisfiability::<$k>::new(n, vec![]); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); assert_eq!( reduction.extract_solution(&vec![false; n]).unwrap(), vec![false; n] @@ -283,11 +316,11 @@ fn test_sat_qubo_checked_numeric_boundaries() { let k2 = KSatisfiability::::new(n, vec![]); let k3 = KSatisfiability::::new(n, vec![]); assert!(matches!( - ReduceTo::>::reduce_to(&k2), + ReduceTo::>>::reduce_to(&k2), Err(crate::rules::ReductionError::IntegerOverflow { .. }) )); assert!(matches!( - ReduceTo::>::reduce_to(&k3), + ReduceTo::>>::reduce_to(&k3), Err(crate::rules::ReductionError::IntegerOverflow { .. }) )); } @@ -301,15 +334,17 @@ fn test_sat_qubo_registered_aggregate_threshold() { 1, clauses.into_iter().map(CNFClause::new).collect(), ); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let mut witness = vec![false; reduction.target.num_vars()]; + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); + let mut witness = vec![false; reduction.target.inner().num_vars()]; witness[0] = expected; let entries = crate::rules::registry::reduction_entries(); let edge = entries .iter() .find(|e| { e.source_name == "KSatisfiability" - && e.target_name == "QUBO" + && e.target_name == "DecisionQUBO" && (e.source_variant_fn)() == KSatisfiability::<$k>::variant() && (e.target_variant_fn)() == QUBO::::variant() }) diff --git a/src/unit_tests/rules/naesatisfiability_maxcut.rs b/src/unit_tests/rules/naesatisfiability_maxcut.rs index 9f07e13f0..4dd13c929 100644 --- a/src/unit_tests/rules/naesatisfiability_maxcut.rs +++ b/src/unit_tests/rules/naesatisfiability_maxcut.rs @@ -2,7 +2,7 @@ use super::*; use crate::models::formula::CNFClause; use crate::models::formula::NAESatisfiability; use crate::models::graph::MaxCut; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -20,15 +20,16 @@ fn test_naesatisfiability_to_maxcut_closed_loop() { ], ); let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); // 2*3 = 6 vertices assert_eq!(target.num_vertices(), 6); // 3 variable edges + 3 + 3 = 9 clause edges assert_eq!(target.num_edges(), 9); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &naesat, &reduction, "NAESAT -> MaxCut closed loop", @@ -40,14 +41,15 @@ fn test_naesatisfiability_to_maxcut_single_clause() { // Single clause: (x1, x2, x3) — NAE-satisfying iff not all same let naesat = NAESatisfiability::new(3, vec![CNFClause::new(vec![1, 2, 3])]); let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); // 6 vertices, 3 variable + 3 clause = 6 edges assert_eq!(target.num_vertices(), 6); assert_eq!(target.num_edges(), 6); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &naesat, &reduction, "NAESAT single clause -> MaxCut", @@ -60,14 +62,15 @@ fn test_naesatisfiability_to_maxcut_two_literal_clause() { // NAE-satisfied when x1 != ~x2, i.e., x1 == x2. let naesat = NAESatisfiability::new(2, vec![CNFClause::new(vec![1, -2])]); let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); // 4 vertices, 2 variable + 1 clause = 3 edges assert_eq!(target.num_vertices(), 4); assert_eq!(target.num_edges(), 3); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &naesat, &reduction, "NAESAT 2-literal clause -> MaxCut", @@ -79,14 +82,15 @@ fn test_naesatisfiability_to_maxcut_four_literal_clause() { // Clause with 4 literals: (x1, x2, ~x3, x4) let naesat = NAESatisfiability::new(4, vec![CNFClause::new(vec![1, 2, -3, 4])]); let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); // One auxiliary variable and two triangles: 10 vertices, 5 + 6 edges. assert_eq!(target.num_vertices(), 10); assert_eq!(target.num_edges(), 11); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &naesat, &reduction, "NAESAT 4-literal clause -> MaxCut", @@ -104,7 +108,8 @@ fn test_naesatisfiability_to_maxcut_extract_solution() { ], ); let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); + ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); // Vertices: x1(0), ~x1(1), x2(2), ~x2(3), x3(4), ~x3(5) // x1=T -> vertex 0 in set 1, vertex 1 in set 0 @@ -130,14 +135,15 @@ fn test_naesatisfiability_to_maxcut_mixed_clause_sizes() { ], ); let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); // 6 vertices, 3 variable + (1 + 3 + 1) = 8 edges assert_eq!(target.num_vertices(), 6); assert_eq!(target.num_edges(), 8); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &naesat, &reduction, "NAESAT mixed clause sizes -> MaxCut", @@ -156,8 +162,9 @@ fn test_naesatisfiability_to_maxcut_optimal_cut_value() { ], ); let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let solver = BruteForce::new(); let witness = solver.solve(target).unwrap(); @@ -172,8 +179,10 @@ fn test_naesatisfiability_to_maxcut_optimal_cut_value() { fn check_every_cut(source: &NAESatisfiability) { use crate::rules::AggregateReductionResult; - let reduction = ReduceTo::>::reduce_to(source).unwrap(); - let target = AggregateReductionResult::target_problem(&reduction); + let reduction = + ReduceTo::>>::reduce_to(source) + .unwrap(); + let target = AggregateReductionResult::target_problem(&reduction).inner(); let mut decoded = vec![false; 1 << source.num_vars()]; let mut best = i64::MIN; for mask in 0..(1usize << target.num_vertices()) { @@ -182,7 +191,14 @@ fn check_every_cut(source: &NAESatisfiability) { .collect(); let value = target.evaluate(&cut).unwrap(); best = best.max(value.0.unwrap()); - let certificate = AggregateReductionResult::extract_value(&reduction, value).0; + let certificate = AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + )), + ) + .0; match reduction.extract_solution(&cut) { Ok(assignment) => { assert!(certificate); @@ -209,10 +225,26 @@ fn check_every_cut(source: &NAESatisfiability) { assert_eq!(has_extension, source.evaluate(&assignment).unwrap().0); } assert_eq!( - AggregateReductionResult::extract_value(&reduction, crate::types::Max(Some(best))).0, + AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(crate::types::Max(Some(best))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0, decoded.iter().any(|&valid| valid) ); - assert!(!AggregateReductionResult::extract_value(&reduction, crate::types::Max(None)).0); + assert!( + !AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(crate::types::Max(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 + ); assert!(reduction .extract_solution(&vec![false; target.num_vertices() + 1]) .is_err()); @@ -251,8 +283,10 @@ fn test_naesatisfiability_to_maxcut_long_clause_interactions() { ], ); check_every_cut(&source); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - assert_eq!(reduction.feasible_cut, 26); + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); + assert_eq!(*reduction.target.bound(), 26); for clauses in [ vec![vec![1, 1, 1, 1, 1]], vec![vec![1, 2, 3, 1, 2], vec![1, -2], vec![2, -3]], diff --git a/src/unit_tests/rules/partition_openshopscheduling.rs b/src/unit_tests/rules/partition_openshopscheduling.rs index 40bc779df..0559710a9 100644 --- a/src/unit_tests/rules/partition_openshopscheduling.rs +++ b/src/unit_tests/rules/partition_openshopscheduling.rs @@ -15,8 +15,10 @@ fn solve_target(target: &OpenShopScheduling) -> Vec { #[test] fn test_partition_to_open_shop_scheduling_closed_loop() { let source = Partition::new(vec![1, 2, 3]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target_solution = solve_target(reduction.target_problem()); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let target_solution = solve_target(reduction.target_problem().inner()); let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).unwrap()); } @@ -25,8 +27,9 @@ fn test_partition_to_open_shop_scheduling_closed_loop() { fn test_partition_to_open_shop_scheduling_structure() { let source = Partition::new(vec![1, 2, 3]).unwrap(); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); assert_eq!(target.num_jobs(), 4); assert_eq!(target.num_machines(), 3); @@ -39,8 +42,10 @@ fn test_partition_to_open_shop_scheduling_structure() { #[test] fn test_partition_to_open_shop_scheduling_extract_solution() { let source = Partition::new(vec![1, 2, 3]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target_solution = solve_target(reduction.target_problem()); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let target_solution = solve_target(reduction.target_problem().inner()); let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).unwrap()); @@ -49,20 +54,24 @@ fn test_partition_to_open_shop_scheduling_extract_solution() { #[test] fn test_partition_to_open_shop_scheduling_odd_total_is_not_satisfying() { let source = Partition::new(vec![2, 4, 5]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let best = solve_target(reduction.target_problem()); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let best = solve_target(reduction.target_problem().inner()); assert!(reduction.extract_solution(&best).is_err()); } #[test] fn test_partition_to_open_shop_scheduling_preserves_construction_overflow() { let source = Partition::new(vec![1_i64 << 61, 1_i64 << 61]).unwrap(); - let error = ReduceTo::::reduce_to(&source).unwrap_err(); + let error = + ReduceTo::>::reduce_to(&source) + .unwrap_err(); assert!(matches!( error, crate::rules::ReductionError::Construction { source_problem: "Partition", - target_problem: "OpenShopScheduling", + target_problem: "DecisionOpenShopScheduling", cause: crate::registry::ConstructionError::IntegerOverflow(_), } )); @@ -89,10 +98,21 @@ fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { }) .collect(); let source = Partition::new(sizes.clone()).unwrap(); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target = AggregateReductionResult::target_problem(&reduction); + let reduction = + ReduceTo::>::reduce_to( + &source, + ) + .unwrap(); + let target = AggregateReductionResult::target_problem(&reduction).inner(); assert!( - !AggregateReductionResult::extract_value(&reduction, crate::types::Min(None)).0 + !AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 ); for mask in 0..(1usize << n) { let assignment: Vec<_> = (0..n).map(|i| mask & (1 << i) != 0).collect(); @@ -118,7 +138,16 @@ fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { } let value = target.evaluate(&schedule).unwrap(); assert_eq!(value, crate::types::Min(Some(3 * half as i64))); - assert!(AggregateReductionResult::extract_value(&reduction, value).0); + assert!( + AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 + ); assert_eq!(reduction.extract_solution(&schedule).unwrap(), assignment); let delayed: Vec<_> = schedule.iter().map(|&time| time + 1).collect(); assert!(target.evaluate(&delayed).unwrap().0.is_some()); @@ -137,13 +166,25 @@ fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { fn test_partition_to_open_shop_odd_singleton_certificate() { use crate::rules::AggregateReductionResult; let source = Partition::new(vec![1]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); let schedule = vec![0, 1, 2, 0, 0, 0]; let value = ReductionResult::target_problem(&reduction) + .inner() .evaluate(&schedule) .unwrap(); assert_eq!(value, crate::types::Min(Some(3))); - assert!(!AggregateReductionResult::extract_value(&reduction, value).0); + assert!( + !AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 + ); assert!(reduction.extract_solution(&schedule).is_err()); } @@ -152,11 +193,17 @@ fn test_partition_to_open_shop_odd_singleton_certificate() { fn test_partition_to_open_shop_certificate_near_horizon_limit() { let size = i64::MAX / 9; let source = Partition::new(vec![size, size]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); let a = usize::try_from(size).unwrap(); let schedule = vec![0, a, 2 * a, 2 * a, 0, a, a, 2 * a, 0]; assert_eq!( - reduction.target_problem().evaluate(&schedule).unwrap(), + reduction + .target_problem() + .inner() + .evaluate(&schedule) + .unwrap(), crate::types::Min(Some(3 * size)) ); assert!( diff --git a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs index 8bb870c3e..3f9f2dc2c 100644 --- a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs @@ -1,7 +1,7 @@ #[cfg(feature = "example-db")] use super::canonical_rule_example_specs; use crate::models::misc::{Partition, SequencingToMinimizeTardyTaskWeight}; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; use crate::solvers::BruteForce; @@ -11,10 +11,12 @@ use crate::types::Min; #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_closed_loop() { let source = Partition::new(vec![3, 1, 1, 2, 2, 1]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision, + >::reduce_to(&source) + .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "Partition -> SequencingToMinimizeTardyTaskWeight closed loop", @@ -24,9 +26,11 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_closed_loop() { #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_structure() { let source = Partition::new(vec![3, 1, 1, 2, 2, 1]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision, + >::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); assert_eq!(target.lengths(), &[3, 1, 1, 2, 2, 1]); assert_eq!(target.weights(), &[3, 1, 1, 2, 2, 1]); @@ -37,8 +41,10 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_structure() { #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_extract_solution() { let source = Partition::new(vec![3, 1, 1, 2, 2, 1]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision, + >::reduce_to(&source) + .expect("reduction should succeed"); assert_eq!( reduction.extract_solution(&vec![1, 2, 4, 5, 0, 3]).unwrap(), @@ -49,9 +55,11 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_extract_solution() #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_odd_total_is_unsatisfying() { let source = Partition::new(vec![2, 4, 5]).unwrap(); - let reduction = ReduceTo::::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision, + >::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let best = BruteForce::new() .solve(target) .unwrap() @@ -61,7 +69,10 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_odd_total_is_unsat assert!( !crate::rules::AggregateReductionResult::extract_value( &reduction, - target.evaluate(&best).unwrap() + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(target.evaluate(&best).unwrap()), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) ) .0 ); @@ -80,18 +91,18 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_canonical_example_ assert_eq!(example.source.problem, "Partition"); assert_eq!( example.target.problem, - "SequencingToMinimizeTardyTaskWeight" + "DecisionSequencingToMinimizeTardyTaskWeight" ); assert_eq!( - example.target.instance["lengths"], + example.target.instance["inner"]["lengths"], serde_json::json!([3, 1, 1, 2, 2, 1]) ); assert_eq!( - example.target.instance["weights"], + example.target.instance["inner"]["weights"], serde_json::json!([3, 1, 1, 2, 2, 1]) ); assert_eq!( - example.target.instance["deadlines"], + example.target.instance["inner"]["deadlines"], serde_json::json!([5, 5, 5, 5, 5, 5]) ); assert_eq!(example.solutions.len(), 1); @@ -106,7 +117,7 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_canonical_example_ let source: Partition = serde_json::from_value(example.source.instance.clone()) .expect("source example deserializes"); - let target: SequencingToMinimizeTardyTaskWeight = + let target: crate::models::decision::Decision = serde_json::from_value(example.target.instance.clone()) .expect("target example deserializes"); @@ -130,9 +141,11 @@ fn test_partition_to_tardy_weight_all_small_configurations() { }) .collect(); let source = Partition::new(sizes).unwrap(); - let reduction = - ReduceTo::::reduce_to(&source).unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction); + let reduction = ReduceTo::< + crate::models::decision::Decision, + >::reduce_to(&source) + .unwrap(); + let target = crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); let source_feasible = (0..1usize << n).any(|mask| { let bits = (0..n).map(|i| mask & (1 << i) != 0).collect(); source.evaluate(&bits).unwrap().0 @@ -150,8 +163,14 @@ fn test_partition_to_tardy_weight_all_small_configurations() { if let Some(weight) = value.0 { optimum = optimum.min(weight); } - let certified = - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0; + let certified = crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + )), + ) + .0; let extracted = reduction.extract_solution(&schedule); assert_eq!(extracted.is_ok(), certified); if let Ok(bits) = extracted { @@ -161,7 +180,10 @@ fn test_partition_to_tardy_weight_all_small_configurations() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - Min(Some(optimum)), + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(Some(optimum))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) ) .0, source_feasible @@ -171,7 +193,14 @@ fn test_partition_to_tardy_weight_all_small_configurations() { .extract_solution(&vec![n as usize; n as usize]) .is_err()); assert!( - !crate::rules::AggregateReductionResult::extract_value(&reduction, Min(None),).0 + !crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0 ); } } @@ -187,12 +216,25 @@ fn test_partition_to_tardy_weight_full_i64_domain() { (vec![half - 1, half - 1, 1, 1], vec![0, 2, 1, 3], half, true), ] { let source = Partition::new(sizes).unwrap(); - let reduction = - ReduceTo::::reduce_to(&source).unwrap(); - let value = reduction.target_problem().evaluate(&schedule).unwrap(); + let reduction = ReduceTo::< + crate::models::decision::Decision, + >::reduce_to(&source) + .unwrap(); + let value = reduction + .target_problem() + .inner() + .evaluate(&schedule) + .unwrap(); assert_eq!(value, Min(Some(expected))); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0, + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0, balanced ); let extracted = reduction.extract_solution(&schedule); diff --git a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs index 2441ab52a..a645eee4f 100644 --- a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -1,5 +1,5 @@ use super::*; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::topology::Graph; use crate::traits::Problem; use crate::types::Min; @@ -18,7 +18,10 @@ fn test_partitionintocliques_target_bound_rejects_overflow() { #[test] fn test_partitionintocliques_aggregate_applies_gadget_offset() { let source = PartitionIntoCliques::new(SimpleGraph::new(3, vec![(0, 1)]), 2); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); // K + 2m + 2 = 6, including both directed-edge gadgets and the side cliques. for (value, expected) in [ (Min(None), false), @@ -27,7 +30,13 @@ fn test_partitionintocliques_aggregate_applies_gadget_offset() { (Min(Some(7)), false), ] { assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), crate::types::Or(expected), ); } @@ -36,10 +45,12 @@ fn test_partitionintocliques_aggregate_applies_gadget_offset() { #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_closed_loop() { let source = PartitionIntoCliques::new(SimpleGraph::empty(1), 1); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "PartitionIntoCliques -> MinimumCoveringByCliques closed loop", @@ -49,9 +60,11 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_closed_loop() { #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_orlin_example_structure() { let source = PartitionIntoCliques::new(SimpleGraph::new(3, vec![(0, 1)]), 2); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let layout = OrlinLayout::new(source.graph()); assert_eq!(target.graph().num_vertices(), 14); @@ -108,9 +121,11 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_orlin_example_structure #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_unsat_extracts_invalid_source() { let source = PartitionIntoCliques::new(SimpleGraph::new(2, vec![]), 1); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let layout = OrlinLayout::new(source.graph()); let target_solution = edge_labels_from_clique_cover( @@ -160,9 +175,11 @@ fn test_partitionintocliques_native_bounds_and_adjacency_semantics() { continue; } let source = source.unwrap(); - let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = ReductionResult::target_problem(&reduction); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = ReductionResult::target_problem(&reduction).inner(); let layout = OrlinLayout::new(source.graph()); let mut cliques: Vec> = (0..n).map(|i| vec![layout.x(i), layout.y(i)]).collect(); @@ -182,7 +199,14 @@ fn test_partitionintocliques_native_bounds_and_adjacency_semantics() { Min(Some((n + layout.num_directed_pairs() + 2) as i64)) ); assert_eq!( - AggregateReductionResult::extract_value(&reduction, value).0, + AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ) + .0, n <= bound ); if n <= bound { diff --git a/src/unit_tests/rules/reduction_path_parity.rs b/src/unit_tests/rules/reduction_path_parity.rs index d2011bcd4..5dc4cd245 100644 --- a/src/unit_tests/rules/reduction_path_parity.rs +++ b/src/unit_tests/rules/reduction_path_parity.rs @@ -118,7 +118,9 @@ fn test_jl_parity_factoring_to_spinglass_path() { let rpath = graph .find_all_paths("Factoring", &src_var, "SpinGlass", &dst_var) .into_iter() - .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) + .find(|path| { + path.type_names() == ["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"] + }) .expect("explicit CircuitSAT route"); // Canonical factor order uses the smaller width first. diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index f6a909138..72de79349 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -41,7 +41,9 @@ fn registered_executors_preserve_construction_errors() { let source = Partition::new(vec![1_i64 << 61, 1_i64 << 61]).unwrap(); let entry = reduction_entries() .into_iter() - .find(|entry| entry.source_name == "Partition" && entry.target_name == "OpenShopScheduling") + .find(|entry| { + entry.source_name == "Partition" && entry.target_name == "DecisionOpenShopScheduling" + }) .unwrap(); let witness_error = entry.reduce_fn.unwrap()(&source).err().unwrap(); let aggregate_error = entry.reduce_aggregate_fn.unwrap()(&source).err().unwrap(); @@ -50,7 +52,7 @@ fn registered_executors_preserve_construction_errors() { error, crate::rules::ReductionError::Construction { source_problem: "Partition", - target_problem: "OpenShopScheduling", + target_problem: "DecisionOpenShopScheduling", cause: crate::registry::ConstructionError::IntegerOverflow(_), } )); diff --git a/src/unit_tests/rules/sat_maximumindependentset.rs b/src/unit_tests/rules/sat_maximumindependentset.rs index 47b23dd0d..827fd79ba 100644 --- a/src/unit_tests/rules/sat_maximumindependentset.rs +++ b/src/unit_tests/rules/sat_maximumindependentset.rs @@ -1,9 +1,10 @@ use super::*; use crate::models::formula::CNFClause; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; use crate::topology::Graph; use crate::traits::Problem; +use crate::types::{Max, Or}; include!("../jl_helpers.rs"); #[test] @@ -46,9 +47,11 @@ fn test_boolvar_complement() { fn test_sat_to_maximumindependentset_closed_loop() { // Simple SAT: (x1) - one clause with one literal let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let is_problem = reduction.target_problem().inner(); // Should have 1 vertex (one literal) assert_eq!(is_problem.graph().num_vertices(), 1); @@ -61,9 +64,11 @@ fn test_two_clause_sat_to_is() { // SAT: (x1) AND (NOT x1) // This is unsatisfiable let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let is_problem = reduction.target_problem().inner(); // Should have 2 vertices assert_eq!(is_problem.graph().num_vertices(), 2); @@ -82,8 +87,10 @@ fn test_two_clause_sat_to_is() { fn test_extract_solution_basic() { // Simple case: (x1 OR x2) let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, 2])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); // Select vertex 0 (literal x1) let is_sol = vec![true, false]; @@ -100,8 +107,10 @@ fn test_extract_solution_basic() { fn test_extract_solution_with_negation() { // (NOT x1) - selecting NOT x1 means x1 should be false let sat = Satisfiability::new(1, vec![CNFClause::new(vec![-1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); let is_sol = vec![true]; let sat_sol = reduction.extract_solution(&is_sol).unwrap(); @@ -112,9 +121,11 @@ fn test_extract_solution_with_negation() { fn test_clique_edges_in_clause() { // A clause with 3 literals should form a clique (3 edges) let sat = Satisfiability::new(3, vec![CNFClause::new(vec![1, 2, 3])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let is_problem = reduction.target_problem().inner(); // 3 vertices, 3 edges (complete graph K3) assert_eq!(is_problem.graph().num_vertices(), 3); @@ -134,9 +145,11 @@ fn test_complement_edges_across_clauses() { CNFClause::new(vec![2]), ], ); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let is_problem = reduction.target_problem().inner(); assert_eq!(is_problem.graph().num_vertices(), 3); assert_eq!(is_problem.graph().num_edges(), 1); // Only the complement edge @@ -148,9 +161,11 @@ fn test_is_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let is_problem = reduction.target_problem().inner(); // IS should have vertices for literals in clauses assert_eq!(is_problem.graph().num_vertices(), 4); // 2 + 2 literals @@ -160,9 +175,11 @@ fn test_is_structure() { fn test_empty_sat() { // Empty SAT (trivially satisfiable) let sat = Satisfiability::new(0, vec![]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let is_problem = reduction.target_problem().inner(); assert_eq!(is_problem.graph().num_vertices(), 0); assert_eq!(is_problem.graph().num_edges(), 0); @@ -172,8 +189,10 @@ fn test_empty_sat() { #[test] fn test_literals_accessor() { let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, -2])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); let literals = reduction.literals(); assert_eq!(literals.len(), 2); @@ -216,8 +235,10 @@ fn test_jl_parity_sat_to_independentset() { let inst = &jl_find_instance_by_label(&sat_data, label)["instance"]; let (num_vars, clauses) = jl_parse_sat_clauses(inst); let source = Satisfiability::new(num_vars, clauses); - let result = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let result = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); let solver = BruteForce::new(); let sat_solutions: HashSet> = solver .find_all_witnesses(&source) @@ -227,19 +248,26 @@ fn test_jl_parity_sat_to_independentset() { for case in data["cases"].as_array().unwrap() { if sat_solutions.is_empty() { let target_solution = BruteForce::new() - .solve(result.target_problem()) + .solve(result.target_problem().inner()) .unwrap() .expect("SAT->IS: target should have an optimal solution"); assert!(result.extract_solution(&target_solution).is_err()); assert_eq!( crate::rules::AggregateReductionResult::extract_value( &result, - result.target_problem().evaluate(&target_solution).unwrap(), + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(result + .target_problem() + .inner() + .evaluate(&target_solution) + .unwrap()), + crate::rules::ReductionResult::target_problem(&result).bound() + )) ), Or(false), ); } else { - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, &format!("SAT->IS [{label}]"), @@ -274,12 +302,14 @@ fn test_sat_to_independentset_all_certificates() { CNFClause::new(second.clone()), ], ); - let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = reduction.target_problem().inner(); assert!(std::ptr::eq( target, - crate::rules::AggregateReductionResult::target_problem(&reduction) + crate::rules::AggregateReductionResult::target_problem(&reduction).inner() )); let mut accepted = false; for mask in 0..(1usize << target.num_vertices()) { @@ -289,7 +319,13 @@ fn test_sat_to_independentset_all_certificates() { let value = target.evaluate(&config).unwrap(); let certificate = value == Max(Some(2)); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(certificate) ); match reduction.extract_solution(&config) { @@ -312,14 +348,22 @@ fn test_sat_to_independentset_all_certificates() { } for num_vars in [0, 3] { let source = Satisfiability::new(num_vars, vec![]); - let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); assert_eq!( reduction.extract_solution(&vec![]).unwrap(), vec![false; num_vars] ); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Max(None)), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Max(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(false) ); } diff --git a/src/unit_tests/rules/sat_minimumdominatingset.rs b/src/unit_tests/rules/sat_minimumdominatingset.rs index c6db9615d..6edc9005e 100644 --- a/src/unit_tests/rules/sat_minimumdominatingset.rs +++ b/src/unit_tests/rules/sat_minimumdominatingset.rs @@ -1,17 +1,20 @@ use super::*; use crate::models::formula::CNFClause; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; use crate::topology::Graph; +use crate::types::{Min, Or}; include!("../jl_helpers.rs"); #[test] fn test_sat_to_minimumdominatingset_closed_loop() { // Simple SAT: (x1) - one variable, one clause let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let ds_problem = reduction.target_problem().inner(); // Should have 3 vertices (variable gadget) + 1 clause vertex = 4 vertices assert_eq!(ds_problem.graph().num_vertices(), 4); @@ -26,9 +29,11 @@ fn test_sat_to_minimumdominatingset_closed_loop() { fn test_two_variable_sat_to_ds() { // SAT: (x1 OR x2) let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, 2])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let ds_problem = reduction.target_problem().inner(); // 2 variables * 3 = 6 gadget vertices + 1 clause vertex = 7 assert_eq!(ds_problem.graph().num_vertices(), 7); @@ -44,8 +49,10 @@ fn test_two_variable_sat_to_ds() { fn test_extract_solution_positive_literal() { // (x1) - select positive literal let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); // Solution: select vertex 0 (positive literal x1) // This dominates vertices 1, 2 (gadget) and vertex 3 (clause) @@ -58,8 +65,10 @@ fn test_extract_solution_positive_literal() { fn test_extract_solution_negative_literal() { // (NOT x1) - select negative literal let sat = Satisfiability::new(1, vec![CNFClause::new(vec![-1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); // Solution: select vertex 1 (negative literal NOT x1) // This dominates vertices 0, 2 (gadget) and vertex 3 (clause) @@ -72,8 +81,10 @@ fn test_extract_solution_negative_literal() { fn test_extract_solution_unused_variable() { // The unit clause x1 leaves x2 unused. let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); // Only x1 occurs, so its triangle is the only gadget. The unused x2 // remains false in the extracted source assignment. @@ -88,9 +99,11 @@ fn test_ds_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let ds_problem = reduction.target_problem().inner(); // 3 vars * 3 = 9 gadget vertices + 2 clause vertices = 11 assert_eq!(ds_problem.graph().num_vertices(), 11); @@ -100,9 +113,11 @@ fn test_ds_structure() { fn test_empty_sat() { // Empty SAT (trivially satisfiable) let sat = Satisfiability::new(0, vec![]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let ds_problem = reduction.target_problem().inner(); assert_eq!(ds_problem.graph().num_vertices(), 0); assert_eq!(ds_problem.graph().num_edges(), 0); @@ -114,9 +129,11 @@ fn test_empty_sat() { fn test_multiple_literals_same_variable() { // Clause with repeated variable: (x1 OR NOT x1) - tautology let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1, -1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let ds_problem = reduction.target_problem().inner(); // 3 gadget vertices + 1 clause vertex = 4 assert_eq!(ds_problem.graph().num_vertices(), 4); @@ -130,8 +147,10 @@ fn test_multiple_literals_same_variable() { #[test] fn test_accessors() { let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, -2])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); assert_eq!(reduction.num_literals(), 2); assert_eq!(reduction.num_clauses(), 1); @@ -140,8 +159,10 @@ fn test_accessors() { #[test] fn test_extract_solution_too_many_selected() { let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); let ds_sol = vec![true, true, false, false]; assert_eq!( @@ -153,8 +174,10 @@ fn test_extract_solution_too_many_selected() { #[test] fn test_extract_solution_rejects_unselected_variable_gadget() { let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); assert_eq!( reduction @@ -168,8 +191,10 @@ fn test_extract_solution_rejects_unselected_variable_gadget() { #[test] fn test_extract_solution_rejects_selected_clause_vertex() { let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); assert_eq!( reduction @@ -184,9 +209,11 @@ fn test_extract_solution_rejects_selected_clause_vertex() { fn test_negated_variable_connection() { // (NOT x1 OR NOT x2) - both negated let sat = Satisfiability::new(2, vec![CNFClause::new(vec![-1, -2])]); - let reduction = ReduceTo::>::reduce_to(&sat) - .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + let reduction = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&sat) + .expect("reduction should succeed"); + let ds_problem = reduction.target_problem().inner(); // 2 * 3 = 6 gadget vertices + 1 clause = 7 assert_eq!(ds_problem.graph().num_vertices(), 7); @@ -233,8 +260,10 @@ fn test_jl_parity_sat_to_dominatingset() { let inst = &jl_find_instance_by_label(&sat_data, label)["instance"]; let (num_vars, clauses) = jl_parse_sat_clauses(inst); let source = Satisfiability::new(num_vars, clauses); - let result = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let result = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); let solver = BruteForce::new(); let sat_solutions: HashSet> = solver .find_all_witnesses(&source) @@ -244,12 +273,12 @@ fn test_jl_parity_sat_to_dominatingset() { for case in data["cases"].as_array().unwrap() { if sat_solutions.is_empty() { let target_solution = BruteForce::new() - .solve(result.target_problem()) + .solve(result.target_problem().inner()) .unwrap() .expect("SAT->DS: target should have an optimal solution"); assert!(result.extract_solution(&target_solution).is_err()); } else { - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, &format!("SAT->DS [{label}]"), @@ -278,12 +307,14 @@ fn test_sat_to_dominatingset_native_certificates() { (3, vec![vec![1], vec![]]), ] { let source = Satisfiability::new(n, clauses.into_iter().map(CNFClause::new).collect()); - let result = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = result.target_problem(); + let result = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); + let target = result.target_problem().inner(); assert!(std::ptr::eq( target, - crate::rules::AggregateReductionResult::target_problem(&result) + crate::rules::AggregateReductionResult::target_problem(&result).inner() )); let mut accepted = false; for mask in 0..(1usize << target.num_vertices()) { @@ -291,9 +322,15 @@ fn test_sat_to_dominatingset_native_certificates() { .map(|i| mask & (1 << i) != 0) .collect(); let value = target.evaluate(&config).unwrap(); - let certificate = value == Min(Some(result.target_size)); + let certificate = value == Min(Some(*result.target.bound())); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&result, value), + crate::rules::AggregateReductionResult::extract_value( + &result, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&result).bound() + )) + ), Or(certificate) ); match result.extract_solution(&config) { @@ -319,10 +356,12 @@ fn test_sat_to_dominatingset_native_certificates() { fn test_sat_to_dominatingset_sparse_declared_variables() { for clauses in [vec![], vec![CNFClause::new(vec![i64::MAX])]] { let source = Satisfiability::new(i64::MAX as usize, clauses); - let result = - ReduceTo::>::reduce_to(&source).unwrap(); + let result = ReduceTo::< + crate::models::decision::Decision>, + >::reduce_to(&source) + .unwrap(); assert_eq!(result.num_literals(), i64::MAX as usize); - assert!(result.target_problem().num_vertices() <= 4); + assert!(result.target_problem().inner().num_vertices() <= 4); // Construction is compact. Extracting an i64::MAX-length source vector // is intentionally not attempted in a unit test. } diff --git a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs index 3db2ebc6d..278830031 100644 --- a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs +++ b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs @@ -1,9 +1,10 @@ use super::*; use crate::models::formula::{CNFClause, Maximum2Satisfiability, Satisfiability}; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::traits::ReduceTo; use crate::solvers::BruteForce; use crate::traits::Problem; +use crate::types::{Max, Or}; #[test] fn test_satisfiability_to_maximum2satisfiability_structure() { @@ -13,10 +14,12 @@ fn test_satisfiability_to_maximum2satisfiability_structure() { ); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); - let aggregate_target = crate::rules::AggregateReductionResult::target_problem(&reduction); + let aggregate_target = + crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); assert!(std::ptr::eq(target, aggregate_target)); assert_eq!(aggregate_target.num_clauses(), 30); assert_eq!(target.num_vars(), 7); @@ -35,10 +38,11 @@ fn test_satisfiability_to_maximum2satisfiability_closed_loop() { ); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "SAT -> Maximum2Satisfiability closed loop", @@ -58,8 +62,9 @@ fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { let source = Satisfiability::new(1, vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])]); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); assert_eq!( target @@ -75,7 +80,13 @@ fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { .expect("MAX-2-SAT target should always have a witness"); assert!(reduction.extract_solution(&target_solution).is_err()); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Max(Some(55))), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Max(Some(55))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(false) ); } @@ -85,8 +96,9 @@ fn test_satisfiability_to_maximum2satisfiability_empty_clause() { let source = Satisfiability::new(1, vec![CNFClause::new(vec![])]); let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - let target = reduction.target_problem(); + ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); assert_eq!(target.num_vars(), 4); assert_eq!(target.num_clauses(), 20); @@ -143,8 +155,12 @@ fn test_satisfiability_to_maximum2satisfiability_every_target_witness() { } } for source in sources { - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = + ReduceTo::>::reduce_to( + &source, + ) + .unwrap(); + let target = reduction.target_problem().inner(); let threshold = (target.num_clauses() / 10 * 7) as i64; let mut best = 0; for bits in 0usize..(1 << target.num_vars()) { @@ -155,7 +171,13 @@ fn test_satisfiability_to_maximum2satisfiability_every_target_witness() { best = best.max(value.0.unwrap()); let expected = value == Max(Some(threshold)); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(expected) ); if expected { @@ -171,7 +193,13 @@ fn test_satisfiability_to_maximum2satisfiability_every_target_witness() { .extract_solution(&vec![false; target.num_vars() + 1]) .is_err()); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Max(None)), + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Max(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + ), Or(false) ); } diff --git a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs index 4e08c58c5..4a1cb6a1c 100644 --- a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs +++ b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs @@ -1,20 +1,25 @@ use super::*; use crate::models::algebraic::ClosestVectorProblem; use crate::traits::Problem; +use crate::types::{Min, Or}; #[test] fn test_subsetsum_to_closestvectorproblem_closed_loop() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target_solution = - crate::solvers::customized::closest_vector_problem::solve(reduction.target_problem()) + let reduction = + ReduceTo::>::reduce_to(&source) .unwrap(); + let target_solution = crate::solvers::customized::closest_vector_problem::solve( + reduction.target_problem().inner(), + ) + .unwrap(); let source_solution = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&source_solution).unwrap().0); assert_eq!( reduction .target_problem() + .inner() .evaluate(&target_solution) .unwrap() .0, @@ -25,8 +30,10 @@ fn test_subsetsum_to_closestvectorproblem_closed_loop() { #[test] fn test_subsetsum_to_closestvectorproblem_structure() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let target = reduction.target_problem().inner(); let expected: serde_json::Value = serde_json::json!({"basis": [[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1], [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1], [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2]], "target": [0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1]}); assert_eq!(serde_json::to_value(target).unwrap(), expected); @@ -36,8 +43,10 @@ fn test_subsetsum_to_closestvectorproblem_structure() { #[test] fn test_subsetsum_to_closestvectorproblem_binary_minimizers() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let target = reduction.target_problem().inner(); for solution in [vec![1, 0, 0, 1, 0, 0, 0], vec![1, 1, 1, 0, 1, 1, 1]] { assert_eq!(target.evaluate(&solution).unwrap().0, Some(4)); @@ -53,13 +62,17 @@ fn test_subsetsum_to_closestvectorproblem_binary_minimizers() { #[test] fn test_subsetsum_to_closestvectorproblem_unsatisfiable_instance() { let source = SubsetSum::new(vec![2u32, 4, 6], 5u32); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let solution = - crate::solvers::customized::closest_vector_problem::solve(reduction.target_problem()) + let reduction = + ReduceTo::>::reduce_to(&source) .unwrap(); + let solution = crate::solvers::customized::closest_vector_problem::solve( + reduction.target_problem().inner(), + ) + .unwrap(); assert!( reduction .target_problem() + .inner() .evaluate(&solution) .unwrap() .unwrap() @@ -72,24 +85,29 @@ fn test_subsetsum_to_closestvectorproblem_large_integers_and_unit_pivots() { use num_bigint::BigUint; let size = BigUint::from(1u32) << 70usize; let source = SubsetSum::new(vec![size.clone()], size); - let result = ReduceTo::::reduce_to(&source).unwrap(); - let mut witness = vec![0; result.target_problem().num_basis_vectors()]; + let result = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let mut witness = vec![0; result.target_problem().inner().num_basis_vectors()]; witness[0] = 1; assert_eq!( - result.target_problem().evaluate(&witness).unwrap(), + result.target_problem().inner().evaluate(&witness).unwrap(), Min(Some(1)) ); assert_eq!(result.extract_solution(&witness).unwrap(), vec![true]); assert!(result .target_problem() + .inner() .basis() .iter() .flatten() .all(|&x| (-2..=1).contains(&x))); let source = SubsetSum::new(vec![1u32; 40], 20u32); - let result = ReduceTo::::reduce_to(&source).unwrap(); - let mut witness = vec![0; result.target_problem().num_basis_vectors()]; + let result = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let mut witness = vec![0; result.target_problem().inner().num_basis_vectors()]; witness[..20].fill(1); witness[40..].copy_from_slice(&[1, 2, 5, 10]); assert!( @@ -111,11 +129,13 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { (vec![2, 4], 5), ] { let source = SubsetSum::new(sizes, target_sum); - let result = ReduceTo::::reduce_to(&source).unwrap(); - let target = result.target_problem(); + let result = + ReduceTo::>::reduce_to(&source) + .unwrap(); + let target = result.target_problem().inner(); assert!(std::ptr::eq( target, - crate::rules::AggregateReductionResult::target_problem(&result) + crate::rules::AggregateReductionResult::target_problem(&result).inner() )); let dimensions = target.num_basis_vectors(); let mut accepted = false; @@ -129,9 +149,15 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { }) .collect(); let value = target.evaluate(&config).unwrap(); - let certificate = value == Min(Some(result.target_squared_distance)); + let certificate = value == Min(Some(*result.target.bound())); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&result, value), + crate::rules::AggregateReductionResult::extract_value( + &result, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&result).bound() + )) + ), Or(certificate) ); match result.extract_solution(&config) { @@ -152,7 +178,13 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { ); assert!(result.extract_solution(&vec![0; dimensions + 1]).is_err()); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&result, Min(None)), + crate::rules::AggregateReductionResult::extract_value( + &result, + crate::types::Or(crate::types::OptimizationValue::meets_bound( + &(Min(None)), + crate::rules::ReductionResult::target_problem(&result).bound() + )) + ), Or(false) ); } diff --git a/src/unit_tests/solvers/customized/closest_vector_problem.rs b/src/unit_tests/solvers/customized/closest_vector_problem.rs index 38b1668b3..231f41325 100644 --- a/src/unit_tests/solvers/customized/closest_vector_problem.rs +++ b/src/unit_tests/solvers/customized/closest_vector_problem.rs @@ -136,3 +136,29 @@ fn test_cvp_pruning_handles_nearly_parallel_integer_columns() { .unwrap(); assert_eq!(solve(&problem).unwrap(), vec![-n - 2, n + 1]); } + +#[test] +fn decision_cvp_uses_the_exact_optimum_and_bound() { + use crate::models::decision::Decision; + let key = ExactProblemKey::new( + Decision::::NAME, + BTreeMap::from([("target".to_string(), "i64".to_string())]), + ); + let solver = crate::solvers::registry::solver_capability_registry() + .unwrap() + .lookup(&key) + .customized + .unwrap(); + for (bound, expected) in [(-1, false), (0, false), (1, true), (2, true)] { + let problem = Decision::new( + ClosestVectorProblem::new(vec![vec![2]], vec![1]).unwrap(), + bound, + ); + let solution = (solver.solve_fn)(&problem).unwrap(); + assert_eq!(solution.is_some(), expected); + if let Some(solution) = solution { + let solution = serde_json::from_value(solution).unwrap(); + assert!(problem.evaluate(&solution).unwrap().0); + } + } +} diff --git a/tests/suites/reductions.rs b/tests/suites/reductions.rs index a60ece8ea..4ec221377 100644 --- a/tests/suites/reductions.rs +++ b/tests/suites/reductions.rs @@ -339,8 +339,10 @@ mod partition_into_cliques_covering_by_cliques_reductions { fn test_partition_into_cliques_to_covering_by_cliques_closed_loop() { let source = PartitionIntoCliques::new(SimpleGraph::empty(1), 1); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + problemreductions::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); let target_solution = BruteForce::new() @@ -355,12 +357,14 @@ mod partition_into_cliques_covering_by_cliques_reductions { #[test] fn test_partition_into_cliques_to_covering_by_cliques_orlin_issue_counts() { let source = PartitionIntoCliques::new(SimpleGraph::new(3, vec![(0, 1)]), 2); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::< + problemreductions::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); - assert_eq!(target.graph().num_vertices(), 14); - assert_eq!(target.graph().num_edges(), 53); + assert_eq!(target.inner().graph().num_vertices(), 14); + assert_eq!(target.inner().graph().num_edges(), 53); } } @@ -614,7 +618,9 @@ mod qubo_reductions { data.source.num_vertices, data.source.edges, )); - let reduction = ReduceTo::::reduce_to(&kc).expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&kc) + .expect("reduction should succeed"); let qubo = reduction.target_problem(); assert_eq!(qubo.num_variables(), data.qubo_num_vars); @@ -721,7 +727,9 @@ mod qubo_reductions { .collect(); let ksat = KSatisfiability::::new(data.source.num_variables, clauses); - let reduction = ReduceTo::::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&ksat) + .expect("reduction should succeed"); let qubo = reduction.target_problem(); assert_eq!(qubo.num_variables(), data.qubo_num_vars); From d6c02567062ae841dcc8d6a093e16ee01d6cfe2d Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 15:56:02 +0800 Subject: [PATCH 15/44] Recover negative decision results through ILP pipelines --- docs/paper/reductions.typ | 2 +- src/solvers/ilp/solver.rs | 9 +- src/solvers/registry.rs | 42 +++++-- src/unit_tests/rules/circuit_spinglass.rs | 81 ++++--------- src/unit_tests/rules/coloring_qubo.rs | 37 +++--- ...imumdominatingset_minimumsummulticenter.rs | 44 ++++--- .../hamiltoniancircuit_quadraticassignment.rs | 56 ++++----- .../rules/hamiltoniancircuit_stackercrane.rs | 38 +++---- ...onianpathbetweentwovertices_longestpath.rs | 41 ++----- src/unit_tests/rules/ksatisfiability_qubo.rs | 64 +++++------ .../rules/naesatisfiability_maxcut.rs | 62 ++++------ .../rules/partition_openshopscheduling.rs | 59 +++------- ...ion_sequencingtominimizetardytaskweight.rs | 66 ++++------- ...ionintocliques_minimumcoveringbycliques.rs | 39 +++---- .../rules/sat_maximumindependentset.rs | 99 ++++++---------- .../rules/sat_minimumdominatingset.rs | 107 +++++++----------- .../satisfiability_maximum2satisfiability.rs | 39 +++---- .../rules/subsetsum_closestvectorproblem.rs | 44 ++----- src/unit_tests/solvers/registry.rs | 41 ++++++- src/unit_tests/solvers/resolver.rs | 77 +------------ 20 files changed, 387 insertions(+), 660 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b61d587a1..a779cf003 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -11646,7 +11646,7 @@ the displayed rule, extracted from the corresponding `pred path` entry. _Correctness._ Every finite target placement must select both isolated vertices. If a source dominating set $D$ has $|D|<=K$, then $q>=0$ and $|D|<=q<=n$. Extend $D$ to $q$ original vertices and add $a,b$. This placement has $k$ centers and radius at most $1$, proving the forward direction. Conversely, a target placement of radius at most $1$ selects both isolates and exactly $q$ original vertices. Each original vertex is within one original edge of a selected vertex, so those $q<=K$ vertices dominate $G$. For $K<0$, $k=1$ cannot cover both isolates and the target has no finite placement. For $n=0,K>=0$, the two isolates form a radius-zero placement. Loops and repeated edges preserve this reasoning. - _Solution extraction and NO instances._ The target is Decision Min-Max Multicenter with bound $1$. Its predicate checks the full placement. Decode a YES witness by taking its first $n$ bits; completed YES and NO answers pass through unchanged. In particular, a four-vertex path with $K=1$ produces optimum radius $2$, not an infeasible target. Checked parameter arithmetic precedes allocation; unrepresentable counts return the formal numeric error. Target sizes are exactly $n+2$ vertices and $m$ edge records. + _Solution extraction and NO instances._ The target is Decision Min-Max Multicenter with bound $1$. Its predicate checks the full placement. Decode a YES witness by taking its first $n$ bits; completed YES and NO answers pass through unchanged. In particular, a four-vertex path with $K=1$ produces an inner optimum radius of $2$, so the decision target answers NO. Checked parameter arithmetic precedes allocation; unrepresentable counts return the formal numeric error. Target sizes are exactly $n+2$ vertices and $m$ edge records. ] #let dmds_msmc = load-example( diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 089d73c1c..05893d30a 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -9,14 +9,9 @@ use crate::traits::Problem; /// A failure to produce an ILP solution optimal within backend numerical tolerances. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum ILPSolveError { - /// The constraints have no feasible assignment. - #[error("the ILP is infeasible")] + /// The source problem has no feasible solution. + #[error("the problem is infeasible")] Infeasible, - /// A target witness did not establish the source decision threshold. - #[error( - "the ILP witness does not meet the decision threshold for {0}; the decision is unresolved" - )] - UnresolvedDecision(String), /// The objective is unbounded. #[error("the ILP objective is unbounded")] Unbounded, diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index 2e99fd700..bd19098ea 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -154,10 +154,9 @@ impl CompiledIlpPipeline { reductions[index - 1].target_problem_any() }; let aggregate = view(step.as_ref())?; - // This pipeline returns a witness, not an aggregate result. A negative - // decision value has no source witness for the extractor to recover. - let value = aggregate.extract_value_from_solution_dyn(source_solution.as_ref())?; - let source = crate::registry::find_variant_entry( + let mut value = + aggregate.extract_value_from_solution_dyn(source_solution.as_ref())?; + let input_problem = crate::registry::find_variant_entry( &self.path[index].name, &self.path[index].variant, ) @@ -165,14 +164,41 @@ impl CompiledIlpPipeline { .ok_or_else(|| { crate::rules::ExtractionError::invalid("pipeline source type mismatch") })?; - if source + if input_problem .aggregate_witness_evaluation(&value) .map_err(crate::rules::ExtractionError::from)? .is_none() { - return Err(super::ILPSolveError::UnresolvedDecision( - self.path[index].label(), - )); + // No witness exists at this step. Recover the completed value + // through every remaining rule instead of invoking its decoder. + for previous in (0..index).rev() { + let view = self.reducers[previous].1.ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "cannot recover a completed value for {}: missing aggregate mapping", + self.path[previous].label() + )) + })?; + value = view(reductions[previous].as_ref())?.extract_value_dyn(value)?; + } + let original = crate::registry::find_variant_entry( + &self.path[0].name, + &self.path[0].variant, + ) + .and_then(|entry| (entry.borrow_fn)(source)) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid("pipeline source type mismatch") + })?; + if original + .aggregate_witness_evaluation(&value) + .map_err(crate::rules::ExtractionError::from)? + .is_none() + { + return Err(super::ILPSolveError::Infeasible); + } + return Err(crate::rules::ExtractionError::invalid( + "cannot recover a source witness from a value-only result", + ) + .into()); } } source_solution = step.extract_solution_dyn(source_solution.as_ref())?; diff --git a/src/unit_tests/rules/circuit_spinglass.rs b/src/unit_tests/rules/circuit_spinglass.rs index 02f8dde17..26f961931 100644 --- a/src/unit_tests/rules/circuit_spinglass.rs +++ b/src/unit_tests/rules/circuit_spinglass.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::models::formula::Circuit; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; @@ -147,10 +148,7 @@ fn test_constant_true() { BooleanExpr::constant(true), )]); let problem = CircuitSAT::new(circuit); - let reduction = - ReduceTo::>>::reduce_to( - &problem, - ) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); let sg = reduction.target_problem().inner(); @@ -178,10 +176,7 @@ fn test_constant_false() { BooleanExpr::constant(false), )]); let problem = CircuitSAT::new(circuit); - let reduction = - ReduceTo::>>::reduce_to( - &problem, - ) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); let sg = reduction.target_problem().inner(); @@ -213,10 +208,7 @@ fn test_multi_input_and() { ]), )]); let problem = CircuitSAT::new(circuit); - let reduction = - ReduceTo::>>::reduce_to( - &problem, - ) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); let sg = reduction.target_problem().inner(); @@ -250,10 +242,7 @@ fn test_reduction_result_methods() { BooleanExpr::var("x"), )]); let problem = CircuitSAT::new(circuit); - let reduction = - ReduceTo::>>::reduce_to( - &problem, - ) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); // Test target_problem and extract_solution work @@ -265,10 +254,7 @@ fn test_reduction_result_methods() { fn test_empty_circuit() { let circuit = Circuit::new(vec![]); let problem = CircuitSAT::new(circuit); - let reduction = - ReduceTo::>>::reduce_to( - &problem, - ) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); let sg = reduction.target_problem().inner(); @@ -283,10 +269,7 @@ fn test_solution_extraction() { BooleanExpr::and(vec![BooleanExpr::var("x"), BooleanExpr::var("y")]), )]); let problem = CircuitSAT::new(circuit); - let reduction = - ReduceTo::>>::reduce_to( - &problem, - ) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); // The source variables are c, x, y (sorted) @@ -317,10 +300,7 @@ fn test_jl_parity_circuitsat_to_spinglass() { Assignment::new(vec!["z".to_string()], z_expr), ]); let source = CircuitSAT::new(circuit); - let result = - ReduceTo::>>::reduce_to( - &source, - ) + let result = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( &source, @@ -361,10 +341,8 @@ fn test_circuit_spinglass_all_threshold_witnesses_native_domain() { vec![output.into()], expr.clone(), )])); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); let target = AggregateReductionResult::target_problem(&reduction).inner(); let expected: BTreeSet<_> = BruteForce::new() .find_all_witnesses(&source) @@ -379,12 +357,11 @@ fn test_circuit_spinglass_all_threshold_witnesses_native_domain() { let energy = target.evaluate(&spins).unwrap(); assert!(energy.0.unwrap() >= *reduction.target.bound()); if reduction - .extract_value(crate::types::Or( - crate::types::OptimizationValue::meets_bound( - &(energy), - crate::rules::ReductionResult::target_problem(&reduction).bound(), - ), - )) + .extract_value( + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&spins) + .unwrap(), + ) .0 { let decoded = reduction.extract_solution(&spins).unwrap(); @@ -395,16 +372,7 @@ fn test_circuit_spinglass_all_threshold_witnesses_native_domain() { } } assert_eq!(actual, expected, "expression {expr:?}, output {output}"); - assert!( - !reduction - .extract_value(crate::types::Or( - crate::types::OptimizationValue::meets_bound( - &(crate::types::Min(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - ) - )) - .0 - ); + assert!(!reduction.extract_value(crate::types::Or(false)).0); } } } @@ -416,11 +384,7 @@ fn test_circuit_spinglass_unsat_threshold_and_invalid_spins() { vec!["x".into()], BooleanExpr::not(BooleanExpr::var("x")), )])); - let reduction = - ReduceTo::>>::reduce_to( - &source, - ) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); assert_eq!(*reduction.target.bound(), -5); assert!( !reduction @@ -442,11 +406,7 @@ fn test_circuit_spinglass_unsat_threshold_and_invalid_spins() { assert!(reduction.extract_solution(&bad).is_err()); } let empty = CircuitSAT::new(Circuit::new(vec![])); - let reduction = - ReduceTo::>>::reduce_to( - &empty, - ) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(&empty).unwrap(); assert!( reduction .extract_value(crate::types::Or( @@ -488,10 +448,7 @@ fn test_circuit_spinglass_variadic_constant_overhead() { let expr = BooleanExpr::xor(args); let source = CircuitSAT::new(Circuit::new(vec![Assignment::new(vec![], expr)])); let reduction = - ReduceTo::>>::reduce_to( - &source, - ) - .unwrap(); + ReduceTo::>>::reduce_to(&source).unwrap(); let target = reduction.target_problem().inner(); let expected = if width == 0 { 1 diff --git a/src/unit_tests/rules/coloring_qubo.rs b/src/unit_tests/rules/coloring_qubo.rs index 7732b8a8f..84ffdd462 100644 --- a/src/unit_tests/rules/coloring_qubo.rs +++ b/src/unit_tests/rules/coloring_qubo.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; @@ -8,8 +9,8 @@ use crate::variant::{K2, K3}; fn test_kcoloring_to_qubo_closed_loop() { // Triangle K3, 3 colors → exactly 6 valid colorings (3! permutations) let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); - let reduction = ReduceTo::>>::reduce_to(&kc) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&kc).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -29,8 +30,8 @@ fn test_kcoloring_to_qubo_closed_loop() { fn test_kcoloring_to_qubo_path() { // Path graph: 0-1-2, 2 colors let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])); - let reduction = ReduceTo::>>::reduce_to(&kc) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&kc).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -50,8 +51,8 @@ fn test_kcoloring_to_qubo_reversed_edges() { // Edge (2, 0) triggers the idx_v < idx_u swap branch (line 104). // Path: 2-0-1 with reversed edge ordering let kc = KColoring::::new(SimpleGraph::new(3, vec![(2, 0), (0, 1)])); - let reduction = ReduceTo::>>::reduce_to(&kc) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&kc).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -69,8 +70,8 @@ fn test_kcoloring_to_qubo_reversed_edges() { #[test] fn test_kcoloring_to_qubo_sizes() { let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); - let reduction = ReduceTo::>>::reduce_to(&kc) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&kc).expect("reduction should succeed"); // QUBO should have n*K = 3*3 = 9 variables assert_eq!(reduction.target_problem().inner().num_variables(), 9); @@ -91,9 +92,7 @@ fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { .collect(); for k in 0..=3 { let source = KColoring::::with_k(SimpleGraph::new(n, edges.clone()), k); - let reduction = - ReduceTo::>>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); let target = AggregateReductionResult::target_problem(&reduction).inner(); assert_eq!(target.num_vars(), n * k); let mut minimum = i64::MAX; @@ -121,10 +120,9 @@ fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { assert_eq!( AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() ) .0, expected @@ -150,14 +148,7 @@ fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { any_coloring ); assert!( - !AggregateReductionResult::extract_value( - &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(crate::types::Min(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) - ) - .0 + !AggregateReductionResult::extract_value(&reduction, crate::types::Or(false)).0 ); assert!(reduction.extract_solution(&vec![false; n * k + 1]).is_err()); } diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 5e5a780dd..ffbe8d295 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -27,10 +27,9 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_structure() { &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (4, 5)], 2, ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); assert_eq!( crate::rules::AggregateReductionResult::target_problem(&reduction) @@ -56,10 +55,9 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_yes_in &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (4, 5)], 2, ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let target_solutions = BruteForce::new().find_all_witnesses(target).unwrap(); @@ -83,10 +81,9 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (4, 5)], 1, ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let target_solutions = BruteForce::new().find_all_witnesses(target).unwrap(); @@ -105,10 +102,9 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(Min(Some(target_value))), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&target_solution) + .unwrap() ), Or(false) ); @@ -137,10 +133,11 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_all_small_graphs() .chain(0..=i64::try_from(n).unwrap() + 1); for bound in bounds { let source = decision_mds(n, &edges, bound); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>>::reduce_to( + &source, + ) + .unwrap(); let target = reduction.target_problem().inner(); assert!(target.num_vertices() <= n + 2); assert_eq!(target.num_edges(), edges.len()); @@ -156,10 +153,9 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_all_small_graphs() } let accepted = crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound(), - )), + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&placement) + .unwrap(), ) .0; match reduction.extract_solution(&placement) { diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs index 952c42b58..061079c08 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -1,4 +1,5 @@ use crate::models::algebraic::QuadraticAssignment; +use crate::models::decision::Decision; use crate::models::graph::HamiltonianCircuit; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; @@ -15,9 +16,8 @@ fn cycle4_hc() -> HamiltonianCircuit { #[test] fn test_hamiltoniancircuit_to_quadraticassignment_closed_loop() { let source = cycle4_hc(); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( &source, @@ -29,9 +29,8 @@ fn test_hamiltoniancircuit_to_quadraticassignment_closed_loop() { #[test] fn test_hamiltoniancircuit_to_quadraticassignment_structure() { let source = cycle4_hc(); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); assert_eq!(target.num_facilities(), 4); @@ -59,9 +58,8 @@ fn test_hamiltoniancircuit_to_quadraticassignment_structure() { #[test] fn test_hamiltoniancircuit_to_quadraticassignment_optimal_cost_is_zero() { let source = cycle4_hc(); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); // The identity permutation [0,1,2,3] is a valid HC on a 4-cycle, @@ -78,9 +76,8 @@ fn test_hamiltoniancircuit_to_quadraticassignment_optimal_cost_is_zero() { fn test_hamiltoniancircuit_to_quadraticassignment_nonhamiltonian_cost_gap() { // Star graph on 4 vertices has no Hamiltonian circuit let source = HamiltonianCircuit::new(SimpleGraph::star(4)); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let best = BruteForce::new() @@ -102,9 +99,8 @@ fn test_hamiltoniancircuit_to_quadraticassignment_nonhamiltonian_cost_gap() { #[test] fn test_hamiltoniancircuit_to_quadraticassignment_extract_solution() { let source = cycle4_hc(); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); // Permutation [0,1,2,3] visits 0->1->2->3->0 on cycle4 let target_config = vec![0, 1, 2, 3]; @@ -136,7 +132,7 @@ fn test_prism_graph_hc_via_qap_ilp_roundtrip() { let hc = HamiltonianCircuit::new(SimpleGraph::new(6, edges)); // HC → QAP → ILP → solve → extract back - let r1 = ReduceTo::>::reduce_to(&hc) + let r1 = ReduceTo::>::reduce_to(&hc) .expect("reduction should succeed"); let r2 = ReduceTo::>::reduce_to(r1.target_problem().inner()) .expect("reduction should succeed"); @@ -165,9 +161,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_small_graphs_are_no() { ] { let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); assert!(!source.evaluate(&(0..n).collect()).unwrap().0); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem().inner(); assert_eq!(target.num_facilities(), 3); assert_eq!(target.num_locations(), 3); @@ -177,10 +171,9 @@ fn test_hamiltoniancircuit_to_quadraticassignment_small_graphs_are_no() { assert!( !crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&best) + .unwrap() ) .0 ); @@ -190,9 +183,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_small_graphs_are_no() { #[test] fn test_hamiltoniancircuit_to_quadraticassignment_rejects_invalid_certificates() { - let reduction = - ReduceTo::>::reduce_to(&cycle4_hc()) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&cycle4_hc()).unwrap(); for config in [ vec![], vec![0, 1, 2], @@ -243,11 +234,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_all_small_graphs_and_orders() edges.extend((0..n).map(|v| (v, v))); edges.extend(edges.clone()); let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); - let reduction = - ReduceTo::>::reduce_to( - &source, - ) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); for mut encoded in 0..n.pow(u32::try_from(n).unwrap()) { let order: Vec<_> = (0..n) .map(|_| { @@ -277,10 +264,9 @@ fn test_hamiltoniancircuit_to_quadraticassignment_all_small_graphs_and_orders() assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&order) + .unwrap() ) .0, expected diff --git a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs index 0d00ac480..9aa4c1038 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs @@ -1,3 +1,4 @@ +use crate::models::decision::Decision; use crate::models::graph::HamiltonianCircuit; use crate::models::misc::StackerCrane; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; @@ -15,8 +16,8 @@ fn cycle4_hc() -> HamiltonianCircuit { #[test] fn test_hamiltoniancircuit_to_stackercrane_closed_loop() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( &source, @@ -28,8 +29,8 @@ fn test_hamiltoniancircuit_to_stackercrane_closed_loop() { #[test] fn test_hamiltoniancircuit_to_stackercrane_structure() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target = reduction.target_problem().inner(); // 4 vertices -> 8 target vertices (2 per original vertex) @@ -53,8 +54,8 @@ fn test_hamiltoniancircuit_to_stackercrane_structure() { fn test_hamiltoniancircuit_to_stackercrane_optimal_cost() { // A 4-cycle has a Hamiltonian circuit; optimal StackerCrane cost = 2n = 8. let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target = reduction.target_problem().inner(); let witness = BruteForce::new() @@ -70,8 +71,8 @@ fn test_hamiltoniancircuit_to_stackercrane_non_hamiltonian() { // Star graph on 4 vertices: no Hamiltonian circuit. // The optimal StackerCrane cost should exceed 2n = 8. let source = HamiltonianCircuit::new(SimpleGraph::star(4)); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target = reduction.target_problem().inner(); let witness = BruteForce::new().solve(target).unwrap(); @@ -92,8 +93,8 @@ fn test_hamiltoniancircuit_to_stackercrane_non_hamiltonian() { #[test] fn test_hamiltoniancircuit_to_stackercrane_extract_solution() { let source = cycle4_hc(); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); // The identity permutation [0, 1, 2, 3] traverses arcs in order, // corresponding to vertex order 0, 1, 2, 3 in the original graph. @@ -123,8 +124,8 @@ fn test_hamiltoniancircuit_to_stackercrane_prism_graph() { (2, 5), ]; let source = HamiltonianCircuit::new(SimpleGraph::new(6, edges)); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( &source, @@ -146,10 +147,7 @@ fn test_stackercrane_certificate_for_all_small_configurations() { .filter_map(|(i, &e)| ((mask >> i) & 1 == 1).then_some(e)) .collect(); let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); // All coordinate configurations, including repeated arc indices. for mut code in 0..n.pow(n as u32) { let config: Vec<_> = (0..n) @@ -160,14 +158,12 @@ fn test_stackercrane_certificate_for_all_small_configurations() { }) .collect(); let expected = source.evaluate(&config).unwrap().0; - let value = target.evaluate(&config).unwrap(); assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() ) .0, expected diff --git a/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs index 09f5907ca..fbd7c8fbe 100644 --- a/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::models::graph::{HamiltonianPathBetweenTwoVertices, LongestPath}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; @@ -14,10 +15,7 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_closed_loop() { 0, 4, ); - let result = - ReduceTo::>>::reduce_to( - &source, - ) + let result = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); let target = result.target_problem().inner(); @@ -41,10 +39,7 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_path_graph() { 0, 3, ); - let result = - ReduceTo::>>::reduce_to( - &source, - ) + let result = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( @@ -64,10 +59,7 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_no_hamiltonian_path() { 1, 2, ); - let result = - ReduceTo::>>::reduce_to( - &source, - ) + let result = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); let solver = BruteForce::new(); let target_best = solver @@ -91,10 +83,7 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_complete_graph() { 0, 3, ); - let result = - ReduceTo::>>::reduce_to( - &source, - ) + let result = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( @@ -112,10 +101,7 @@ fn test_hamiltonianpathbetweentwovertices_to_longestpath_triangle() { 0, 2, ); - let result = - ReduceTo::>>::reduce_to( - &source, - ) + let result = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); let target = result.target_problem().inner(); @@ -152,10 +138,9 @@ fn test_hamiltonian_path_extraction_for_all_small_graphs_and_endpoints() { start, end, ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); for mask in 0usize..(1 << edges.len()) { @@ -166,11 +151,9 @@ fn test_hamiltonian_path_extraction_for_all_small_graphs_and_endpoints() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction) - .bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() ) .0, expected diff --git a/src/unit_tests/rules/ksatisfiability_qubo.rs b/src/unit_tests/rules/ksatisfiability_qubo.rs index 65677a0af..3bb229434 100644 --- a/src/unit_tests/rules/ksatisfiability_qubo.rs +++ b/src/unit_tests/rules/ksatisfiability_qubo.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::models::formula::CNFClause; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; @@ -18,8 +19,8 @@ fn test_ksatisfiability_to_qubo_closed_loop() { CNFClause::new(vec![-2, -3]), // ¬x2 ∨ ¬x3 ], ); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -36,8 +37,8 @@ fn test_ksatisfiability_to_qubo_closed_loop() { fn test_ksatisfiability_to_qubo_simple() { // 2 vars, 1 clause: (x1 ∨ x2) → 3 satisfying assignments let ksat = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, 2])]); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -61,8 +62,8 @@ fn test_ksatisfiability_to_qubo_contradiction() { CNFClause::new(vec![-1, -1]), // ¬x1 ∨ ¬x1 = ¬x1 ], ); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -83,8 +84,8 @@ fn test_ksatisfiability_to_qubo_reversed_vars() { CNFClause::new(vec![1, 2]), ], ); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -102,8 +103,8 @@ fn test_ksatisfiability_to_qubo_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); // QUBO should have at least the original variables @@ -125,8 +126,8 @@ fn test_k3satisfiability_to_qubo_closed_loop() { CNFClause::new(vec![3, -4, -5]), // x3 ∨ ¬x4 ∨ ¬x5 ], ); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); // QUBO should have 5 + 7 = 12 variables @@ -148,8 +149,8 @@ fn test_k3satisfiability_to_qubo_closed_loop() { fn test_k3satisfiability_to_qubo_single_clause() { // Single 3-SAT clause: (x1 ∨ x2 ∨ x3) — 7 satisfying assignments let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, 3])]); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); // 3 vars + 1 auxiliary = 4 total @@ -172,8 +173,8 @@ fn test_k3satisfiability_to_qubo_single_clause() { fn test_k3satisfiability_to_qubo_all_negated() { // All negated: (¬x1 ∨ ¬x2 ∨ ¬x3) — 7 satisfying assignments let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![-1, -2, -3])]); - let reduction = ReduceTo::>>::reduce_to(&ksat) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -209,11 +210,7 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { 1, vec![CNFClause::new(a.clone()), CNFClause::new(b.clone())], ); - let reduction = - ReduceTo::>>::reduce_to( - &source, - ) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); let target = ReductionResult::target_problem(&reduction).inner(); let mut minimum = i64::MAX; for mask in 0..(1 << target.num_vars()) { @@ -242,11 +239,9 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { assert_eq!( AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(Min(Some(energy))), - crate::rules::ReductionResult::target_problem(&reduction) - .bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() ), Or(penalty == 0) ); @@ -274,10 +269,7 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { assert_eq!( AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(Min(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::types::Or(false) ), Or(false) ); @@ -289,9 +281,7 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { } for n in [0, 3] { let source = KSatisfiability::<$k>::new(n, vec![]); - let reduction = - ReduceTo::>>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); assert_eq!( reduction.extract_solution(&vec![false; n]).unwrap(), vec![false; n] @@ -316,11 +306,11 @@ fn test_sat_qubo_checked_numeric_boundaries() { let k2 = KSatisfiability::::new(n, vec![]); let k3 = KSatisfiability::::new(n, vec![]); assert!(matches!( - ReduceTo::>>::reduce_to(&k2), + ReduceTo::>>::reduce_to(&k2), Err(crate::rules::ReductionError::IntegerOverflow { .. }) )); assert!(matches!( - ReduceTo::>>::reduce_to(&k3), + ReduceTo::>>::reduce_to(&k3), Err(crate::rules::ReductionError::IntegerOverflow { .. }) )); } @@ -334,9 +324,7 @@ fn test_sat_qubo_registered_aggregate_threshold() { 1, clauses.into_iter().map(CNFClause::new).collect(), ); - let reduction = - ReduceTo::>>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); let mut witness = vec![false; reduction.target.inner().num_vars()]; witness[0] = expected; let entries = crate::rules::registry::reduction_entries(); diff --git a/src/unit_tests/rules/naesatisfiability_maxcut.rs b/src/unit_tests/rules/naesatisfiability_maxcut.rs index 4dd13c929..b492d2436 100644 --- a/src/unit_tests/rules/naesatisfiability_maxcut.rs +++ b/src/unit_tests/rules/naesatisfiability_maxcut.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::models::formula::CNFClause; use crate::models::formula::NAESatisfiability; use crate::models::graph::MaxCut; @@ -19,9 +20,8 @@ fn test_naesatisfiability_to_maxcut_closed_loop() { CNFClause::new(vec![-1, -2, 3]), ], ); - let reduction = - ReduceTo::>>::reduce_to(&naesat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); // 2*3 = 6 vertices @@ -40,9 +40,8 @@ fn test_naesatisfiability_to_maxcut_closed_loop() { fn test_naesatisfiability_to_maxcut_single_clause() { // Single clause: (x1, x2, x3) — NAE-satisfying iff not all same let naesat = NAESatisfiability::new(3, vec![CNFClause::new(vec![1, 2, 3])]); - let reduction = - ReduceTo::>>::reduce_to(&naesat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); // 6 vertices, 3 variable + 3 clause = 6 edges @@ -61,9 +60,8 @@ fn test_naesatisfiability_to_maxcut_two_literal_clause() { // Clause with 2 literals: (x1, ~x2) — always NAE-satisfying unless x1=T, x2=F or x1=F, x2=T... actually (x1, ~x2) is NAE-unsatisfied when both literals are same: x1=T,~x2=T (x2=F) or x1=F,~x2=F (x2=T). // NAE-satisfied when x1 != ~x2, i.e., x1 == x2. let naesat = NAESatisfiability::new(2, vec![CNFClause::new(vec![1, -2])]); - let reduction = - ReduceTo::>>::reduce_to(&naesat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); // 4 vertices, 2 variable + 1 clause = 3 edges @@ -81,9 +79,8 @@ fn test_naesatisfiability_to_maxcut_two_literal_clause() { fn test_naesatisfiability_to_maxcut_four_literal_clause() { // Clause with 4 literals: (x1, x2, ~x3, x4) let naesat = NAESatisfiability::new(4, vec![CNFClause::new(vec![1, 2, -3, 4])]); - let reduction = - ReduceTo::>>::reduce_to(&naesat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); // One auxiliary variable and two triangles: 10 vertices, 5 + 6 edges. @@ -107,9 +104,8 @@ fn test_naesatisfiability_to_maxcut_extract_solution() { CNFClause::new(vec![-1, 3, 2]), ], ); - let reduction = - ReduceTo::>>::reduce_to(&naesat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); // Vertices: x1(0), ~x1(1), x2(2), ~x2(3), x3(4), ~x3(5) // x1=T -> vertex 0 in set 1, vertex 1 in set 0 @@ -134,9 +130,8 @@ fn test_naesatisfiability_to_maxcut_mixed_clause_sizes() { CNFClause::new(vec![-1, -3]), // 2 literals -> 1 pair ], ); - let reduction = - ReduceTo::>>::reduce_to(&naesat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); // 6 vertices, 3 variable + (1 + 3 + 1) = 8 edges @@ -161,9 +156,8 @@ fn test_naesatisfiability_to_maxcut_optimal_cut_value() { CNFClause::new(vec![-1, -2, 3]), ], ); - let reduction = - ReduceTo::>>::reduce_to(&naesat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let solver = BruteForce::new(); @@ -179,9 +173,7 @@ fn test_naesatisfiability_to_maxcut_optimal_cut_value() { fn check_every_cut(source: &NAESatisfiability) { use crate::rules::AggregateReductionResult; - let reduction = - ReduceTo::>>::reduce_to(source) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(source).unwrap(); let target = AggregateReductionResult::target_problem(&reduction).inner(); let mut decoded = vec![false; 1 << source.num_vars()]; let mut best = i64::MIN; @@ -193,10 +185,9 @@ fn check_every_cut(source: &NAESatisfiability) { best = best.max(value.0.unwrap()); let certificate = AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound(), - )), + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&cut) + .unwrap(), ) .0; match reduction.extract_solution(&cut) { @@ -235,16 +226,7 @@ fn check_every_cut(source: &NAESatisfiability) { .0, decoded.iter().any(|&valid| valid) ); - assert!( - !AggregateReductionResult::extract_value( - &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(crate::types::Max(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) - ) - .0 - ); + assert!(!AggregateReductionResult::extract_value(&reduction, crate::types::Or(false)).0); assert!(reduction .extract_solution(&vec![false; target.num_vertices() + 1]) .is_err()); @@ -283,9 +265,7 @@ fn test_naesatisfiability_to_maxcut_long_clause_interactions() { ], ); check_every_cut(&source); - let reduction = - ReduceTo::>>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); assert_eq!(*reduction.target.bound(), 26); for clauses in [ vec![vec![1, 1, 1, 1, 1]], diff --git a/src/unit_tests/rules/partition_openshopscheduling.rs b/src/unit_tests/rules/partition_openshopscheduling.rs index 0559710a9..afab0667b 100644 --- a/src/unit_tests/rules/partition_openshopscheduling.rs +++ b/src/unit_tests/rules/partition_openshopscheduling.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::ILP; +use crate::models::decision::Decision; use crate::models::misc::{OpenShopScheduling, Partition}; use crate::solvers::ILPSolver; use crate::traits::Problem; @@ -15,9 +16,7 @@ fn solve_target(target: &OpenShopScheduling) -> Vec { #[test] fn test_partition_to_open_shop_scheduling_closed_loop() { let source = Partition::new(vec![1, 2, 3]).unwrap(); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target_solution = solve_target(reduction.target_problem().inner()); let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).unwrap()); @@ -26,9 +25,8 @@ fn test_partition_to_open_shop_scheduling_closed_loop() { #[test] fn test_partition_to_open_shop_scheduling_structure() { let source = Partition::new(vec![1, 2, 3]).unwrap(); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); assert_eq!(target.num_jobs(), 4); @@ -42,9 +40,7 @@ fn test_partition_to_open_shop_scheduling_structure() { #[test] fn test_partition_to_open_shop_scheduling_extract_solution() { let source = Partition::new(vec![1, 2, 3]).unwrap(); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target_solution = solve_target(reduction.target_problem().inner()); let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 3); @@ -54,9 +50,7 @@ fn test_partition_to_open_shop_scheduling_extract_solution() { #[test] fn test_partition_to_open_shop_scheduling_odd_total_is_not_satisfying() { let source = Partition::new(vec![2, 4, 5]).unwrap(); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let best = solve_target(reduction.target_problem().inner()); assert!(reduction.extract_solution(&best).is_err()); } @@ -64,9 +58,7 @@ fn test_partition_to_open_shop_scheduling_odd_total_is_not_satisfying() { #[test] fn test_partition_to_open_shop_scheduling_preserves_construction_overflow() { let source = Partition::new(vec![1_i64 << 61, 1_i64 << 61]).unwrap(); - let error = - ReduceTo::>::reduce_to(&source) - .unwrap_err(); + let error = ReduceTo::>::reduce_to(&source).unwrap_err(); assert!(matches!( error, crate::rules::ReductionError::Construction { @@ -98,21 +90,10 @@ fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { }) .collect(); let source = Partition::new(sizes.clone()).unwrap(); - let reduction = - ReduceTo::>::reduce_to( - &source, - ) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = AggregateReductionResult::target_problem(&reduction).inner(); assert!( - !AggregateReductionResult::extract_value( - &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(crate::types::Min(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) - ) - .0 + !AggregateReductionResult::extract_value(&reduction, crate::types::Or(false)).0 ); for mask in 0..(1usize << n) { let assignment: Vec<_> = (0..n).map(|i| mask & (1 << i) != 0).collect(); @@ -141,10 +122,9 @@ fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { assert!( AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap() ) .0 ); @@ -166,9 +146,7 @@ fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { fn test_partition_to_open_shop_odd_singleton_certificate() { use crate::rules::AggregateReductionResult; let source = Partition::new(vec![1]).unwrap(); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let schedule = vec![0, 1, 2, 0, 0, 0]; let value = ReductionResult::target_problem(&reduction) .inner() @@ -178,10 +156,9 @@ fn test_partition_to_open_shop_odd_singleton_certificate() { assert!( !AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap() ) .0 ); @@ -193,9 +170,7 @@ fn test_partition_to_open_shop_odd_singleton_certificate() { fn test_partition_to_open_shop_certificate_near_horizon_limit() { let size = i64::MAX / 9; let source = Partition::new(vec![size, size]).unwrap(); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let a = usize::try_from(size).unwrap(); let schedule = vec![0, a, 2 * a, 2 * a, 0, a, a, 2 * a, 0]; assert_eq!( diff --git a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs index 3f9f2dc2c..8743e1abb 100644 --- a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs @@ -1,5 +1,6 @@ #[cfg(feature = "example-db")] use super::canonical_rule_example_specs; +use crate::models::decision::Decision; use crate::models::misc::{Partition, SequencingToMinimizeTardyTaskWeight}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::traits::ReductionResult; @@ -11,10 +12,8 @@ use crate::types::Min; #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_closed_loop() { let source = Partition::new(vec![3, 1, 1, 2, 2, 1]).unwrap(); - let reduction = ReduceTo::< - crate::models::decision::Decision, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( &source, @@ -26,10 +25,8 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_closed_loop() { #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_structure() { let source = Partition::new(vec![3, 1, 1, 2, 2, 1]).unwrap(); - let reduction = ReduceTo::< - crate::models::decision::Decision, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); assert_eq!(target.lengths(), &[3, 1, 1, 2, 2, 1]); @@ -41,10 +38,8 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_structure() { #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_extract_solution() { let source = Partition::new(vec![3, 1, 1, 2, 2, 1]).unwrap(); - let reduction = ReduceTo::< - crate::models::decision::Decision, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); assert_eq!( reduction.extract_solution(&vec![1, 2, 4, 5, 0, 3]).unwrap(), @@ -55,10 +50,8 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_extract_solution() #[test] fn test_partition_to_sequencing_to_minimize_tardy_task_weight_odd_total_is_unsatisfying() { let source = Partition::new(vec![2, 4, 5]).unwrap(); - let reduction = ReduceTo::< - crate::models::decision::Decision, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let best = BruteForce::new() .solve(target) @@ -69,10 +62,9 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_odd_total_is_unsat assert!( !crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(target.evaluate(&best).unwrap()), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&best) + .unwrap() ) .0 ); @@ -117,7 +109,7 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_canonical_example_ let source: Partition = serde_json::from_value(example.source.instance.clone()) .expect("source example deserializes"); - let target: crate::models::decision::Decision = + let target: Decision = serde_json::from_value(example.target.instance.clone()) .expect("target example deserializes"); @@ -141,10 +133,9 @@ fn test_partition_to_tardy_weight_all_small_configurations() { }) .collect(); let source = Partition::new(sizes).unwrap(); - let reduction = ReduceTo::< - crate::models::decision::Decision, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>::reduce_to(&source) + .unwrap(); let target = crate::rules::AggregateReductionResult::target_problem(&reduction).inner(); let source_feasible = (0..1usize << n).any(|mask| { let bits = (0..n).map(|i| mask & (1 << i) != 0).collect(); @@ -165,10 +156,9 @@ fn test_partition_to_tardy_weight_all_small_configurations() { } let certified = crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound(), - )), + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap(), ) .0; let extracted = reduction.extract_solution(&schedule); @@ -195,10 +185,7 @@ fn test_partition_to_tardy_weight_all_small_configurations() { assert!( !crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(Min(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::types::Or(false) ) .0 ); @@ -216,10 +203,8 @@ fn test_partition_to_tardy_weight_full_i64_domain() { (vec![half - 1, half - 1, 1, 1], vec![0, 2, 1, 3], half, true), ] { let source = Partition::new(sizes).unwrap(); - let reduction = ReduceTo::< - crate::models::decision::Decision, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>::reduce_to(&source).unwrap(); let value = reduction .target_problem() .inner() @@ -229,10 +214,9 @@ fn test_partition_to_tardy_weight_full_i64_domain() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap() ) .0, balanced diff --git a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs index a645eee4f..064b7bacc 100644 --- a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::topology::Graph; use crate::traits::Problem; @@ -18,10 +19,8 @@ fn test_partitionintocliques_target_bound_rejects_overflow() { #[test] fn test_partitionintocliques_aggregate_applies_gadget_offset() { let source = PartitionIntoCliques::new(SimpleGraph::new(3, vec![(0, 1)]), 2); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); // K + 2m + 2 = 6, including both directed-edge gadgets and the side cliques. for (value, expected) in [ (Min(None), false), @@ -45,10 +44,8 @@ fn test_partitionintocliques_aggregate_applies_gadget_offset() { #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_closed_loop() { let source = PartitionIntoCliques::new(SimpleGraph::empty(1), 1); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); assert_satisfaction_round_trip_from_satisfaction_target( &source, @@ -60,10 +57,8 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_closed_loop() { #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_orlin_example_structure() { let source = PartitionIntoCliques::new(SimpleGraph::new(3, vec![(0, 1)]), 2); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let layout = OrlinLayout::new(source.graph()); @@ -121,10 +116,8 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_orlin_example_structure #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_unsat_extracts_invalid_source() { let source = PartitionIntoCliques::new(SimpleGraph::new(2, vec![]), 1); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let layout = OrlinLayout::new(source.graph()); @@ -175,10 +168,9 @@ fn test_partitionintocliques_native_bounds_and_adjacency_semantics() { continue; } let source = source.unwrap(); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = ReductionResult::target_problem(&reduction).inner(); let layout = OrlinLayout::new(source.graph()); let mut cliques: Vec> = @@ -201,10 +193,9 @@ fn test_partitionintocliques_native_bounds_and_adjacency_semantics() { assert_eq!( AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() ) .0, n <= bound diff --git a/src/unit_tests/rules/sat_maximumindependentset.rs b/src/unit_tests/rules/sat_maximumindependentset.rs index 827fd79ba..23d6c717a 100644 --- a/src/unit_tests/rules/sat_maximumindependentset.rs +++ b/src/unit_tests/rules/sat_maximumindependentset.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::models::formula::CNFClause; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; @@ -47,10 +48,8 @@ fn test_boolvar_complement() { fn test_sat_to_maximumindependentset_closed_loop() { // Simple SAT: (x1) - one clause with one literal let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let is_problem = reduction.target_problem().inner(); // Should have 1 vertex (one literal) @@ -64,10 +63,8 @@ fn test_two_clause_sat_to_is() { // SAT: (x1) AND (NOT x1) // This is unsatisfiable let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let is_problem = reduction.target_problem().inner(); // Should have 2 vertices @@ -87,10 +84,8 @@ fn test_two_clause_sat_to_is() { fn test_extract_solution_basic() { // Simple case: (x1 OR x2) let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, 2])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); // Select vertex 0 (literal x1) let is_sol = vec![true, false]; @@ -107,10 +102,8 @@ fn test_extract_solution_basic() { fn test_extract_solution_with_negation() { // (NOT x1) - selecting NOT x1 means x1 should be false let sat = Satisfiability::new(1, vec![CNFClause::new(vec![-1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let is_sol = vec![true]; let sat_sol = reduction.extract_solution(&is_sol).unwrap(); @@ -121,10 +114,8 @@ fn test_extract_solution_with_negation() { fn test_clique_edges_in_clause() { // A clause with 3 literals should form a clique (3 edges) let sat = Satisfiability::new(3, vec![CNFClause::new(vec![1, 2, 3])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let is_problem = reduction.target_problem().inner(); // 3 vertices, 3 edges (complete graph K3) @@ -145,10 +136,8 @@ fn test_complement_edges_across_clauses() { CNFClause::new(vec![2]), ], ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let is_problem = reduction.target_problem().inner(); assert_eq!(is_problem.graph().num_vertices(), 3); @@ -161,10 +150,8 @@ fn test_is_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let is_problem = reduction.target_problem().inner(); // IS should have vertices for literals in clauses @@ -175,10 +162,8 @@ fn test_is_structure() { fn test_empty_sat() { // Empty SAT (trivially satisfiable) let sat = Satisfiability::new(0, vec![]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let is_problem = reduction.target_problem().inner(); assert_eq!(is_problem.graph().num_vertices(), 0); @@ -189,10 +174,8 @@ fn test_empty_sat() { #[test] fn test_literals_accessor() { let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, -2])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let literals = reduction.literals(); assert_eq!(literals.len(), 2); @@ -235,10 +218,9 @@ fn test_jl_parity_sat_to_independentset() { let inst = &jl_find_instance_by_label(&sat_data, label)["instance"]; let (num_vars, clauses) = jl_parse_sat_clauses(inst); let source = Satisfiability::new(num_vars, clauses); - let result = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let result = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let solver = BruteForce::new(); let sat_solutions: HashSet> = solver .find_all_witnesses(&source) @@ -255,14 +237,9 @@ fn test_jl_parity_sat_to_independentset() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &result, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(result - .target_problem() - .inner() - .evaluate(&target_solution) - .unwrap()), - crate::rules::ReductionResult::target_problem(&result).bound() - )) + crate::rules::ReductionResult::target_problem(&result) + .evaluate(&target_solution) + .unwrap() ), Or(false), ); @@ -302,10 +279,9 @@ fn test_sat_to_independentset_all_certificates() { CNFClause::new(second.clone()), ], ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = reduction.target_problem().inner(); assert!(std::ptr::eq( target, @@ -321,10 +297,9 @@ fn test_sat_to_independentset_all_certificates() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() ), Or(certificate) ); @@ -348,10 +323,9 @@ fn test_sat_to_independentset_all_certificates() { } for num_vars in [0, 3] { let source = Satisfiability::new(num_vars, vec![]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let reduction = + ReduceTo::>>::reduce_to(&source) + .unwrap(); assert_eq!( reduction.extract_solution(&vec![]).unwrap(), vec![false; num_vars] @@ -359,10 +333,7 @@ fn test_sat_to_independentset_all_certificates() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(Max(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::types::Or(false) ), Or(false) ); diff --git a/src/unit_tests/rules/sat_minimumdominatingset.rs b/src/unit_tests/rules/sat_minimumdominatingset.rs index 6edc9005e..4f9e16ebb 100644 --- a/src/unit_tests/rules/sat_minimumdominatingset.rs +++ b/src/unit_tests/rules/sat_minimumdominatingset.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::models::formula::CNFClause; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; @@ -10,10 +11,8 @@ include!("../jl_helpers.rs"); fn test_sat_to_minimumdominatingset_closed_loop() { // Simple SAT: (x1) - one variable, one clause let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let ds_problem = reduction.target_problem().inner(); // Should have 3 vertices (variable gadget) + 1 clause vertex = 4 vertices @@ -29,10 +28,8 @@ fn test_sat_to_minimumdominatingset_closed_loop() { fn test_two_variable_sat_to_ds() { // SAT: (x1 OR x2) let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, 2])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let ds_problem = reduction.target_problem().inner(); // 2 variables * 3 = 6 gadget vertices + 1 clause vertex = 7 @@ -49,10 +46,8 @@ fn test_two_variable_sat_to_ds() { fn test_extract_solution_positive_literal() { // (x1) - select positive literal let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); // Solution: select vertex 0 (positive literal x1) // This dominates vertices 1, 2 (gadget) and vertex 3 (clause) @@ -65,10 +60,8 @@ fn test_extract_solution_positive_literal() { fn test_extract_solution_negative_literal() { // (NOT x1) - select negative literal let sat = Satisfiability::new(1, vec![CNFClause::new(vec![-1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); // Solution: select vertex 1 (negative literal NOT x1) // This dominates vertices 0, 2 (gadget) and vertex 3 (clause) @@ -81,10 +74,8 @@ fn test_extract_solution_negative_literal() { fn test_extract_solution_unused_variable() { // The unit clause x1 leaves x2 unused. let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); // Only x1 occurs, so its triangle is the only gadget. The unused x2 // remains false in the extracted source assignment. @@ -99,10 +90,8 @@ fn test_ds_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let ds_problem = reduction.target_problem().inner(); // 3 vars * 3 = 9 gadget vertices + 2 clause vertices = 11 @@ -113,10 +102,8 @@ fn test_ds_structure() { fn test_empty_sat() { // Empty SAT (trivially satisfiable) let sat = Satisfiability::new(0, vec![]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let ds_problem = reduction.target_problem().inner(); assert_eq!(ds_problem.graph().num_vertices(), 0); @@ -129,10 +116,8 @@ fn test_empty_sat() { fn test_multiple_literals_same_variable() { // Clause with repeated variable: (x1 OR NOT x1) - tautology let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1, -1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let ds_problem = reduction.target_problem().inner(); // 3 gadget vertices + 1 clause vertex = 4 @@ -147,10 +132,8 @@ fn test_multiple_literals_same_variable() { #[test] fn test_accessors() { let sat = Satisfiability::new(2, vec![CNFClause::new(vec![1, -2])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); assert_eq!(reduction.num_literals(), 2); assert_eq!(reduction.num_clauses(), 1); @@ -159,10 +142,8 @@ fn test_accessors() { #[test] fn test_extract_solution_too_many_selected() { let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let ds_sol = vec![true, true, false, false]; assert_eq!( @@ -174,10 +155,8 @@ fn test_extract_solution_too_many_selected() { #[test] fn test_extract_solution_rejects_unselected_variable_gadget() { let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); assert_eq!( reduction @@ -191,10 +170,8 @@ fn test_extract_solution_rejects_unselected_variable_gadget() { #[test] fn test_extract_solution_rejects_selected_clause_vertex() { let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); assert_eq!( reduction @@ -209,10 +186,8 @@ fn test_extract_solution_rejects_selected_clause_vertex() { fn test_negated_variable_connection() { // (NOT x1 OR NOT x2) - both negated let sat = Satisfiability::new(2, vec![CNFClause::new(vec![-1, -2])]); - let reduction = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&sat) - .expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&sat) + .expect("reduction should succeed"); let ds_problem = reduction.target_problem().inner(); // 2 * 3 = 6 gadget vertices + 1 clause = 7 @@ -260,10 +235,9 @@ fn test_jl_parity_sat_to_dominatingset() { let inst = &jl_find_instance_by_label(&sat_data, label)["instance"]; let (num_vars, clauses) = jl_parse_sat_clauses(inst); let source = Satisfiability::new(num_vars, clauses); - let result = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .expect("reduction should succeed"); + let result = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let solver = BruteForce::new(); let sat_solutions: HashSet> = solver .find_all_witnesses(&source) @@ -307,10 +281,9 @@ fn test_sat_to_dominatingset_native_certificates() { (3, vec![vec![1], vec![]]), ] { let source = Satisfiability::new(n, clauses.into_iter().map(CNFClause::new).collect()); - let result = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let result = + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = result.target_problem().inner(); assert!(std::ptr::eq( target, @@ -326,10 +299,9 @@ fn test_sat_to_dominatingset_native_certificates() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &result, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&result).bound() - )) + crate::rules::ReductionResult::target_problem(&result) + .evaluate(&config) + .unwrap() ), Or(certificate) ); @@ -356,10 +328,9 @@ fn test_sat_to_dominatingset_native_certificates() { fn test_sat_to_dominatingset_sparse_declared_variables() { for clauses in [vec![], vec![CNFClause::new(vec![i64::MAX])]] { let source = Satisfiability::new(i64::MAX as usize, clauses); - let result = ReduceTo::< - crate::models::decision::Decision>, - >::reduce_to(&source) - .unwrap(); + let result = + ReduceTo::>>::reduce_to(&source) + .unwrap(); assert_eq!(result.num_literals(), i64::MAX as usize); assert!(result.target_problem().inner().num_vertices() <= 4); // Construction is compact. Extracting an i64::MAX-length source vector diff --git a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs index 278830031..7e6ffde0a 100644 --- a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs +++ b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs @@ -1,4 +1,5 @@ use super::*; +use crate::models::decision::Decision; use crate::models::formula::{CNFClause, Maximum2Satisfiability, Satisfiability}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::traits::ReduceTo; @@ -13,9 +14,8 @@ fn test_satisfiability_to_maximum2satisfiability_structure() { vec![CNFClause::new(vec![1, -2, 3]), CNFClause::new(vec![-1, 2])], ); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); let aggregate_target = @@ -37,9 +37,8 @@ fn test_satisfiability_to_maximum2satisfiability_closed_loop() { vec![CNFClause::new(vec![1, -2, 3]), CNFClause::new(vec![-1, 2])], ); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); assert_satisfaction_round_trip_from_satisfaction_target( @@ -61,9 +60,8 @@ fn test_satisfiability_to_maximum2satisfiability_closed_loop() { fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { let source = Satisfiability::new(1, vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])]); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); assert_eq!( @@ -95,9 +93,8 @@ fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { fn test_satisfiability_to_maximum2satisfiability_empty_clause() { let source = Satisfiability::new(1, vec![CNFClause::new(vec![])]); - let reduction = - ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem().inner(); assert_eq!(target.num_vars(), 4); @@ -155,11 +152,7 @@ fn test_satisfiability_to_maximum2satisfiability_every_target_witness() { } } for source in sources { - let reduction = - ReduceTo::>::reduce_to( - &source, - ) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem().inner(); let threshold = (target.num_clauses() / 10 * 7) as i64; let mut best = 0; @@ -173,10 +166,9 @@ fn test_satisfiability_to_maximum2satisfiability_every_target_witness() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&assignment) + .unwrap() ), Or(expected) ); @@ -195,10 +187,7 @@ fn test_satisfiability_to_maximum2satisfiability_every_target_witness() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(Max(None)), - crate::rules::ReductionResult::target_problem(&reduction).bound() - )) + crate::types::Or(false) ), Or(false) ); diff --git a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs index 4a1cb6a1c..fda54c32e 100644 --- a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs +++ b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs @@ -1,14 +1,13 @@ use super::*; use crate::models::algebraic::ClosestVectorProblem; +use crate::models::decision::Decision; use crate::traits::Problem; use crate::types::{Min, Or}; #[test] fn test_subsetsum_to_closestvectorproblem_closed_loop() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target_solution = crate::solvers::customized::closest_vector_problem::solve( reduction.target_problem().inner(), ) @@ -30,9 +29,7 @@ fn test_subsetsum_to_closestvectorproblem_closed_loop() { #[test] fn test_subsetsum_to_closestvectorproblem_structure() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem().inner(); let expected: serde_json::Value = serde_json::json!({"basis": [[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1], [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1], [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2]], "target": [0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1]}); @@ -43,9 +40,7 @@ fn test_subsetsum_to_closestvectorproblem_structure() { #[test] fn test_subsetsum_to_closestvectorproblem_binary_minimizers() { let source = SubsetSum::new(vec![3u32, 7, 1, 8], 11u32); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem().inner(); for solution in [vec![1, 0, 0, 1, 0, 0, 0], vec![1, 1, 1, 0, 1, 1, 1]] { @@ -62,9 +57,7 @@ fn test_subsetsum_to_closestvectorproblem_binary_minimizers() { #[test] fn test_subsetsum_to_closestvectorproblem_unsatisfiable_instance() { let source = SubsetSum::new(vec![2u32, 4, 6], 5u32); - let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let solution = crate::solvers::customized::closest_vector_problem::solve( reduction.target_problem().inner(), ) @@ -85,9 +78,7 @@ fn test_subsetsum_to_closestvectorproblem_large_integers_and_unit_pivots() { use num_bigint::BigUint; let size = BigUint::from(1u32) << 70usize; let source = SubsetSum::new(vec![size.clone()], size); - let result = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let result = ReduceTo::>::reduce_to(&source).unwrap(); let mut witness = vec![0; result.target_problem().inner().num_basis_vectors()]; witness[0] = 1; assert_eq!( @@ -104,9 +95,7 @@ fn test_subsetsum_to_closestvectorproblem_large_integers_and_unit_pivots() { .all(|&x| (-2..=1).contains(&x))); let source = SubsetSum::new(vec![1u32; 40], 20u32); - let result = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let result = ReduceTo::>::reduce_to(&source).unwrap(); let mut witness = vec![0; result.target_problem().inner().num_basis_vectors()]; witness[..20].fill(1); witness[40..].copy_from_slice(&[1, 2, 5, 10]); @@ -129,9 +118,7 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { (vec![2, 4], 5), ] { let source = SubsetSum::new(sizes, target_sum); - let result = - ReduceTo::>::reduce_to(&source) - .unwrap(); + let result = ReduceTo::>::reduce_to(&source).unwrap(); let target = result.target_problem().inner(); assert!(std::ptr::eq( target, @@ -153,10 +140,9 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { assert_eq!( crate::rules::AggregateReductionResult::extract_value( &result, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(value), - crate::rules::ReductionResult::target_problem(&result).bound() - )) + crate::rules::ReductionResult::target_problem(&result) + .evaluate(&config) + .unwrap() ), Or(certificate) ); @@ -178,13 +164,7 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { ); assert!(result.extract_solution(&vec![0; dimensions + 1]).is_err()); assert_eq!( - crate::rules::AggregateReductionResult::extract_value( - &result, - crate::types::Or(crate::types::OptimizationValue::meets_bound( - &(Min(None)), - crate::rules::ReductionResult::target_problem(&result).bound() - )) - ), + crate::rules::AggregateReductionResult::extract_value(&result, crate::types::Or(false)), Or(false) ); } diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 9193c5456..7e90b387d 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -54,7 +54,7 @@ fn generic_decision_ilp_respects_maximization_bounds() { if bound > 1 { assert!(matches!( result, - Err(crate::solvers::ILPSolveError::UnresolvedDecision(_)) + Err(crate::solvers::ILPSolveError::Infeasible) )); assert!(BruteForce::new().solve(&decision).unwrap().is_none()); continue; @@ -68,7 +68,7 @@ fn generic_decision_ilp_respects_maximization_bounds() { } #[test] -fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { +fn generic_decision_ilp_reports_infeasibility_but_preserves_extraction_errors() { use crate::models::decision::Decision; use crate::models::graph::MinimumVertexCover; use crate::rules::{AggregateReductionResult, ExtractionError, ReductionResult}; @@ -126,7 +126,7 @@ fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { let inner = Inner::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i64; 2]); assert!(matches!( pipeline.solve(&Decision::new(inner.clone(), 0), &ILPSolver::new()), - Err(ILPSolveError::UnresolvedDecision(_)) + Err(ILPSolveError::Infeasible) )); assert!(matches!( pipeline.solve(&Decision::new(inner, 1), &ILPSolver::new()), @@ -135,6 +135,41 @@ fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { )); } +#[test] +fn ilp_negative_intermediate_requires_every_remaining_value_mapping() { + use crate::models::graph::HamiltonianCircuit; + use crate::solvers::{ILPSolveError, ILPSolver}; + use crate::topology::SimpleGraph; + use crate::traits::Problem; + + // The triangle is optimal for LongestCircuit but cannot cover all four vertices. + let problem = HamiltonianCircuit::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2)])); + let key = ExactProblemKey::new( + HamiltonianCircuit::::NAME, + crate::export::variant_to_map(HamiltonianCircuit::::variant()), + ); + let registry = solver_capability_registry().unwrap(); + let original = registry.lookup(&key).ilp.unwrap(); + assert!(matches!( + original.solve(&problem, &ILPSolver::new()), + Err(ILPSolveError::Infeasible) + )); + let mut pipeline = CompiledIlpPipeline { + path: original.path.clone(), + reducers: original.reducers.clone(), + }; + pipeline.reducers[0].1 = None; + let result = pipeline.solve(&problem, &ILPSolver::new()); + assert!( + matches!( + &result, + Err(ILPSolveError::Extraction(crate::rules::ExtractionError::InvalidTargetSolution(message))) + if message.contains("missing aggregate mapping") + ), + "{result:?}" + ); +} + static DIRECT_BOOL_A: IlpPipelineRegistration = IlpPipelineRegistration { path: &[StaticProblemStep { name: "ILP", diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index ed9c120ab..b631b006b 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -41,18 +41,7 @@ fn decision_reductions_check_target_optimum_before_extracting_witness() { SolverRequest::Ilp, SolverRequest::Default, ] { - let result = solve(&problem, backend); - if matches!( - &result, - Err(crate::solvers::SolveError::IlpSolve { - source: crate::solvers::ILPSolveError::UnresolvedDecision(_), - .. - }) - ) { - assert!(!expected, "{name}, {backend:?}"); - continue; - } - match result.unwrap().outcome { + match solve(&problem, backend).unwrap().outcome { SolveOutcome::Optimal { solution, evaluation, @@ -84,18 +73,7 @@ fn hamiltonian_ilp_matches_exhaustive_search_on_small_graphs() { ) .unwrap(); let reference = solve(&problem, SolverRequest::BruteForce).unwrap(); - let actual = solve(&problem, SolverRequest::Ilp); - if matches!( - &actual, - Err(crate::solvers::SolveError::IlpSolve { - source: crate::solvers::ILPSolveError::UnresolvedDecision(_), - .. - }) - ) { - assert!(matches!(reference.outcome, SolveOutcome::Infeasible)); - continue; - } - let actual = actual.unwrap(); + let actual = solve(&problem, SolverRequest::Ilp).unwrap(); assert_eq!( matches!(actual.outcome, SolveOutcome::Infeasible), matches!(reference.outcome, SolveOutcome::Infeasible), @@ -152,21 +130,7 @@ fn generic_decision_ilp_compares_inner_optimum_with_bound() { SolverRequest::Ilp, SolverRequest::Default, ] { - let result = solve(&loaded, backend); - if bound < optimum && backend != SolverRequest::BruteForce { - assert!( - matches!( - result, - Err(crate::solvers::SolveError::IlpSolve { - source: crate::solvers::ILPSolveError::UnresolvedDecision(_), - .. - }) - ), - "{name}, {bound}, {backend:?}" - ); - continue; - } - let result = result.unwrap(); + let result = solve(&loaded, backend).unwrap(); if bound < optimum { assert_eq!( result.outcome, @@ -238,21 +202,7 @@ fn generic_decision_ilp_matches_exhaustive_search_on_small_graphs() { ) .unwrap(); let reference = solve(&loaded, SolverRequest::BruteForce).unwrap(); - let actual = solve(&loaded, SolverRequest::Ilp); - if matches!(reference.outcome, SolveOutcome::Infeasible) { - assert!( - matches!( - actual, - Err(crate::solvers::SolveError::IlpSolve { - source: crate::solvers::ILPSolveError::UnresolvedDecision(_), - .. - }) - ), - "{name}, graph {mask}, bound {bound}" - ); - continue; - } - let actual = actual.unwrap(); + let actual = solve(&loaded, SolverRequest::Ilp).unwrap(); assert_eq!( matches!(actual.outcome, SolveOutcome::Infeasible), matches!(reference.outcome, SolveOutcome::Infeasible), @@ -587,24 +537,7 @@ fn check_unit_dominating_decision(num_vertices: usize, edges: &[(usize, usize)], .unwrap(); let reference = solve(&problem, SolverRequest::BruteForce).unwrap(); for backend in [SolverRequest::Ilp, SolverRequest::Default] { - let actual = solve(&problem, backend); - if matches!(reference.outcome, SolveOutcome::Infeasible) { - assert!( - matches!( - actual, - Err(crate::solvers::SolveError::IlpSolve { - source: crate::solvers::ILPSolveError::UnresolvedDecision(_), - .. - }) | Ok(crate::solvers::SolveResult { - outcome: SolveOutcome::Infeasible, - .. - }) - ), - "n={num_vertices}, edges={edges:?}, bound={bound}" - ); - continue; - } - let actual = actual.unwrap(); + let actual = solve(&problem, backend).unwrap(); let SolverExecution::Ilp { reduction_path } = &actual.solver else { panic!("expected the registered ILP pipeline"); }; From d754abc5aa895ffff70824b6ee638a017436dfcb Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 17:28:14 +0800 Subject: [PATCH 16/44] Persist QUBO as coordinate entries and rename CVP coefficient variant --- docs/src/design.md | 6 ++ .../src/commands/create/tests.rs | 4 +- problemreductions-cli/tests/cli_tests.rs | 8 +- .../algebraic/closest_vector_problem.rs | 6 +- src/models/algebraic/qubo.rs | 77 +++++++++++++++++-- .../algebraic/closest_vector_problem.rs | 9 ++- src/unit_tests/models/algebraic/qubo.rs | 67 ++++++++++++++++ .../rules/subsetsum_closestvectorproblem.rs | 5 +- .../customized/closest_vector_problem.rs | 4 +- 9 files changed, 166 insertions(+), 20 deletions(-) diff --git a/docs/src/design.md b/docs/src/design.md index b1ebf1215..fb7bfbd59 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -500,6 +500,12 @@ let json: String = to_json(&problem)?; let restored: MaximumIndependentSet = from_json(&json)?; ``` +QUBO data uses `{"num_vars": 3, "entries": [[0,0,-2], [0,1,4]]}`. +Each entry is `[row, column, coefficient]` with zero-based indices; output lists +nonzero entries in row-major order. Duplicate and out-of-range coordinates are +errors. As with `from_matrix`, evaluation uses only the upper triangle, including +the diagonal. CLI creation still accepts `--matrix`. + ## Contributing See [Call for Contributions](index.html#open-questions) for the recommended issue-based workflow (no coding required). diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index 4fd410129..767cbe971 100644 --- a/problemreductions-cli/src/commands/create/tests.rs +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -347,7 +347,7 @@ fn test_create_schema_driven_builds_integer_target_closest_vector_problem() { panic!("expected create command"); }; - let resolved_variant = BTreeMap::from([("target".to_string(), "i64".to_string())]); + let resolved_variant = BTreeMap::from([("coefficient".to_string(), "i64".to_string())]); let (data, variant) = create_schema_driven(&args, "ClosestVectorProblem", &resolved_variant) .expect("schema-driven create should parse"); @@ -374,7 +374,7 @@ fn test_create_rejects_fractional_cvp_target() { let Commands::Create(args) = cli.command else { panic!("expected create command"); }; - let variant = BTreeMap::from([("target".into(), "i64".into())]); + let variant = BTreeMap::from([("coefficient".into(), "i64".into())]); assert!(create_schema_driven(&args, "ClosestVectorProblem", &variant).is_err()); } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 5c7bf7fc7..59a5722b2 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -3174,6 +3174,12 @@ fn test_create_qubo() { let content = std::fs::read_to_string(&output_file).unwrap(); let json: serde_json::Value = serde_json::from_str(&content).unwrap(); assert_eq!(json["type"], "QUBO"); + assert_eq!( + json["data"], + serde_json::json!({ + "num_vars": 2, "entries": [[0,0,1],[0,1,-1],[1,1,2]] + }) + ); std::fs::remove_file(&output_file).ok(); } @@ -10254,7 +10260,7 @@ fn test_extract_rejects_tampered_target_data() { // what the reduction chain actually produces. let bundle_text = std::fs::read_to_string(&bundle_file).unwrap(); let mut bundle: serde_json::Value = serde_json::from_str(&bundle_text).unwrap(); - bundle["target"]["data"]["matrix"][0][0] = serde_json::json!(999.0); + bundle["target"]["data"]["entries"][0][2] = serde_json::json!(999.0); let mut f = std::fs::File::create(&tampered_file).unwrap(); f.write_all(bundle.to_string().as_bytes()).unwrap(); diff --git a/src/models/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index c8e4e0632..1b5b7b7db 100644 --- a/src/models/algebraic/closest_vector_problem.rs +++ b/src/models/algebraic/closest_vector_problem.rs @@ -33,7 +33,7 @@ inventory::submit! { name: "ClosestVectorProblem", display_name: "Closest Vector Problem", aliases: &["CVP"], - dimensions: &[VariantDimension::new("target", "i64", &["i64"])], + dimensions: &[VariantDimension::new("coefficient", "i64", &["i64"])], category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Find the closest point in an integer lattice to a target vector", @@ -196,7 +196,7 @@ impl Problem for ClosestVectorProblem { } fn variant() -> Vec<(&'static str, &'static str)> { - vec![("target", "i64")] + vec![("coefficient", "i64")] } } @@ -226,7 +226,7 @@ crate::decision_problem_meta!(ClosestVectorProblem, "DecisionClosestVectorProble inventory::submit! { crate::registry::ProblemSchemaEntry { name: "DecisionClosestVectorProblem", display_name: "Decision ClosestVectorProblem", aliases: &[], - dimensions: &[VariantDimension::new("target", "i64", &["i64"])], category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), + dimensions: &[VariantDimension::new("coefficient", "i64", &["i64"])], category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), description: "Does a feasible solution meet the objective bound?", fields: &[ crate::registry::FieldInfo { name: "basis", type_name: "Vec>", description: "Basis matrix as semicolon-separated column vectors." }, diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 5cbc12e71..7a6b326b7 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -55,7 +55,11 @@ inventory::submit! { /// // Optimal is x = [0, 1] with value -2 /// assert!(solutions.contains(&vec![false, true])); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] +#[serde( + try_from = "QuboData", + bound(deserialize = "W: WeightElement + Deserialize<'de>") +)] pub struct QUBO { /// Number of variables. num_vars: usize, @@ -64,6 +68,65 @@ pub struct QUBO { matrix: Vec>, } +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct QuboData { + num_vars: usize, + entries: Vec<(usize, usize, W)>, +} + +impl Serialize for QUBO { + fn serialize(&self, serializer: S) -> Result { + let entries = self + .matrix + .iter() + .enumerate() + .flat_map(|(row, values)| { + values + .iter() + .enumerate() + .filter_map(move |(column, value)| { + (!value.to_sum().is_zero()).then_some((row, column, value)) + }) + }) + .collect(); + QuboData { + num_vars: self.num_vars, + entries, + } + .serialize(serializer) + } +} + +impl TryFrom> for QUBO { + type Error = ConstructionError; + + fn try_from(mut data: QuboData) -> Result { + for &(row, column, _) in &data.entries { + if row >= data.num_vars || column >= data.num_vars { + return Err(ConstructionError::Conversion(format!( + "QUBO index ({row}, {column}) is outside 0..{}", + data.num_vars + ))); + } + } + data.entries.sort_by_key(|&(row, column, _)| (row, column)); + for pair in data.entries.windows(2) { + if (pair[0].0, pair[0].1) == (pair[1].0, pair[1].1) { + return Err(ConstructionError::Conversion(format!( + "duplicate QUBO index ({}, {})", + pair[0].0, pair[0].1 + ))); + } + } + let mut matrix = vec![vec![W::default(); data.num_vars]; data.num_vars]; + for (row, column, value) in data.entries { + matrix[row][column] = value; + } + Self::from_matrix(matrix) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct QuboCreateSpec { /// Q matrix; the number of variables is its row count. @@ -190,13 +253,11 @@ where continue; } - if let Some(q_ij) = self.matrix.get(i).and_then(|row| row.get(j)) { - value = W::checked_add_to_sum( - value, - q_ij.to_sum(), - "summing selected QUBO coefficients", - )?; - } + value = W::checked_add_to_sum( + value, + self.matrix[i][j].to_sum(), + "summing selected QUBO coefficients", + )?; } } diff --git a/src/unit_tests/models/algebraic/closest_vector_problem.rs b/src/unit_tests/models/algebraic/closest_vector_problem.rs index ec162c905..a4928991b 100644 --- a/src/unit_tests/models/algebraic/closest_vector_problem.rs +++ b/src/unit_tests/models/algebraic/closest_vector_problem.rs @@ -9,7 +9,10 @@ fn test_cvp_constructs_integer_targets() { assert_eq!(integer.num_basis_vectors(), 2); assert_eq!(integer.ambient_dimension(), 3); assert_eq!(integer.target(), &[3, 3, 1]); - assert_eq!(ClosestVectorProblem::variant(), vec![("target", "i64")]); + assert_eq!( + ClosestVectorProblem::variant(), + vec![("coefficient", "i64")] + ); } #[test] @@ -104,7 +107,7 @@ fn test_cvp_create_specs_have_no_bounds() { } #[test] -fn test_cvp_registers_only_integer_target_variant() { +fn test_cvp_registers_only_integer_coefficient_variant() { let mut variants = crate::registry::variant_entries() .into_iter() .filter(|entry| entry.name == ClosestVectorProblem::NAME) @@ -114,7 +117,7 @@ fn test_cvp_registers_only_integer_target_variant() { assert_eq!( variants, vec![std::collections::BTreeMap::from([( - "target".into(), + "coefficient".into(), "i64".into() )]),] ); diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 448fef75b..0a8f12a15 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -5,6 +5,73 @@ use crate::traits::Problem; use crate::types::Min; include!("../../jl_helpers.rs"); +#[test] +fn test_qubo_entries_roundtrip() { + let data = serde_json::json!({"num_vars": 3, "entries": [[1,1,3],[0,1,-2],[1,0,4]]}); + let problem: QUBO = serde_json::from_value(data).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + assert_eq!( + encoded, + serde_json::json!({ + "num_vars": 3, "entries": [[0,1,-2],[1,0,4],[1,1,3]] + }) + ); + let restored: QUBO = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(restored.matrix(), problem.matrix()); + assert_eq!( + restored.evaluate(&vec![true, true, false]).unwrap(), + Min(Some(1)) + ); + let float: QUBO = serde_json::from_value(encoded).unwrap(); + let restored_float: QUBO = + serde_json::from_value(serde_json::to_value(&float).unwrap()).unwrap(); + assert_eq!(restored_float.matrix(), float.matrix()); + for num_vars in [0, 3] { + let problem = QUBO::::from_matrix(vec![vec![0; num_vars]; num_vars]).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + assert_eq!( + encoded, + serde_json::json!({"num_vars": num_vars, "entries": []}) + ); + let restored: QUBO = serde_json::from_value(encoded).unwrap(); + assert_eq!(restored.num_vars(), num_vars); + } +} + +#[test] +fn test_qubo_entries_reject_invalid_data() { + for (data, message) in [ + ( + serde_json::json!({"num_vars": 2, "entries": [[0,0,0],[1,1,1],[0,0,2]]}), + "duplicate QUBO index", + ), + ( + serde_json::json!({"num_vars": 2, "entries": [[2,0,1]]}), + "outside 0..2", + ), + ( + serde_json::json!({"num_vars": 2, "entries": [[0,2,1]]}), + "outside 0..2", + ), + ( + serde_json::json!({"num_vars": 2}), + "missing field `entries`", + ), + ( + serde_json::json!({"num_vars": 0, "entries": [], "matrix": []}), + "unknown field `matrix`", + ), + ] { + let error = serde_json::from_value::>(data).unwrap_err(); + assert!(error.to_string().contains(message), "{error}"); + } + assert!(QUBO::try_from(QuboData { + num_vars: 1, + entries: vec![(0, 0, f64::NAN)] + }) + .is_err()); +} + #[test] fn test_qubo_from_matrix() { let problem = QUBO::from_matrix(vec![vec![1, 2], vec![0, 3]]).unwrap(); diff --git a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs index fda54c32e..328cb6159 100644 --- a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs +++ b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs @@ -34,7 +34,10 @@ fn test_subsetsum_to_closestvectorproblem_structure() { let expected: serde_json::Value = serde_json::json!({"basis": [[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1], [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1], [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2]], "target": [0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1]}); assert_eq!(serde_json::to_value(target).unwrap(), expected); - assert_eq!(ClosestVectorProblem::variant(), vec![("target", "i64")]); + assert_eq!( + ClosestVectorProblem::variant(), + vec![("coefficient", "i64")] + ); } #[test] diff --git a/src/unit_tests/solvers/customized/closest_vector_problem.rs b/src/unit_tests/solvers/customized/closest_vector_problem.rs index 231f41325..3c942db5c 100644 --- a/src/unit_tests/solvers/customized/closest_vector_problem.rs +++ b/src/unit_tests/solvers/customized/closest_vector_problem.rs @@ -51,7 +51,7 @@ fn test_cvp_solver_reports_unrepresentable_coefficient() { fn test_cvp_solver_is_registered_without_brute_force() { let key = ExactProblemKey::new( ClosestVectorProblem::NAME, - BTreeMap::from([("target".to_string(), "i64".to_string())]), + BTreeMap::from([("coefficient".to_string(), "i64".to_string())]), ); let capabilities = solver_capabilities(&key).unwrap(); assert_eq!( @@ -142,7 +142,7 @@ fn decision_cvp_uses_the_exact_optimum_and_bound() { use crate::models::decision::Decision; let key = ExactProblemKey::new( Decision::::NAME, - BTreeMap::from([("target".to_string(), "i64".to_string())]), + BTreeMap::from([("coefficient".to_string(), "i64".to_string())]), ); let solver = crate::solvers::registry::solver_capability_registry() .unwrap() From 47d530308f4296b6876df59f8475c17ba4e5e6f2 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 20:04:13 +0800 Subject: [PATCH 17/44] Fix QUBO paper examples for coordinate serialization --- docs/paper/reductions.typ | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a779cf003..cb21247e6 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -5030,7 +5030,10 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| #{ let x = load-model-example("QUBO") let n = x.instance.num_vars - let Q = x.instance.matrix + let Q = range(n).map(_ => (0,) * n) + for (i, j, value) in x.instance.entries { + Q.at(i).at(j) = value + } let sol = (config: x.optimal_config, metric: x.optimal_value) let xstar = sol.config let fstar = metric-value(sol.metric) @@ -12060,7 +12063,10 @@ the displayed rule, extracted from the corresponding `pred path` entry. let basis = cvp_qubo.source.instance.basis let target = cvp_qubo.source.instance.target let coords = cvp_qubo_sol.source_config - let matrix = cvp_qubo.target.instance.matrix + let matrix = range(cvp_qubo.target.instance.num_vars).map(_ => (0,) * cvp_qubo.target.instance.num_vars) + for (i, j, value) in cvp_qubo.target.instance.entries { + matrix.at(i).at(j) = value + } let bits = cvp_qubo_sol.target_config let lower = (-23, -14) let anchor = range(target.len()).map(d => lower.enumerate().fold(0.0, (acc, (i, x)) => acc + x * basis.at(i).at(d))) From 6d5ad9351af718a6c14e97d2d0fcbd6528e7d867 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 20 Sep 2026 20:42:20 +0800 Subject: [PATCH 18/44] Support unit-weight decision vertex cover and decision ILP pipelines --- problemreductions-cli/src/dispatch.rs | 11 +-- src/models/graph/minimum_vertex_cover.rs | 67 ++++++++------ ...onminimumvertexcover_hamiltoniancircuit.rs | 25 ++---- ...tisfiability_decisionminimumvertexcover.rs | 15 ++-- src/solvers/pipelines.rs | 89 +++++++++++++++++++ src/unit_tests/example_db.rs | 2 +- src/unit_tests/reduction_graph.rs | 6 +- src/unit_tests/registry/variant.rs | 4 +- src/unit_tests/rules/aggregate_contracts.rs | 7 +- ...onminimumvertexcover_hamiltoniancircuit.rs | 33 +++---- ...tisfiability_decisionminimumvertexcover.rs | 8 +- src/unit_tests/solvers/registry.rs | 55 +++++------- src/unit_tests/solvers/resolver.rs | 59 ++++++++++++ 13 files changed, 258 insertions(+), 123 deletions(-) diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index ddc74a2bb..787a51732 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -712,10 +712,11 @@ mod tests { problem_step::>(), problem_step::< problemreductions::models::decision::Decision< - MinimumVertexCover, + MinimumVertexCover, >, >(), - problem_step::>(), + problem_step::>( + ), ], ); for solver in [SolverRequest::BruteForce, SolverRequest::Ilp] { @@ -794,10 +795,10 @@ mod tests { let route = crate::commands::reduce::parse_path_json( r#"{"path":[{ "from":{"name":"KSatisfiability","variant":{"k":"K3"}}, - "to":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}} + "to":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"One"}} },{ - "from":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}}, - "to":{"name":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}} + "from":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"One"}}, + "to":{"name":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"One"}} }]}"#, ).unwrap(); let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 001b84fc6..b5115bee3 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -309,13 +309,14 @@ crate::register_decision_variant!( category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("weight", "i64", &["i64"]), + VariantDimension::new("weight", "i64", &["i64", "One"]), ], fields: [ FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, FieldInfo { name: "bound", type_name: "W::Sum", description: "Decision bound (maximum allowed cover cost)" }, ], + additional: [MinimumVertexCover => "1.1996^num_vertices"], decode: |_, indices: Vec| crate::config::config_to_bits(&indices), random ); @@ -353,35 +354,43 @@ pub(crate) fn decision_canonical_model_example_specs( #[cfg(feature = "example-db")] pub(crate) fn decision_canonical_rule_example_specs( ) -> Vec { - vec![crate::example_db::specs::RuleExampleSpec { - id: "decision_minimum_vertex_cover_to_minimum_vertex_cover", - build: || { - use crate::example_db::specs::assemble_rule_example; - use crate::export::SolutionPair; - use crate::rules::{AggregateReductionResult, ReduceToAggregate}; - - let source = crate::models::decision::Decision::new( - MinimumVertexCover::new( - SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), - vec![1i64; 4], - ), - 2, - ); - let result = source - .reduce_to_aggregate() - .expect("reduction should succeed"); - let target = result.target_problem(); - let config = vec![true, false, true, false]; - assemble_rule_example( - &source, - target, - vec![SolutionPair { - source_config: serde_json::json!(config.clone()), - target_config: serde_json::json!(config), - }], - ) + use crate::example_db::specs::{rule_example_with_witness, RuleExampleSpec}; + use crate::export::SolutionPair; + vec![ + RuleExampleSpec { + id: "decision_minimum_vertex_cover_to_minimum_vertex_cover", + build: || { + rule_example_with_witness::<_, MinimumVertexCover>( + Decision::new( + MinimumVertexCover::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), + vec![1i64; 4], + ), + 2, + ), + SolutionPair { + source_config: serde_json::json!([true, false, true, false]), + target_config: serde_json::json!([true, false, true, false]), + }, + ) + }, }, - }] + RuleExampleSpec { + id: "decision_minimum_vertex_cover_one_to_minimum_vertex_cover_one", + build: || { + rule_example_with_witness::<_, MinimumVertexCover>( + Decision::new( + MinimumVertexCover::new(SimpleGraph::path(3), vec![One; 3]), + 1, + ), + SolutionPair { + source_config: serde_json::json!([false, true, false]), + target_config: serde_json::json!([false, true, false]), + }, + ) + }, + }, + ] } /// Check if a set of vertices forms a vertex cover. diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index a836ac821..5d47f1f48 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -1,13 +1,14 @@ //! Reduction from Decision Minimum Vertex Cover to Hamiltonian Circuit. //! //! This implements the gadget construction from Garey & Johnson, Theorem 3.4, -//! on the unit-weight `Decision>` model. +//! on the unit-weight `Decision>` model. use crate::models::decision::Decision; use crate::models::graph::{HamiltonianCircuit, MinimumVertexCover}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; +use crate::types::One; use std::collections::BTreeSet; #[derive(Debug, Clone)] @@ -222,7 +223,7 @@ impl TheoremConstruction { } } -/// Result of reducing Decision> to +/// Result of reducing Decision> to /// HamiltonianCircuit. #[derive(Debug, Clone)] pub struct ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { @@ -244,7 +245,7 @@ impl ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { } impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { - type Source = Decision>; + type Source = Decision>; type Target = HamiltonianCircuit; fn target_problem(&self) -> &Self::Target { @@ -298,7 +299,7 @@ fn insert_edge(edges: &mut BTreeSet<(usize, usize)>, a: usize, b: usize) { impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { - type Source = Decision>; + type Source = Decision>; type Target = HamiltonianCircuit; fn target_problem(&self) -> &Self::Target { @@ -316,20 +317,10 @@ impl crate::rules::AggregateReductionResult num_edges = "the construction size depends on the decision threshold, which is not a problem parameter", } )] -impl ReduceTo> for Decision> { +impl ReduceTo> for Decision> { type Result = ReductionDecisionMinimumVertexCoverToHamiltonianCircuit; fn reduce_to(&self) -> Result { - let weights = self.inner().weights(); - if weights.iter().any(|&weight| weight != 1) { - return Err(crate::rules::ReductionError::invalid_target::< - Decision>, - HamiltonianCircuit, - >( - "Garey-Johnson construction requires unit vertex weights" - )); - } - let num_source_vertices = self.inner().graph().num_vertices(); // A loop forces its vertex into every cover. Reduce the remaining // loopless graph with the budget left after selecting those vertices. @@ -439,7 +430,7 @@ impl ReduceTo> for Decision>, + Decision>, HamiltonianCircuit, >("active source vertex has no Hamiltonian gadget path endpoints") })?; @@ -470,7 +461,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec to Decision. #[derive(Debug, Clone)] pub struct Reduction3SATToDecisionMVC { - target: Decision>, + target: Decision>, source_num_vars: usize, } impl ReductionResult for Reduction3SATToDecisionMVC { type Source = KSatisfiability; - type Target = Decision>; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -67,7 +68,7 @@ impl ReductionResult for Reduction3SATToDecisionMVC { #[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for Reduction3SATToDecisionMVC { type Source = KSatisfiability; - type Target = Decision>; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target @@ -84,7 +85,7 @@ impl crate::rules::AggregateReductionResult for Reduction3SATToDecisionMVC { num_edges = "num_vars + 6 * num_clauses", } )] -impl ReduceTo>> for KSatisfiability { +impl ReduceTo>> for KSatisfiability { type Result = Reduction3SATToDecisionMVC; fn reduce_to(&self) -> Result { @@ -130,13 +131,13 @@ impl ReduceTo>> for KSatisfiabilit } let graph = SimpleGraph::new(total_vertices, edges); - let weights = vec![1i64; total_vertices]; + let weights = vec![One; total_vertices]; let target = MinimumVertexCover::new(graph, weights); Ok(Reduction3SATToDecisionMVC { target: Decision::new( target, - >>>::exact_i64( + >>>::exact_i64( n + 2 * m, "computing the cover bound", )?, @@ -163,7 +164,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>, + Decision>, >( source, SolutionPair { diff --git a/src/solvers/pipelines.rs b/src/solvers/pipelines.rs index d69fceb77..aa11a5c65 100644 --- a/src/solvers/pipelines.rs +++ b/src/solvers/pipelines.rs @@ -838,3 +838,92 @@ register_ilp_pipeline! { ("UndirectedTwoCommodityIntegralFlow", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), } + +register_ilp_pipeline! { + ("DecisionLongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMaximum2Satisfiability", []), + ("Maximum2Satisfiability", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("MaximumSetPacking", [("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("MaximumSetPacking", [("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionOpenShopScheduling", []), + ("OpenShopScheduling", []), + ("ILP", [("variable", "i64"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionQUBO", [("weight", "i64")]), + ("QUBO", [("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionQuadraticAssignment", []), + ("QuadraticAssignment", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionRuralPostman", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("RuralPostman", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "i64"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionSequencingToMinimizeTardyTaskWeight", []), + ("SequencingToMinimizeTardyTaskWeight", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionSpinGlass", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("QUBO", [("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionStackerCrane", []), + ("StackerCrane", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumHittingSet", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index da098cd5b..be0bed438 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -976,7 +976,7 @@ fn test_find_rule_example_ksatisfiability_to_minimumvertexcover() { name: "DecisionMinimumVertexCover".to_string(), variant: BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), - ("weight".to_string(), "i64".to_string()), + ("weight".to_string(), "One".to_string()), ]), }; let example = find_rule_example(&source, &target).unwrap(); diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index c503cf423..ce165c6db 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -995,15 +995,15 @@ fn test_ksatisfiability_k3_to_decision_minimum_vertex_cover_direct_mappings() { assert!(graph.has_direct_reduction_mode::< KSatisfiability, - Decision>, + Decision>, >(ReductionMode::Witness)); assert!(graph.has_direct_reduction_mode::< KSatisfiability, - Decision>, + Decision>, >(ReductionMode::Aggregate)); assert!(!graph.has_direct_reduction_mode::< KSatisfiability, - Decision>, + Decision>, >(ReductionMode::Turing)); } diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index 5b5504ce3..1a026a5a3 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -442,7 +442,9 @@ fn unit_variants_construct_without_unit_inputs() { json!({"graph":[[0,1],[1,2]],"source_vertex":0,"target_vertex":2,"bound":2}) } "DecisionMinMaxMulticenter" => json!({"graph":[[0,1],[1,2]],"k":1,"bound":1}), - "DecisionMinimumDominatingSet" => json!({"graph":graph,"bound":1}), + "DecisionMinimumDominatingSet" | "DecisionMinimumVertexCover" => { + json!({"graph":graph,"bound":1}) + } "MaxCut" => json!({"graph":[[0,1],[1,2]]}), "LongestPath" => json!({"graph":[[0,1],[1,2]],"source_vertex":0,"target_vertex":2}), "MinMaxMulticenter" => json!({"graph":[[0,1],[1,2]],"k":1}), diff --git a/src/unit_tests/rules/aggregate_contracts.rs b/src/unit_tests/rules/aggregate_contracts.rs index 8339a9e24..140e9e1ca 100644 --- a/src/unit_tests/rules/aggregate_contracts.rs +++ b/src/unit_tests/rules/aggregate_contracts.rs @@ -243,9 +243,10 @@ fn sat_cover_threshold_supports_short_and_empty_clauses() { 1, clauses.into_iter().map(CNFClause::new).collect(), ); - check_decision::<_, crate::models::decision::Decision>>( - &source, - ); + check_decision::< + _, + crate::models::decision::Decision>, + >(&source); check_decision::<_, crate::models::graph::KClique>(&source); check_decision::<_, crate::models::graph::Kernel>(&source); check_decision::<_, crate::models::misc::SubsetSum>(&source); diff --git a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index fd55e223e..acd21d3ea 100644 --- a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -9,13 +9,12 @@ use crate::traits::Problem; fn decision_mvc( num_vertices: usize, edges: &[(usize, usize)], - weights: &[i64], k: i64, -) -> Decision> { +) -> Decision> { Decision::new( MinimumVertexCover::new( SimpleGraph::new(num_vertices, edges.to_vec()), - weights.to_vec(), + vec![One; num_vertices], ), k, ) @@ -23,7 +22,7 @@ fn decision_mvc( #[test] fn test_decisionminimumvertexcover_to_hamiltoniancircuit_structure_counts() { - let source = decision_mvc(3, &[(0, 1), (1, 2)], &[1, 1, 1], 1); + let source = decision_mvc(3, &[(0, 1), (1, 2)], 1); let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); let target = reduction.target_problem(); @@ -35,7 +34,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_structure_counts() { #[test] fn test_decisionminimumvertexcover_to_hamiltoniancircuit_closed_loop() { - let source = decision_mvc(3, &[(0, 1), (1, 2)], &[1, 1, 1], 1); + let source = decision_mvc(3, &[(0, 1), (1, 2)], 1); let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); @@ -57,7 +56,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_closed_loop() { #[test] fn test_decisionminimumvertexcover_to_hamiltoniancircuit_ignores_isolated_vertices() { - let source = decision_mvc(3, &[(0, 1)], &[1, 1, 1], 1); + let source = decision_mvc(3, &[(0, 1)], 1); let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); @@ -79,7 +78,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_ignores_isolated_vertic #[test] fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_yes_when_k_covers_all_active_vertices( ) { - let source = decision_mvc(3, &[(0, 1), (1, 2)], &[1, 1, 1], 3); + let source = decision_mvc(3, &[(0, 1), (1, 2)], 3); let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); let target = reduction.target_problem(); @@ -97,7 +96,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_yes_when_k_covers #[test] fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_no_when_k_zero() { - let source = decision_mvc(2, &[(0, 1)], &[1, 1], 0); + let source = decision_mvc(2, &[(0, 1)], 0); let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); let target = reduction.target_problem(); @@ -106,16 +105,6 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_no_when_k_zero() assert!(BruteForce::new().solve(target).unwrap().is_none()); } -#[test] -fn test_decisionminimumvertexcover_to_hamiltoniancircuit_rejects_non_unit_weights() { - let source = decision_mvc(2, &[(0, 1)], &[2, 1], 1); - let error = ReduceTo::>::reduce_to(&source).unwrap_err(); - assert!(matches!( - error, - crate::rules::ReductionError::InvalidTarget { .. } - )); -} - #[test] fn test_self_loops_consume_cover_budget() { for (edges, bound, cover) in [ @@ -126,7 +115,7 @@ fn test_self_loops_consume_cover_budget() { vec![true, false, true, false], ), ] { - let source = decision_mvc(4, &edges, &[1; 4], bound); + let source = decision_mvc(4, &edges, bound); let result = ReduceTo::>::reduce_to(&source).unwrap(); let witness = result.build_target_witness(&cover); assert!(result.target_problem().evaluate(&witness).unwrap().0); @@ -136,7 +125,7 @@ fn test_self_loops_consume_cover_budget() { assert!(result.extract_solution(&vec![]).is_err()); } for bound in [-1, 0, 1] { - let source = decision_mvc(2, &[(0, 0), (1, 1)], &[1, 1], bound); + let source = decision_mvc(2, &[(0, 0), (1, 1)], bound); let result = ReduceTo::>::reduce_to(&source).unwrap(); assert!(BruteForce::new().solve(&source).unwrap().is_none()); assert!(BruteForce::new() @@ -159,12 +148,12 @@ fn test_registered_aggregate_preserves_decision() { .find(|edge| { edge.source_name == "DecisionMinimumVertexCover" && (edge.source_variant_fn)() - == Decision::>::variant() + == Decision::>::variant() && edge.target_name == "HamiltonianCircuit" }) .unwrap(); for bound in [0, 1] { - let source = decision_mvc(1, &[(0, 0)], &[1], bound); + let source = decision_mvc(1, &[(0, 0)], bound); let result = (edge.reduce_aggregate_fn.unwrap())(&source).unwrap(); assert_eq!( result diff --git a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs index 387b9de80..abe45504c 100644 --- a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -17,7 +17,7 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_closed_loop() { CNFClause::new(vec![-1, -2, 3]), ], ); - let reduction = ReduceTo::>>::reduce_to(&source) + let reduction = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); let target = reduction.target_problem(); @@ -42,7 +42,7 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_unsatisfiable() { CNFClause::new(vec![1, 1, 1]), ], ); - let reduction = ReduceTo::>>::reduce_to(&source) + let reduction = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); let target = reduction.target_problem(); @@ -53,7 +53,7 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_unsatisfiable() { #[test] fn test_ksatisfiability_to_decisionminimumvertexcover_structure_and_bound() { let source = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, -1, 2])]); - let reduction = ReduceTo::>>::reduce_to(&source) + let reduction = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); let target = reduction.target_problem(); @@ -71,7 +71,7 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_extract_solution() { CNFClause::new(vec![-1, -2, 3]), ], ); - let reduction = ReduceTo::>>::reduce_to(&source) + let reduction = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); let cover = vec![ false, true, false, true, true, false, true, true, false, true, true, false, diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 7e90b387d..f7cc0c6e7 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -1,11 +1,26 @@ use super::*; use std::collections::BTreeMap; -const BOOL_VARIANT: &[(&str, &str)] = &[("variable", "bool"), ("coefficient", "i64")]; const FLOAT_BOOL_VARIANT: &[(&str, &str)] = &[("variable", "bool"), ("coefficient", "f64")]; const FLOAT_I64_VARIANT: &[(&str, &str)] = &[("variable", "i64"), ("coefficient", "f64")]; const NO_VARIANT: &[(&str, &str)] = &[]; +#[test] +fn decision_variants_support_ilp_when_the_inner_problem_does() { + let registry = solver_capability_registry().unwrap(); + for edge in reduction_entries().iter().filter(|edge| edge.turing) { + let inner = edge_key(edge, true); + let decision = edge_key(edge, false); + if registry.lookup(&inner).ilp.is_some() { + assert!( + registry.lookup(&decision).ilp.is_some(), + "{} lacks ILP support", + decision.label() + ); + } + } +} + #[test] fn generic_decision_ilp_respects_maximization_bounds() { use crate::models::decision::Decision; @@ -13,36 +28,14 @@ fn generic_decision_ilp_respects_maximization_bounds() { use crate::solvers::BruteForce; use crate::topology::SimpleGraph; - // Exercise the same generic decision edge without adding a production solver registration. - static PIPELINE: IlpPipelineRegistration = IlpPipelineRegistration { - path: &[ - StaticProblemStep { - name: "DecisionMaximumIndependentSet", - variant: &[("graph", "SimpleGraph"), ("weight", "i64")], - }, - StaticProblemStep { - name: "MaximumIndependentSet", - variant: &[("graph", "SimpleGraph"), ("weight", "i64")], - }, - StaticProblemStep { - name: "MaximumSetPacking", - variant: &[("weight", "i64")], - }, - StaticProblemStep { - name: "ILP", - variant: BOOL_VARIANT, - }, - ], - }; - let registry = build_registry( - ®istered_variant_keys(), - inventory::iter::(), - inventory::iter::().chain([&PIPELINE]), - inventory::iter::(), - &reduction_entries(), - ) - .unwrap(); - let source = ExactProblemKey::from_static(&PIPELINE.path[0]); + let registry = solver_capability_registry().unwrap(); + let source = ExactProblemKey::new( + "DecisionMaximumIndependentSet", + BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]), + ); let pipeline = registry.lookup(&source).ilp.unwrap(); let inner = MaximumIndependentSet::new( SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index b631b006b..6648e7b82 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -4,6 +4,65 @@ use crate::solvers::{solve, SolveOutcome, SolverExecution, SolverRequest}; use crate::traits::Problem; use std::collections::BTreeMap; +#[test] +fn decision_ilp_paths_respect_bounds_and_return_valid_witnesses() { + let graph = serde_json::json!({"num_vertices": 3, "edges": [[0,1],[1,2]]}); + let cases = [ + ( + "DecisionMinimumVertexCover", + BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "One".into()), + ]), + serde_json::json!({"graph": graph, "weights": [1,1,1]}), + 1, + ), + ( + "DecisionMinimumCoveringByCliques", + BTreeMap::from([("graph".into(), "SimpleGraph".into())]), + serde_json::json!({"graph": graph}), + 2, + ), + ( + "DecisionOpenShopScheduling", + BTreeMap::new(), + serde_json::json!({"num_machines": 2, "processing_times": [[2,1],[1,2]]}), + 3, + ), + ( + "DecisionRuralPostman", + BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]), + serde_json::json!({"graph": graph, "edge_lengths": [1,1], "required_edges": [0,1]}), + 4, + ), + ]; + for (name, variant, inner, optimum) in cases { + for bound in [optimum - 1, optimum, optimum + 1] { + let problem = load_dyn( + name, + &variant, + serde_json::json!({"inner": inner, "bound": bound}), + ) + .unwrap(); + let result = solve(&problem, SolverRequest::Default).unwrap(); + assert!( + matches!(result.solver, SolverExecution::Ilp { .. }), + "{name}" + ); + match result.outcome { + SolveOutcome::Optimal { solution, .. } => { + assert!(bound >= optimum, "{name}, bound {bound}"); + assert_eq!(problem.evaluate_dyn(&solution).unwrap(), "Or(true)"); + } + SolveOutcome::Infeasible => assert!(bound < optimum, "{name}, bound {bound}"), + } + } + } +} + #[test] fn decision_reductions_check_target_optimum_before_extracting_witness() { let variant = BTreeMap::from([("graph".into(), "SimpleGraph".into())]); From 2959932352751bc6fcf8ef6b8a9a08855d91343c Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 21 Sep 2026 03:22:54 +0800 Subject: [PATCH 19/44] Fix infeasibility propagation and solver dispatch --- .claude/CLAUDE.md | 2 +- docs/src/design.md | 10 +- problemreductions-cli/src/commands/solve.rs | 6 +- problemreductions-cli/src/dispatch.rs | 142 ++++++++++++++++---- problemreductions-cli/src/mcp/tools.rs | 6 +- problemreductions-cli/tests/cli_tests.rs | 8 +- src/rules/traits.rs | 3 + src/solvers/pipelines.rs | 10 ++ src/solvers/registry.rs | 34 +---- src/unit_tests/solvers/registry.rs | 8 +- src/unit_tests/solvers/resolver.rs | 54 ++++++++ 11 files changed, 202 insertions(+), 81 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2465f66cd..05c2894c2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -167,7 +167,7 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `BruteForce::find_all_witnesses()` is a reference-testing helper for collecting every optimal or satisfying solution - `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows. Neither requires a rule-category tag. When both are registered, completed-result recovery borrows both mappings from the same constructed reduction. - Register a completed-value mapping with `#[aggregate_reduction]` on its concrete `AggregateReductionResult` implementation. Generic implementations use `register_aggregate_reduction!(ResultType)` for each concrete result type. These register implementations, not rule categories. Read resolved edges through `reduction_entries()`, not raw inventory entries. -- Reduction chains expose solution and aggregate-value mappings, not solver outcomes. CLI execution coordinates those mappings when recovering a completed target result; callers establish optimality or infeasibility under their solver's numerical contract. A missing mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. +- Reduction chains expose solution and aggregate-value mappings, not solver outcomes. Every witness reduction preserves existence: source feasibility implies target feasibility. Established target or intermediate infeasibility propagates to the source without a value map or witness extraction. CLI execution coordinates mappings for feasible target results; callers establish optimality or infeasibility under their solver's numerical contract. A missing required mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. - Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. - Decision-equivalence rules map completed `Or` values identically. Decision-to-optimization rules own their feasibility/threshold map; reject target configurations that do not certify YES instead of returning an invalid source witness. Optimization rules decode optimal witnesses and evaluate the source; register a value map only when mathematically defined. Counting and universal rules map completed folds without witnesses. Follow [result mappings](../docs/src/design.md#result-mappings); no mandatory rule-category tags. - Decode only the reduction's defined mathematical mapping. Reject malformed structure with `ExtractionError`; never panic, truncate, clamp, invent defaults, or add recovery branches. Explicit mathematical alternatives and sentinels are allowed. Test successful decoding and every rejected representation. diff --git a/docs/src/design.md b/docs/src/design.md index fb7bfbd59..df19f4716 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -361,9 +361,13 @@ register concrete instances of generic implementations, including belong to the same graph edge and share its constructed result. Aggregate-only rules use `ReduceToAggregate`. -Reverse a multi-step chain one edge at a time. A NO result continues through -explicit value maps, not through a fabricated invalid witness. Missing maps, -failed extraction, and solver errors are errors, never NO. Completed-result +Every witness reduction must construct a feasible target whenever the source +is feasible. Established target infeasibility therefore implies source +infeasibility, without a witness or a value map. This also applies when an +intermediate problem is proved infeasible by its completed-value map. +For feasible targets, reverse the chain one edge at a time using the required +witness and value mappings. Missing required maps, failed extraction, and +solver errors are errors, never proof of infeasibility. Completed-result recovery follows the selected solver's contract, including its numerical tolerances. A witness-only solver API cannot return a witness for a negative decision result and reports that limitation explicitly. These rules do not diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 3387c0db2..7b0280cbd 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -99,10 +99,8 @@ pub fn solve( }; tx.send(result).ok(); }); - match rx.recv_timeout(Duration::from_secs(timeout_seconds)) { - Ok(result) => result, - Err(_) => anyhow::bail!("Solve timed out after {} seconds", timeout_seconds), - } + rx.recv_timeout(Duration::from_secs(timeout_seconds)) + .map_err(|error| crate::dispatch::solve_worker_error(error, timeout_seconds))? } else { match parsed { SolveInput::Problem(pj) => { diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 787a51732..b397e1413 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -148,6 +148,20 @@ pub fn solve_result_json(problem: &str, result: &SolveResult) -> serde_json::Val .expect("solve output is serializable") } +pub(crate) fn solve_worker_error( + error: std::sync::mpsc::RecvTimeoutError, + seconds: u64, +) -> anyhow::Error { + match error { + std::sync::mpsc::RecvTimeoutError::Timeout => { + anyhow::anyhow!("Solve timed out after {} seconds", seconds) + } + std::sync::mpsc::RecvTimeoutError::Disconnected => { + anyhow::anyhow!("Solve worker terminated without returning a result") + } + } +} + pub(crate) struct BundleSolveResult { pub(crate) source_name: String, pub(crate) target_name: String, @@ -362,7 +376,7 @@ impl BundleReplay { pub(crate) fn extract_result(&self, result: &SolveOutcome) -> Result { use problemreductions::rules::ExtractionError; let steps = &self.steps; - let (mut witness, mut value) = match result { + let (mut witness, mut value, mut evaluation) = match result { SolveOutcome::Optimal { solution, evaluation, @@ -378,11 +392,10 @@ impl BundleReplay { ) .into()); } - (Some(solution.clone()), value) + (solution.clone(), value, actual) } - SolveOutcome::Infeasible => (None, self.target.empty_aggregate_json()?), + SolveOutcome::Infeasible => return Ok(SolveOutcome::Infeasible), }; - let mut evaluation = None; for (index, step) in steps.iter().enumerate().rev() { let input: &dyn DynProblem = if index == 0 { &*self.source @@ -397,39 +410,25 @@ impl BundleReplay { }; if let Some(mapped_value) = &mapped { if input.aggregate_witness_evaluation(mapped_value)?.is_none() { - witness = None; - value = mapped_value.clone(); - evaluation = None; - continue; + return Ok(SolveOutcome::Infeasible); } } - let target_witness = witness.take().ok_or_else(|| { - ExtractionError::invalid(format!( - "cannot recover a {} witness from this value-only result", - input.problem_name() - )) - })?; - let solution = step.chain.extract_solution_json(target_witness)?; + let solution = step.chain.extract_solution_json(witness)?; value = input.evaluate_json(&solution)?; - evaluation = Some( - input - .aggregate_witness_evaluation(&value)? - .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?, - ); + evaluation = input + .aggregate_witness_evaluation(&value)? + .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?; if mapped.is_some_and(|mapped| mapped != value) { return Err(ExtractionError::invalid( "extracted witness does not realize the mapped aggregate", ) .into()); } - witness = Some(solution); + witness = solution; } - Ok(match witness { - Some(solution) => SolveOutcome::Optimal { - solution, - evaluation: evaluation.expect("a recovered witness has an evaluation"), - }, - None => SolveOutcome::Infeasible, + Ok(SolveOutcome::Optimal { + solution: witness, + evaluation, }) } @@ -529,6 +528,37 @@ pub struct PathStep { #[cfg(test)] mod tests { + #[test] + fn solve_worker_panic_is_not_a_timeout() { + let (sender, receiver) = std::sync::mpsc::channel::<()>(); + let worker = std::thread::spawn(move || { + let _sender = sender; + panic!("solver failed"); + }); + assert!(worker.join().is_err()); + let error = receiver + .recv_timeout(std::time::Duration::ZERO) + .unwrap_err(); + assert_eq!(error, std::sync::mpsc::RecvTimeoutError::Disconnected); + assert_eq!( + super::solve_worker_error(error, 120).to_string(), + "Solve worker terminated without returning a result" + ); + } + + #[test] + fn solve_worker_deadline_is_reported_as_timeout() { + let (_sender, receiver) = std::sync::mpsc::channel::<()>(); + let error = receiver + .recv_timeout(std::time::Duration::ZERO) + .unwrap_err(); + assert_eq!(error, std::sync::mpsc::RecvTimeoutError::Timeout); + assert_eq!( + super::solve_worker_error(error, 120).to_string(), + "Solve timed out after 120 seconds" + ); + } + use super::*; use crate::test_support::{AggregateValueSource, AGGREGATE_SOURCE_NAME}; use problemreductions::models::graph::MaximumIndependentSet; @@ -562,6 +592,63 @@ mod tests { BundleReplay::prepare(&bundle).unwrap() } + #[test] + fn completed_recovery_handles_infeasibility_without_value_maps() { + use problemreductions::models::algebraic::{BMF, ILP}; + use problemreductions::models::graph::BicliqueCover; + + for rank in [1, 2] { + let source = BMF::new(vec![vec![true, false], vec![false, true]], rank); + let replay = replay( + &source, + vec![ + problem_step::(), + problem_step::(), + problem_step::>(), + ], + ); + assert!(replay + .steps + .iter() + .all(|step| !step.chain.has_value_mapping())); + let result = replay.solve(SolverRequest::Ilp).unwrap(); + if rank == 1 { + assert!(matches!(result.target_outcome, SolveOutcome::Infeasible)); + assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); + } else { + let SolveOutcome::Optimal { + solution, + evaluation, + } = result.source_outcome + else { + panic!("rank-two identity matrix has an exact factorization"); + }; + assert_eq!(evaluation, "Min(4)"); + assert_eq!( + replay.source.evaluate_witness_dyn(&solution).unwrap(), + Some(evaluation) + ); + } + } + } + + #[test] + fn completed_recovery_handles_infeasible_numeric_cast() { + use problemreductions::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; + let source = ILP::::new( + 1, + vec![LinearConstraint::eq(vec![(0, 1)], 2)], + vec![(0, 1)], + ObjectiveSense::Minimize, + ) + .unwrap(); + let replay = replay(&source, vec![problem_step::>()]); + assert!(!replay.steps[0].chain.has_value_mapping()); + let result = replay.solve(SolverRequest::Ilp).unwrap(); + assert!(matches!(result.target_outcome, SolveOutcome::Infeasible)); + assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); + } + #[test] fn completed_recovery_composes_solution_only_and_value_mapping_steps() { use problemreductions::models::{Decision, MinimumVertexCover}; @@ -603,7 +690,6 @@ mod tests { ); } } - assert!(replay.extract_result(&SolveOutcome::Infeasible).is_err()); for (solution, evaluation) in [ (json!([true]), "Max(1)"), (json!([true, true, true]), "Max(None)"), diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 0c3da5a08..513b85fc6 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -466,10 +466,8 @@ impl McpServer { }; tx.send(result).ok(); }); - match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) { - Ok(result) => result, - Err(_) => anyhow::bail!("Solve timed out after {} seconds", timeout_secs), - } + rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) + .map_err(|error| crate::dispatch::solve_worker_error(error, timeout_secs))? } else if is_bundle { let bundle: ReductionBundle = serde_json::from_value(json)?; solve_bundle_inner(bundle, request) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 59a5722b2..17bd5c58e 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -3640,7 +3640,7 @@ fn test_solve_direct_ilp_i64_problem() { } #[test] -fn test_solve_partial_ilp_route_defaults_to_brute_force() { +fn test_solve_weighted_completion_time_defaults_to_ilp() { let problem_file = std::env::temp_dir() .join("pred_test_solve_sequencing_to_minimize_weighted_completion_time.json"); @@ -3679,8 +3679,10 @@ fn test_solve_partial_ilp_route_defaults_to_brute_force() { stdout.contains("\"problem\": \"SequencingToMinimizeWeightedCompletionTime\""), "{stdout}" ); - assert!(stdout.contains("\"kind\": \"brute-force\""), "{stdout}"); - assert!(stdout.contains("\"solution\": ["), "{stdout}"); + let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(result["solver"]["kind"], "ilp"); + assert_eq!(result["status"], "optimal"); + assert_eq!(result["evaluation"], "Min(46)"); std::fs::remove_file(&problem_file).ok(); } diff --git a/src/rules/traits.rs b/src/rules/traits.rs index 79c74b65b..28b79d2c8 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -172,6 +172,9 @@ pub(crate) fn validate_target_solution( /// /// This trait encapsulates the target problem and provides methods /// to extract solutions back to the source problem space. +/// Construction must preserve existence: a feasible source has a feasible target. +/// Consequently, established target infeasibility implies source infeasibility, +/// without a witness or an aggregate-value mapping. pub trait ReductionResult { /// The source problem type. type Source: Problem; diff --git a/src/solvers/pipelines.rs b/src/solvers/pipelines.rs index aa11a5c65..4527b0034 100644 --- a/src/solvers/pipelines.rs +++ b/src/solvers/pipelines.rs @@ -736,6 +736,11 @@ register_ilp_pipeline! { ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } +register_ilp_pipeline! { + ("SequencingToMinimizeWeightedCompletionTime", []), + ("ILP", [("variable", "i64"), ("coefficient", "i64")]), +} + register_ilp_pipeline! { ("SequencingToMinimizeWeightedTardiness", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), @@ -793,6 +798,11 @@ register_ilp_pipeline! { ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } +register_ilp_pipeline! { + ("SteinerTree", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + register_ilp_pipeline! { ("StringToStringCorrection", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index bd19098ea..c0debd7c2 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -154,8 +154,7 @@ impl CompiledIlpPipeline { reductions[index - 1].target_problem_any() }; let aggregate = view(step.as_ref())?; - let mut value = - aggregate.extract_value_from_solution_dyn(source_solution.as_ref())?; + let value = aggregate.extract_value_from_solution_dyn(source_solution.as_ref())?; let input_problem = crate::registry::find_variant_entry( &self.path[index].name, &self.path[index].variant, @@ -169,36 +168,7 @@ impl CompiledIlpPipeline { .map_err(crate::rules::ExtractionError::from)? .is_none() { - // No witness exists at this step. Recover the completed value - // through every remaining rule instead of invoking its decoder. - for previous in (0..index).rev() { - let view = self.reducers[previous].1.ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "cannot recover a completed value for {}: missing aggregate mapping", - self.path[previous].label() - )) - })?; - value = view(reductions[previous].as_ref())?.extract_value_dyn(value)?; - } - let original = crate::registry::find_variant_entry( - &self.path[0].name, - &self.path[0].variant, - ) - .and_then(|entry| (entry.borrow_fn)(source)) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid("pipeline source type mismatch") - })?; - if original - .aggregate_witness_evaluation(&value) - .map_err(crate::rules::ExtractionError::from)? - .is_none() - { - return Err(super::ILPSolveError::Infeasible); - } - return Err(crate::rules::ExtractionError::invalid( - "cannot recover a source witness from a value-only result", - ) - .into()); + return Err(super::ILPSolveError::Infeasible); } } source_solution = step.extract_solution_dyn(source_solution.as_ref())?; diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index f7cc0c6e7..6f5396bc6 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -129,7 +129,7 @@ fn generic_decision_ilp_reports_infeasibility_but_preserves_extraction_errors() } #[test] -fn ilp_negative_intermediate_requires_every_remaining_value_mapping() { +fn ilp_negative_intermediate_does_not_require_remaining_value_mappings() { use crate::models::graph::HamiltonianCircuit; use crate::solvers::{ILPSolveError, ILPSolver}; use crate::topology::SimpleGraph; @@ -154,11 +154,7 @@ fn ilp_negative_intermediate_requires_every_remaining_value_mapping() { pipeline.reducers[0].1 = None; let result = pipeline.solve(&problem, &ILPSolver::new()); assert!( - matches!( - &result, - Err(ILPSolveError::Extraction(crate::rules::ExtractionError::InvalidTargetSolution(message))) - if message.contains("missing aggregate mapping") - ), + matches!(&result, Err(ILPSolveError::Infeasible)), "{result:?}" ); } diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index 6648e7b82..f98171f99 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -4,6 +4,60 @@ use crate::solvers::{solve, SolveOutcome, SolverExecution, SolverRequest}; use crate::traits::Problem; use std::collections::BTreeMap; +#[test] +fn tree_and_weighted_sequencing_default_to_ilp() { + let tree_variant = BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]); + let cases = [ + ( + "SteinerTree", + tree_variant.clone(), + serde_json::json!({"graph": {"num_vertices": 3, "edges": [[0,1],[1,2]]}, + "edge_weights": [1,-3], "terminals": [0,1]}), + ), + ( + "SteinerTree", + tree_variant, + serde_json::json!({"graph": {"num_vertices": 3, "edges": [[0,1]]}, + "edge_weights": [1], "terminals": [0,2]}), + ), + ( + "SequencingToMinimizeWeightedCompletionTime", + BTreeMap::new(), + serde_json::json!({"lengths": [2,1,0], "weights": [3,5,2], + "precedences": [[0,2],[1,2]]}), + ), + ]; + for (name, variant, data) in cases { + let problem = load_dyn(name, &variant, data).unwrap(); + let expected = solve(&problem, SolverRequest::BruteForce).unwrap(); + let actual = solve(&problem, SolverRequest::Default).unwrap(); + assert!( + matches!(actual.solver, SolverExecution::Ilp { .. }), + "{name}" + ); + match (expected.outcome, actual.outcome) { + (SolveOutcome::Infeasible, SolveOutcome::Infeasible) => {} + ( + SolveOutcome::Optimal { + evaluation: expected, + .. + }, + SolveOutcome::Optimal { + solution, + evaluation, + }, + ) => { + assert_eq!(evaluation, expected, "{name}"); + assert_eq!(problem.evaluate_dyn(&solution).unwrap(), expected, "{name}"); + } + outcomes => panic!("{name}: mismatched outcomes: {outcomes:?}"), + } + } +} + #[test] fn decision_ilp_paths_respect_bounds_and_return_valid_witnesses() { let graph = serde_json::json!({"num_vertices": 3, "edges": [[0,1],[1,2]]}); From e48e6f3b9a34946d7d1303e871c8fcb65c479a78 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 21 Sep 2026 13:09:35 +0800 Subject: [PATCH 20/44] Enumerate permutation witnesses with Lehmer coordinates --- src/models/misc/betweenness.rs | 4 +- src/models/misc/cyclic_ordering.rs | 4 +- ...mum_code_generation_unlimited_registers.rs | 5 +- src/unit_tests/models/misc/betweenness.rs | 2 +- src/unit_tests/models/misc/cyclic_ordering.rs | 2 +- ...mum_code_generation_unlimited_registers.rs | 2 +- src/unit_tests/solvers/brute_force.rs | 65 +++++++++++++++++++ 7 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/models/misc/betweenness.rs b/src/models/misc/betweenness.rs index bf55a1c12..99a28b5c0 100644 --- a/src/models/misc/betweenness.rs +++ b/src/models/misc/betweenness.rs @@ -171,7 +171,7 @@ impl Problem for Betweenness { impl crate::solvers::BruteForceProblem for Betweenness { fn dimensions(&self) -> Vec { - vec![self.num_elements; self.num_elements] + super::lehmer_dims(self.num_elements) } } @@ -180,7 +180,7 @@ crate::declare_variants! { } crate::register_brute_force! { - Betweenness, + Betweenness decode |problem: &Betweenness, indices: Vec| super::decode_lehmer(&indices, problem.num_elements()).expect("enumerated Lehmer digits are valid"), } #[cfg(feature = "example-db")] diff --git a/src/models/misc/cyclic_ordering.rs b/src/models/misc/cyclic_ordering.rs index 0edaffe5f..bb5f73652 100644 --- a/src/models/misc/cyclic_ordering.rs +++ b/src/models/misc/cyclic_ordering.rs @@ -176,7 +176,7 @@ impl Problem for CyclicOrdering { impl crate::solvers::BruteForceProblem for CyclicOrdering { fn dimensions(&self) -> Vec { - vec![self.num_elements; self.num_elements] + super::lehmer_dims(self.num_elements) } } @@ -185,7 +185,7 @@ crate::declare_variants! { } crate::register_brute_force! { - CyclicOrdering, + CyclicOrdering decode |problem: &CyclicOrdering, indices: Vec| super::decode_lehmer(&indices, problem.num_elements()).expect("enumerated Lehmer digits are valid"), } #[cfg(feature = "example-db")] diff --git a/src/models/misc/minimum_code_generation_unlimited_registers.rs b/src/models/misc/minimum_code_generation_unlimited_registers.rs index 1a5744db9..3090a3944 100644 --- a/src/models/misc/minimum_code_generation_unlimited_registers.rs +++ b/src/models/misc/minimum_code_generation_unlimited_registers.rs @@ -393,8 +393,7 @@ impl Problem for MinimumCodeGenerationUnlimitedRegisters { impl crate::solvers::BruteForceProblem for MinimumCodeGenerationUnlimitedRegisters { fn dimensions(&self) -> Vec { - let n_internal = self.num_internal(); - vec![n_internal; n_internal] + super::lehmer_dims(self.num_internal()) } } @@ -403,7 +402,7 @@ crate::declare_variants! { } crate::register_brute_force! { - MinimumCodeGenerationUnlimitedRegisters, + MinimumCodeGenerationUnlimitedRegisters decode |problem: &MinimumCodeGenerationUnlimitedRegisters, indices: Vec| super::decode_lehmer(&indices, problem.num_internal()).expect("enumerated Lehmer digits are valid"), } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/models/misc/betweenness.rs b/src/unit_tests/models/misc/betweenness.rs index ffd895343..2c5466115 100644 --- a/src/unit_tests/models/misc/betweenness.rs +++ b/src/unit_tests/models/misc/betweenness.rs @@ -17,7 +17,7 @@ fn test_betweenness_basic() { problem.triples(), &[(0, 1, 2), (2, 3, 4), (0, 2, 4), (1, 3, 4)] ); - assert_eq!(problem.dimensions(), vec![5; 5]); + assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); assert_eq!(problem.num_variables(), 5); assert_eq!(::NAME, "Betweenness"); assert_eq!(::variant(), vec![]); diff --git a/src/unit_tests/models/misc/cyclic_ordering.rs b/src/unit_tests/models/misc/cyclic_ordering.rs index 5f8bb0b76..b93680dc3 100644 --- a/src/unit_tests/models/misc/cyclic_ordering.rs +++ b/src/unit_tests/models/misc/cyclic_ordering.rs @@ -14,7 +14,7 @@ fn test_cyclic_ordering_basic() { assert_eq!(problem.num_elements(), 5); assert_eq!(problem.num_triples(), 3); assert_eq!(problem.triples(), &[(0, 1, 2), (2, 3, 0), (1, 3, 4)]); - assert_eq!(problem.dimensions(), vec![5; 5]); + assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); assert_eq!(problem.num_variables(), 5); assert_eq!(::NAME, "CyclicOrdering"); assert_eq!(::variant(), vec![]); diff --git a/src/unit_tests/models/misc/minimum_code_generation_unlimited_registers.rs b/src/unit_tests/models/misc/minimum_code_generation_unlimited_registers.rs index 8525b22a4..f1ca88395 100644 --- a/src/unit_tests/models/misc/minimum_code_generation_unlimited_registers.rs +++ b/src/unit_tests/models/misc/minimum_code_generation_unlimited_registers.rs @@ -40,7 +40,7 @@ fn test_minimum_code_generation_unlimited_registers_creation() { assert_eq!(problem.num_internal(), 3); assert_eq!(problem.left_arcs(), &[(1, 3), (2, 3), (0, 1)]); assert_eq!(problem.right_arcs(), &[(1, 4), (2, 4), (0, 2)]); - assert_eq!(problem.dimensions(), vec![3; 3]); + assert_eq!(problem.dimensions(), vec![3, 2, 1]); assert_eq!( ::NAME, "MinimumCodeGenerationUnlimitedRegisters" diff --git a/src/unit_tests/solvers/brute_force.rs b/src/unit_tests/solvers/brute_force.rs index c6cf3667b..66d4f23e2 100644 --- a/src/unit_tests/solvers/brute_force.rs +++ b/src/unit_tests/solvers/brute_force.rs @@ -4,6 +4,71 @@ use crate::types::{AggregationError, Max, Min, Or, Sum}; use std::cell::Cell; use std::rc::Rc; +#[test] +fn test_brute_force_permutation_models_preserve_all_optimal_witnesses() { + use crate::models::misc::{ + Betweenness, CyclicOrdering, MinimumCodeGenerationUnlimitedRegisters, + }; + + fn check

(problem: P) + where + P: BruteForceProblem> + 'static, + P::Value: SolutionAggregate + PartialEq + 'static, + { + let n = problem.num_variables(); + assert_eq!( + problem.dimensions().iter().product::(), + (1..=n).product::() + ); + let candidates = CartesianIndices::new(vec![n; n]) + .unwrap() + .map(|solution| { + let value = problem.evaluate(&solution).unwrap(); + (solution, value) + }) + .collect::>(); + let expected_value = candidates + .iter() + .fold(P::Value::identity(), |total, (_, value)| { + total.combine(value.clone()).unwrap() + }); + let mut expected = candidates + .into_iter() + .filter(|(_, value)| P::Value::contributes_to_solution(value, &expected_value)) + .map(|(solution, _)| solution) + .collect::>(); + let (actual_value, mut actual) = BruteForce::new().solve_with_witnesses(&problem).unwrap(); + expected.sort(); + actual.sort(); + assert_eq!(actual_value, expected_value); + assert_eq!(actual, expected); + let solution = BruteForce::new().solve(&problem).unwrap(); + assert_eq!(solution.is_none(), expected.is_empty()); + if let Some(solution) = solution { + assert!(expected.contains(&solution)); + } + } + + for n in 1..=5 { + check(CyclicOrdering::new(n, vec![])); + check(Betweenness::new(n, vec![])); + } + check(CyclicOrdering::new(3, vec![(0, 1, 2), (0, 2, 1)])); + check(Betweenness::new(3, vec![(0, 1, 2), (1, 0, 2)])); + check(CyclicOrdering::new(4, vec![(0, 2, 1), (1, 3, 2)])); + check(Betweenness::new(4, vec![(0, 2, 1), (1, 3, 2)])); + check(MinimumCodeGenerationUnlimitedRegisters::new( + 2, + vec![], + vec![], + )); + check(MinimumCodeGenerationUnlimitedRegisters::new( + 5, + vec![(1, 3), (2, 3), (0, 1)], + vec![(1, 4), (2, 4), (0, 2)], + )); +} + #[derive(Clone, serde::Serialize, serde::Deserialize)] struct MaxSumProblem { weights: Vec, From ef634c2b661e0320df74dc3dfc0743b1c1870d6f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 21 Sep 2026 13:09:35 +0800 Subject: [PATCH 21/44] Encode scheduling and circuit decision bounds as ILP constraints --- docs/paper/reductions.typ | 46 +++++++ src/rules/longestcircuit_ilp.rs | 115 +++++++++++++++--- src/rules/openshopscheduling_ilp.rs | 105 ++++++++++++++-- src/solvers/pipelines.rs | 3 - src/unit_tests/rules/longestcircuit_ilp.rs | 45 +++++++ .../rules/openshopscheduling_ilp.rs | 39 ++++++ src/unit_tests/solvers/registry.rs | 17 +++ 7 files changed, 343 insertions(+), 27 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index cb21247e6..a0ff1188e 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -15384,6 +15384,29 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Return the $n m$ start-time variables $s_{j,i}$ directly in job-major order. ] +#let doss_ilp = load-example("DecisionOpenShopScheduling", "ILP") +#reduction-rule("DecisionOpenShopScheduling", "ILP", + example: true, + example-caption: [A bounded open-shop schedule], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(doss_ilp.source) + " -o schedule.json", + "pred reduce schedule.json --via route.json -o bundle.json", + "pred solve bundle.json", + "pred evaluate schedule.json --config " + cli-config(doss_ilp.solutions.at(0).source_config), + ) + The canonical instance has processing times #repr(doss_ilp.source.instance.inner.processing_times) and bound #doss_ilp.source.instance.bound. Add the makespan constraint with this bound and set the objective to zero. The stored feasible ILP assignment decodes to start times #fmt-values(doss_ilp.solutions.at(0).source_config), which satisfy the bound. The fixture stores one witness. + ], +)[ + Impose the decision bound on the open-shop makespan variable. The optimization formulation gains one constraint and no variables. +][ + _Construction._ For bound $B$, use the OpenShopScheduling-to-ILP construction above, add $C <= B$, and replace the objective with zero. + + _Correctness._ ($arrow.r.double$) A schedule of makespan at most $B$ gives feasible ordering variables and start times, with $C$ equal to its makespan. The existing horizon bounds can be met by removing unnecessary idle time. ($arrow.l.double$) Every feasible target assignment decodes to a schedule whose makespan is at most $C <= B$. Thus target feasibility is equivalent to the source YES answer; no optimum needs to be computed. + + _Solution extraction._ Check target feasibility, then use the existing job-major start-time decoder. Construction has the same asymptotic cost as the optimization formulation.#footnote[Complexity follows from the implementation; not independently verified from literature.] +] + #reduction-rule("MinimumTardinessSequencing", "ILP")[ A position-assignment ILP captures the permutation, the precedence constraints, and a binary tardy indicator for each unit-length task. ][ @@ -15725,6 +15748,29 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Output the binary edge-selection vector $(y_e)_(e in E)$. ] +#let dlc_ilp = load-example("DecisionLongestCircuit", "ILP") +#reduction-rule("DecisionLongestCircuit", "ILP", + example: true, + example-caption: [A circuit meeting a length bound], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(dlc_ilp.source) + " -o circuit.json", + "pred reduce circuit.json --via route.json -o bundle.json", + "pred solve bundle.json", + "pred evaluate circuit.json --config " + cli-config(dlc_ilp.solutions.at(0).source_config), + ) + The canonical instance has edge lengths #repr(dlc_ilp.source.instance.inner.edge_lengths) and bound #dlc_ilp.source.instance.bound. Add the selected-length constraint with this bound and set the objective to zero. The stored feasible ILP assignment decodes to edge selections #fmt-values(dlc_ilp.solutions.at(0).source_config), whose total length meets the bound. The fixture stores one witness. + ], +)[ + Impose the decision bound on the selected circuit length. The optimization formulation gains one constraint and no variables. +][ + _Construction._ For bound $B$, use the LongestCircuit-to-ILP construction above, add $sum_(e in E) l_e y_e >= B$, and replace the objective with zero. + + _Correctness._ ($arrow.r.double$) A circuit of length at least $B$ extends to the existing selection and connectivity variables and meets the new constraint. ($arrow.l.double$) Every feasible target assignment selects one simple circuit, and the new constraint guarantees its length is at least $B$. A graph with no circuit remains infeasible regardless of the bound. + + _Solution extraction._ Check target feasibility, then return the existing edge-selection vector. Construction has the same asymptotic cost as the optimization formulation.#footnote[Complexity follows from the implementation; not independently verified from literature.] +] + #reduction-rule("QuadraticAssignment", "ILP")[ Assign each facility to exactly one location, enforce injectivity, and linearize every quadratic cost term with McCormick products. ][ diff --git a/src/rules/longestcircuit_ilp.rs b/src/rules/longestcircuit_ilp.rs index c67c9a2e7..02e3f9bb5 100644 --- a/src/rules/longestcircuit_ilp.rs +++ b/src/rules/longestcircuit_ilp.rs @@ -10,6 +10,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::LongestCircuit; +use crate::models::Decision; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; @@ -27,6 +28,15 @@ pub struct ReductionLongestCircuitToILP { num_edges: usize, } +impl ReductionLongestCircuitToILP { + fn decode_edges(&self, solution: &[i64]) -> Vec { + solution[..self.num_edges] + .iter() + .map(|&value| value == 1) + .collect() + } +} + impl ReductionResult for ReductionLongestCircuitToILP { type Source = LongestCircuit; type Target = ILP; @@ -42,10 +52,7 @@ impl ReductionResult for ReductionLongestCircuitToILP { ) -> crate::rules::ExtractionResult<::Solution> { crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - Ok(target_solution[..self.num_edges] - .iter() - .map(|&value| value == 1) - .collect()) + Ok(self.decode_edges(target_solution)) } } @@ -174,19 +181,99 @@ impl ReduceTo> for LongestCircuit { } } +/// Feasibility encoding of the circuit-length bound, with the existing edge decoder. +#[derive(Debug, Clone)] +pub struct ReductionDecisionLongestCircuitToILP { + inner: ReductionLongestCircuitToILP, +} + +impl ReductionResult for ReductionDecisionLongestCircuitToILP { + type Source = Decision>; + type Target = ILP; + + fn target_problem(&self) -> &Self::Target { + self.inner.target_problem() + } + + fn extract_solution(&self, solution: &Vec) -> crate::rules::ExtractionResult> { + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "ILP assignment does not satisfy the bounded circuit constraints", + )); + } + Ok(self.inner.decode_edges(solution)) + } +} + +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionDecisionLongestCircuitToILP { + type Source = Decision>; + type Target = ILP; + + fn target_problem(&self) -> &Self::Target { + self.inner.target_problem() + } + + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + +#[reduction( + transform = exact { + num_vars = "num_edges + 2 * num_vertices + 2 * num_edges * num_vertices", + num_constraints = "3 + num_vertices + 2 * num_vertices^2 + 2 * num_edges * num_vertices", + }, + unavailable = { + num_nonzeros = "depends on the graph and nonzero edge lengths", + } +)] +impl ReduceTo> for Decision> { + type Result = ReductionDecisionLongestCircuitToILP; + + fn reduce_to(&self) -> Result { + let mut inner = ReduceTo::>::reduce_to(self.inner())?; + let mut constraints = inner.target.constraints().to_vec(); + constraints.push(LinearConstraint::ge( + inner.target.objective().to_vec(), + *self.bound(), + )); + inner.target = ILP::with_variables( + inner.target.variables().to_vec(), + constraints, + vec![], + ObjectiveSense::Minimize, + ) + .map_err(>>::target_construction)?; + Ok(ReductionDecisionLongestCircuitToILP { inner }) + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { - vec![crate::example_db::specs::RuleExampleSpec { - id: "longestcircuit_to_ilp", - build: || { - // Triangle with unit lengths - let source = LongestCircuit::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), - vec![1, 1, 1], - ); - crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + vec![ + crate::example_db::specs::RuleExampleSpec { + id: "decisionlongestcircuit_to_ilp", + build: || { + let source = + Decision::new(LongestCircuit::new(SimpleGraph::cycle(3), vec![1i64; 3]), 3); + crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + }, + }, + crate::example_db::specs::RuleExampleSpec { + id: "longestcircuit_to_ilp", + build: || { + // Triangle with unit lengths + let source = LongestCircuit::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![1, 1, 1], + ); + crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) + }, }, - }] + ] } #[cfg(test)] diff --git a/src/rules/openshopscheduling_ilp.rs b/src/rules/openshopscheduling_ilp.rs index 41f385661..d2d1f2bcb 100644 --- a/src/rules/openshopscheduling_ilp.rs +++ b/src/rules/openshopscheduling_ilp.rs @@ -28,6 +28,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::OpenShopScheduling; +use crate::models::Decision; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; @@ -49,6 +50,12 @@ pub struct ReductionOSSToILP { } impl ReductionOSSToILP { + fn decode_schedule(&self, solution: &[i64]) -> crate::rules::ExtractionResult> { + let start = self.num_order_vars; + let end = start + self.num_jobs * self.num_machines; + crate::rules::ilp_helpers::decode_usize_values(&solution[start..end]) + } + fn pair_idx(&self, j: usize, k: usize) -> usize { debug_assert!(j < k); let n = self.num_jobs; @@ -92,9 +99,7 @@ impl ReductionResult for ReductionOSSToILP { target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let start = self.num_order_vars; - let end = start + self.num_jobs * self.num_machines; - crate::rules::ilp_helpers::decode_usize_values(&target_solution[start..end]) + self.decode_schedule(target_solution) } } @@ -272,16 +277,96 @@ impl ReduceTo> for OpenShopScheduling { } } +/// Feasibility encoding of the makespan bound, with the existing schedule decoder. +#[derive(Debug, Clone)] +pub struct ReductionDecisionOpenShopSchedulingToILP { + inner: ReductionOSSToILP, +} + +impl ReductionResult for ReductionDecisionOpenShopSchedulingToILP { + type Source = Decision; + type Target = ILP; + + fn target_problem(&self) -> &Self::Target { + self.inner.target_problem() + } + + fn extract_solution(&self, solution: &Vec) -> crate::rules::ExtractionResult> { + let value = + crate::rules::traits::validate_target_solution(self.target_problem(), solution)?; + if value.value.is_none() { + return Err(crate::rules::ExtractionError::invalid( + "ILP assignment does not satisfy the bounded scheduling constraints", + )); + } + self.inner.decode_schedule(solution) + } +} + +#[crate::aggregate_reduction] +impl crate::rules::AggregateReductionResult for ReductionDecisionOpenShopSchedulingToILP { + type Source = Decision; + type Target = ILP; + + fn target_problem(&self) -> &Self::Target { + self.inner.target_problem() + } + + fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { + crate::types::Or(value.value.is_some()) + } +} + +#[reduction( + transform = exact { + num_vars = "num_jobs * (num_jobs - 1) / 2 * num_machines + num_jobs * num_machines + num_jobs * num_machines * (num_machines - 1) / 2 + 1", + num_constraints = "3 * num_jobs * (num_jobs - 1) / 2 * num_machines + 2 * num_jobs * num_machines + 3 * num_jobs * num_machines * (num_machines - 1) / 2 + 2", + }, + unavailable = { + num_nonzeros = "depends on the generated scheduling constraints", + } +)] +impl ReduceTo> for Decision { + type Result = ReductionDecisionOpenShopSchedulingToILP; + + fn reduce_to(&self) -> Result { + let mut inner = ReduceTo::>::reduce_to(self.inner())?; + let mut constraints = inner.target.constraints().to_vec(); + constraints.push(LinearConstraint::le( + inner.target.objective().to_vec(), + *self.bound(), + )); + inner.target = ILP::with_variables( + inner.target.variables().to_vec(), + constraints, + vec![], + ObjectiveSense::Minimize, + ) + .map_err(>>::target_construction)?; + Ok(ReductionDecisionOpenShopSchedulingToILP { inner }) + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { - vec![crate::example_db::specs::RuleExampleSpec { - id: "openshopscheduling_to_ilp", - build: || { - // Small 2x2 instance for canonical example - let source = OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]); - crate::example_db::specs::rule_example_via_ilp::<_, i64>(source) + vec![ + crate::example_db::specs::RuleExampleSpec { + id: "decisionopenshopscheduling_to_ilp", + build: || { + let source = + Decision::new(OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]), 3); + crate::example_db::specs::rule_example_via_ilp::<_, i64>(source) + }, + }, + crate::example_db::specs::RuleExampleSpec { + id: "openshopscheduling_to_ilp", + build: || { + // Small 2x2 instance for canonical example + let source = OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]); + crate::example_db::specs::rule_example_via_ilp::<_, i64>(source) + }, }, - }] + ] } #[cfg(test)] diff --git a/src/solvers/pipelines.rs b/src/solvers/pipelines.rs index 4527b0034..d10636f6a 100644 --- a/src/solvers/pipelines.rs +++ b/src/solvers/pipelines.rs @@ -207,7 +207,6 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("HamiltonianCircuit", [("graph", "SimpleGraph")]), ("DecisionLongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), - ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } @@ -851,7 +850,6 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("DecisionLongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), - ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } @@ -890,7 +888,6 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("DecisionOpenShopScheduling", []), - ("OpenShopScheduling", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), } diff --git a/src/unit_tests/rules/longestcircuit_ilp.rs b/src/unit_tests/rules/longestcircuit_ilp.rs index 46f03d612..1ff88d8f1 100644 --- a/src/unit_tests/rules/longestcircuit_ilp.rs +++ b/src/unit_tests/rules/longestcircuit_ilp.rs @@ -4,6 +4,51 @@ use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; +#[test] +fn test_decision_longestcircuit_to_ilp_bound_is_a_constraint() { + let inner = LongestCircuit::new(SimpleGraph::cycle(3), vec![1, 2, 3]); + let optimization = ReduceTo::>::reduce_to(&inner).unwrap(); + let solver = ILPSolver::new(); + let optimal = solver.solve(optimization.target_problem()).unwrap(); + for bound in [-1, 5, 6, 7] { + let source = Decision::new(inner.clone(), bound); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + assert!(target.objective().is_empty()); + assert_eq!(target.num_vars(), optimization.target_problem().num_vars()); + assert_eq!( + target.num_constraints(), + optimization.target_problem().num_constraints() + 1 + ); + let expected = BruteForce::new().solve(&source).unwrap(); + let actual = solver.solve(&source); + if expected.is_none() { + assert!(matches!( + actual, + Err(crate::solvers::ILPSolveError::Infeasible) + )); + assert!(reduction.extract_solution(&optimal).is_err()); + } else { + assert_eq!( + source.evaluate(&actual.unwrap()).unwrap(), + crate::types::Or(true) + ); + assert!(reduction + .extract_solution(&vec![0; target.num_vars()]) + .is_err()); + } + } + let acyclic = Decision::new(LongestCircuit::new(SimpleGraph::path(3), vec![1, 2]), -1); + assert!(matches!( + solver.solve(&acyclic), + Err(crate::solvers::ILPSolveError::Infeasible) + )); + assert_eq!( + inner.evaluate(&solver.solve(&inner).unwrap()).unwrap(), + crate::types::Max(Some(6)) + ); +} + #[test] fn test_reduction_creates_valid_ilp() { // Triangle with unit lengths diff --git a/src/unit_tests/rules/openshopscheduling_ilp.rs b/src/unit_tests/rules/openshopscheduling_ilp.rs index 904f7fbf2..27b813623 100644 --- a/src/unit_tests/rules/openshopscheduling_ilp.rs +++ b/src/unit_tests/rules/openshopscheduling_ilp.rs @@ -11,6 +11,45 @@ fn small_instance() -> OpenShopScheduling { OpenShopScheduling::new(2, vec![vec![1, 2], vec![2, 1]]) } +#[test] +fn test_decision_openshopscheduling_to_ilp_bound_is_a_constraint() { + let inner = small_instance(); + let optimization = ReduceTo::>::reduce_to(&inner).unwrap(); + let solver = ILPSolver::new(); + let optimal = solver.solve(optimization.target_problem()).unwrap(); + for bound in [-1, 2, 3, 4] { + let source = Decision::new(inner.clone(), bound); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + assert!(target.objective().is_empty()); + assert_eq!(target.num_vars(), optimization.target_problem().num_vars()); + assert_eq!( + target.num_constraints(), + optimization.target_problem().num_constraints() + 1 + ); + let result = solver.solve(&source); + if bound < 3 { + assert!(matches!( + result, + Err(crate::solvers::ILPSolveError::Infeasible) + )); + assert!(reduction.extract_solution(&optimal).is_err()); + } else { + assert_eq!( + source.evaluate(&result.unwrap()).unwrap(), + crate::types::Or(true) + ); + assert!(reduction + .extract_solution(&vec![0; target.num_vars()]) + .is_err()); + } + } + assert_eq!( + inner.evaluate(&solver.solve(&inner).unwrap()).unwrap(), + Min(Some(3)) + ); +} + /// 3 machines, 2 jobs. fn medium_instance() -> OpenShopScheduling { OpenShopScheduling::new(3, vec![vec![3, 1, 2], vec![2, 3, 1]]) diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 6f5396bc6..d2094f301 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -151,6 +151,23 @@ fn ilp_negative_intermediate_does_not_require_remaining_value_mappings() { path: original.path.clone(), reducers: original.reducers.clone(), }; + // Exercise completed-value recovery through the explicit optimization route. + pipeline.path.insert( + 2, + ExactProblemKey::new("LongestCircuit", pipeline.path[1].variant.clone()), + ); + pipeline.reducers = pipeline + .path + .windows(2) + .map(|pair| { + let entry = reduction_entries() + .iter() + .copied() + .find(|entry| edge_key(entry, true) == pair[0] && edge_key(entry, false) == pair[1]) + .unwrap(); + (entry.reduce_fn.unwrap(), entry.aggregate_view_fn) + }) + .collect(); pipeline.reducers[0].1 = None; let result = pipeline.solve(&problem, &ILPSolver::new()); assert!( From 67715f12b0cd7ad92042ab9b2b9f6ed85250d21f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 21 Sep 2026 13:27:04 +0800 Subject: [PATCH 22/44] Simplify ILP recovery test pipeline construction --- src/unit_tests/solvers/registry.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index d2094f301..c4480fa49 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -147,17 +147,13 @@ fn ilp_negative_intermediate_does_not_require_remaining_value_mappings() { original.solve(&problem, &ILPSolver::new()), Err(ILPSolveError::Infeasible) )); - let mut pipeline = CompiledIlpPipeline { - path: original.path.clone(), - reducers: original.reducers.clone(), - }; // Exercise completed-value recovery through the explicit optimization route. - pipeline.path.insert( + let mut path = original.path.clone(); + path.insert( 2, - ExactProblemKey::new("LongestCircuit", pipeline.path[1].variant.clone()), + ExactProblemKey::new("LongestCircuit", path[1].variant.clone()), ); - pipeline.reducers = pipeline - .path + let reducers = path .windows(2) .map(|pair| { let entry = reduction_entries() @@ -168,6 +164,7 @@ fn ilp_negative_intermediate_does_not_require_remaining_value_mappings() { (entry.reduce_fn.unwrap(), entry.aggregate_view_fn) }) .collect(); + let mut pipeline = CompiledIlpPipeline { path, reducers }; pipeline.reducers[0].1 = None; let result = pipeline.solve(&problem, &ILPSolver::new()); assert!( From 8dfa3a4517a72c1ed9dafbfcda707d22e4c06a85 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 21 Sep 2026 14:31:33 +0800 Subject: [PATCH 23/44] Validate target feasibility during extraction and remove test-only API --- problemreductions-cli/src/commands/extract.rs | 5 +- problemreductions-cli/tests/cli_tests.rs | 51 +++++++++++++++++++ src/registry/dyn_problem.rs | 12 +---- src/unit_tests/example_db.rs | 7 --- src/unit_tests/rules/aggregate_contracts.rs | 1 + 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index 8fac8a558..ac869f5e4 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -79,7 +79,10 @@ pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { } ExternalResult::Feasible { ref solution } | ExternalResult::Optimal { ref solution } => { let replay = BundleReplay::prepare(&bundle)?; - let actual = replay.target.evaluate_dyn(solution)?; + let actual = replay + .target + .evaluate_witness_dyn(solution)? + .context("target witness is infeasible")?; if evaluation .as_ref() .is_some_and(|value| value != &serde_json::json!(actual)) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 17bd5c58e..0e033e6f3 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -9900,6 +9900,57 @@ fn test_completed_decision_recovery_and_aggregate_cli() { std::fs::remove_dir_all(dir).unwrap(); } +#[test] +fn test_extract_rejects_infeasible_target_even_when_decoded_source_is_feasible() { + use problemreductions::models::{OpenShopScheduling, ILP}; + use problemreductions::rules::{ReduceTo, ReductionResult}; + use serde_json::json; + + let source = OpenShopScheduling::new(1, vec![vec![1]]); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let bundle = std::env::temp_dir().join(format!( + "pred-extract-target-feasibility-{}.json", + std::process::id() + )); + let source_key = json!({"name":"OpenShopScheduling","variant":{}}); + let target_variant = json!({"variable":"i64","coefficient":"i64"}); + std::fs::write( + &bundle, + json!({ + "source":{"type":"OpenShopScheduling","variant":{},"data":source}, + "target":{"type":"ILP","variant":target_variant,"data":reduction.target_problem()}, + "path":[source_key,{"name":"ILP","variant":target_variant}] + }) + .to_string(), + ) + .unwrap(); + + // Both assignments decode to start time 0; only C=1 satisfies C-start >= 1. + for status in ["feasible", "optimal"] { + for evaluation in [None, Some("Min(None)")] { + let mut result = json!({"status":status,"solution":[0,0]}); + if let Some(evaluation) = evaluation { + result["evaluation"] = json!(evaluation); + } + let output = extract_target_result(&bundle, result); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("target witness is infeasible"), "{stderr}"); + } + let output = extract_target_result(&bundle, json!({"status":status,"solution":[0,1]})); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["status"], status); + assert_eq!(result["solution"], json!([0])); + assert_eq!(result["evaluation"], "Min(1)"); + } + std::fs::remove_file(bundle).unwrap(); +} + #[test] fn test_extract_roundtrip_mis_to_qubo() { let problem_file = std::env::temp_dir().join("pred_test_extract_in.json"); diff --git a/src/registry/dyn_problem.rs b/src/registry/dyn_problem.rs index e607d5c32..b50fa1e16 100644 --- a/src/registry/dyn_problem.rs +++ b/src/registry/dyn_problem.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use std::fmt; use crate::traits::{EvaluationError, Problem}; -use crate::types::{Aggregate, SolutionAggregate}; +use crate::types::SolutionAggregate; /// Format a metric for CLI- and registry-facing dynamic dispatch. /// @@ -21,8 +21,6 @@ where /// /// Implemented for serializable problems whose values support solution witnesses. pub trait DynProblem: Any { - /// Aggregate for an exhausted problem with no feasible witnesses. - fn empty_aggregate_json(&self) -> Result; /// Whether a completed aggregate admits a representative witness. fn aggregate_witness_evaluation( &self, @@ -55,14 +53,6 @@ where T::Solution: serde::de::DeserializeOwned, T::Value: SolutionAggregate + fmt::Display + Serialize, { - fn empty_aggregate_json(&self) -> Result { - serde_json::to_value(T::Value::identity()).map_err(|error| { - EvaluationError::InvalidConfiguration(format!( - "cannot serialize aggregate identity: {error}" - )) - }) - } - fn aggregate_witness_evaluation( &self, value: &Value, diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index be0bed438..d9e5eebd4 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -783,13 +783,6 @@ fn rule_specs_solution_pairs_are_consistent() { "Rule {label}: aggregate and witness mappings disagree" ); if source_eval == "Or(true)" { - assert_eq!( - chain - .extract_value(target.empty_aggregate_json().unwrap()) - .unwrap(), - serde_json::json!(false), - "Rule {label}: infeasible target must map to NO" - ); if let Some(config) = pair.target_config.as_array() { for bit in [false, true] { let candidate = serde_json::Value::Array( diff --git a/src/unit_tests/rules/aggregate_contracts.rs b/src/unit_tests/rules/aggregate_contracts.rs index 140e9e1ca..7c5d1723b 100644 --- a/src/unit_tests/rules/aggregate_contracts.rs +++ b/src/unit_tests/rules/aggregate_contracts.rs @@ -61,6 +61,7 @@ where >::Result: AggregateReductionResult, { let reduction = source.reduce_to().unwrap(); + assert_eq!(reduction.extract_value(T::Value::identity()), Or(false)); let target = ReductionResult::target_problem(&reduction); let (total, witnesses) = BruteForce::new().solve_with_witnesses(target).unwrap(); let expected = Or(BruteForce::new().solve(source).unwrap().is_some()); From 0b118a1c50cd4f97ab9c26ca2a7249f3d26a0e44 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 21 Sep 2026 14:55:04 +0800 Subject: [PATCH 24/44] Expose rule solution and value mappings directly in pred extract --- docs/paper/reductions.typ | 3 +- docs/src/cli-commands.md | 33 +- problemreductions-cli/src/cli.rs | 48 +- problemreductions-cli/src/commands/extract.rs | 153 ++----- problemreductions-cli/tests/cli_tests.rs | 417 ++++-------------- 5 files changed, 175 insertions(+), 479 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 4fa5aed1b..2a232b988 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -17934,8 +17934,7 @@ The following table shows concrete target-variable counts for example instances, "pred create --example DecisionMaximumIndependentSet/One -o independent-set.json", "pred reduce independent-set.json --via route.json -o bundle.json", "pred solve bundle.json", - "echo '{\"status\":\"feasible\",\"solution\":" + json.encode(mis_ifb_sol.target_config) + "}' > target-result.json", - "pred extract bundle.json --result target-result.json", + "pred extract bundle.json --config " + cli-config(mis_ifb_sol.target_config), ) Source bound: #mis_ifb.source.instance.bound; selected vertices: #fmt-values(mis_ifb_sol.source_config) \ Target: #mis_ifb.target.instance.graph.num_vertices vertices, #mis_ifb.target.instance.graph.arcs.len() arcs, #mis_ifb.target.instance.bundles.len() bundles; requirement #mis_ifb.target.instance.requirement \ diff --git a/docs/src/cli-commands.md b/docs/src/cli-commands.md index 651dbe8eb..3930d3000 100644 --- a/docs/src/cli-commands.md +++ b/docs/src/cli-commands.md @@ -93,25 +93,32 @@ For a problem file, JSON inspection includes `parameter_values`, the model's act pred path MIS QUBO --json -o paths.json python3 -c 'import json; print(json.dumps(json.load(open("paths.json"))["paths"][0]))' > path.json pred reduce problem.json --via path.json -o reduced.json -pred extract reduced.json --result target-result.json -o source-result.json +pred extract reduced.json --config '[true,false]' -o source-solution.json +pred extract reduced.json --value 2 ``` The bundle contains the source instance, the target instance, and the variant-level path; keep it whole to preserve solution recovery. `--via` replays one route extracted from the `paths` envelope, whose source variant must match the input. -`extract` accepts the target problem's `pred solve` JSON output directly, or an -external result with an explicit `status`: +`extract` calls the reduction rules' existing mappings. Supply exactly one input: -| Result | JSON | -| --- | --- | -| Feasible solution, not necessarily optimal | `{"status":"feasible","solution":[true,false]}` | -| Optimal solution | `{"status":"optimal","solution":[true,false]}` | -| No solution | `{"status":"infeasible"}` | -| Complete count or determined objective value | `{"status":"complete","value":12}` | +- `--config`: a target configuration, passed through `extract_solution` in reverse + path order. Returns the source configuration and its evaluation. +- `--value`: a completed target aggregate, passed through `extract_value` in reverse + path order. Returns the mapped source value, without a witness. -An optional `evaluation` must match the supplied solution. Value-only results -require value mappings along the entire route and return no witness. Conflicting -fields or unsupported mappings are errors. Extraction runs no solver and does -not establish optimality or infeasibility. +For example, the rule from DecisionMinimumVertexCover with bound 1 to +MinimumVertexCover maps target optimum `2` to source value `false`. +Its witness mapping cannot produce a cover of size at most 1 from a two-vertex +cover; that mapping returns an error. These are the rule's two distinct contracts. + +The example inputs above are illustrative; use the actual target's configuration +or value encoding. Extraction requires no `status`, runs no solver, and does not +prove that a supplied aggregate is complete or optimal. Unsupported mappings and +malformed inputs are errors. `pred solve reduced.json` still handles completed +solver results internally. + +Aggregate-only paths can be constructed with `pred reduce --aggregate` and +recovered with `pred extract --value` through `AggregateReductionChain::extract_value`. ## Solve diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 1f44f9c5f..7ec1e5d9b 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -209,20 +209,16 @@ Examples: Inspect(InspectArgs), /// Solve a problem instance Solve(SolveArgs), - /// Recover a source result from a target result JSON file + /// Recover a source configuration or value through the reduction rules #[command(after_help = "\ Examples: - pred extract bundle.json --result target-result.json - pred extract bundle.json --result target-result.json -o source-result.json + pred extract bundle.json --config '[true,false]' + pred extract bundle.json --config '[true,false]' -o source.json + pred extract bundle.json --value 2 -Result JSON (status is required): - {\"status\":\"feasible\",\"solution\":[true,false]} feasible, not necessarily optimal - {\"status\":\"optimal\",\"solution\":[true,false]} solver-reported optimum - {\"status\":\"infeasible\"} no solution - {\"status\":\"complete\",\"value\":12} full count or determined objective - -Accepts pred solve JSON output directly. An optional evaluation must match the -solution. Extraction does not establish optimality or infeasibility.")] +--config calls the rules' solution mapping; --value calls their aggregate mapping. +Supply the completed target aggregate for --value, such as an optimum or count. +Extraction does not solve the target or prove that the supplied value is optimal.")] Extract(ExtractArgs), /// Start MCP (Model Context Protocol) server for AI assistant integration #[cfg(feature = "mcp")] @@ -331,7 +327,7 @@ pub struct ReduceArgs { /// Explicit reduction route selected from a path-set entry. #[arg(long, required = true)] pub via: PathBuf, - /// Execute value mappings; supply a complete value result to `pred extract`. + /// Construct an aggregate-value path for recovery with pred extract --value. #[arg(long)] pub aggregate: bool, } @@ -340,9 +336,12 @@ pub struct ReduceArgs { pub struct ExtractArgs { /// Reduction bundle JSON (from pred reduce). Use - for stdin. pub input: PathBuf, - /// Target result JSON file with an explicit status. Use - for stdin. + /// Target problem configuration encoded as JSON. + #[arg(long, required_unless_present = "value", conflicts_with = "value")] + pub config: Option, + /// Completed target aggregate encoded as JSON, passed to the rules' value mapping. #[arg(long)] - pub result: PathBuf, + pub value: Option, } #[derive(clap::Args)] @@ -565,19 +564,24 @@ mod tests { } #[test] - fn extract_requires_a_result_file() { - assert!( - Cli::try_parse_from(["pred", "extract", "bundle.json", "--result", "result.json"]) - .is_ok() - ); + fn extract_requires_exactly_one_mapping_input() { + assert!(Cli::try_parse_from([ + "pred", + "extract", + "bundle.json", + "--config", + "[true,false]" + ]) + .is_ok()); assert!(Cli::try_parse_from(["pred", "extract", "bundle.json"]).is_err()); - for flag in ["--config", "--value"] { + assert!(Cli::try_parse_from(["pred", "extract", "bundle.json", "--value", "2"]).is_ok()); + for flag in ["--result", "--value", "--status"] { assert!(Cli::try_parse_from([ "pred", "extract", "bundle.json", - "--result", - "result.json", + "--config", + "[true,false]", flag, "2" ]) diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index ac869f5e4..e34d54ca0 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -2,24 +2,11 @@ use crate::cli::ExtractArgs; use crate::dispatch::{extract_bundle_value, read_input, BundleReplay, ReductionBundle}; use crate::output::OutputConfig; use anyhow::{Context, Result}; -use problemreductions::solvers::SolveOutcome; -use serde_json::Value; -use std::path::Path; -#[derive(serde::Deserialize)] -#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] -enum ExternalResult { - Feasible { solution: Value }, - Optimal { solution: Value }, - Infeasible {}, - Complete { value: Value }, -} - -fn load_bundle(input: &Path) -> Result { - let content = read_input(input)?; +/// Apply the reduction rules' configuration or aggregate-value mapping. +pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { let json: serde_json::Value = - serde_json::from_str(&content).context("Input is not valid JSON")?; - + serde_json::from_str(&read_input(&args.input)?).context("Input is not valid JSON")?; if !(json.get("source").is_some() && json.get("target").is_some() && json.get("path").is_some()) { anyhow::bail!( @@ -28,112 +15,48 @@ fn load_bundle(input: &Path) -> Result { Got a plain problem file; did you mean `pred evaluate`?" ); } - let bundle: ReductionBundle = serde_json::from_value(json).context("Failed to parse reduction bundle")?; - - Ok(bundle) -} - -/// Recover the explicitly stated target result, without upgrading its guarantee. -pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { - let bundle = load_bundle(&args.input)?; - let mut json: serde_json::Map = - serde_json::from_str(&read_input(&args.result)?).context("Invalid target result")?; - let evaluation = json.remove("evaluation"); - // These fields describe pred output; they do not change the recovery contract. - for metadata in ["problem", "solver", "reduced_to", "intermediate"] { - json.remove(metadata); - } - let external: ExternalResult = - serde_json::from_value(Value::Object(json)).context("Invalid target result")?; - match external { - ExternalResult::Complete { value } => { - if evaluation.is_some() { - anyhow::bail!("complete value results do not have a witness evaluation"); - } - let source_value = extract_bundle_value(&bundle, value.clone())?; - out.emit( - || { - format!( - "Problem: {}\nStatus: complete\nValue: {source_value}", - bundle.source.problem_type - ) - }, - || { - Ok(serde_json::json!({ - "problem": bundle.source.problem_type, - "status": "complete", - "value": source_value, - "intermediate": {"status": "complete", "value": value}, - })) - }, - ) - } - ExternalResult::Infeasible {} => { - if evaluation.is_some() { - anyhow::bail!("infeasible results do not have a witness evaluation"); - } - let replay = BundleReplay::prepare(&bundle)?; - emit_completed(&replay, SolveOutcome::Infeasible, out) - } - ExternalResult::Feasible { ref solution } | ExternalResult::Optimal { ref solution } => { - let replay = BundleReplay::prepare(&bundle)?; - let actual = replay - .target - .evaluate_witness_dyn(solution)? - .context("target witness is infeasible")?; - if evaluation - .as_ref() - .is_some_and(|value| value != &serde_json::json!(actual)) - { - anyhow::bail!("target evaluation does not match the witness"); - } - if matches!(external, ExternalResult::Optimal { .. }) { - return emit_completed( - &replay, - SolveOutcome::Optimal { - solution: solution.clone(), - evaluation: actual, - }, - out, - ); - } - let (source_solution, source_evaluation) = replay.extract(solution)?; - out.emit( - || format!("Problem: {}\nStatus: feasible\nSolution: {source_solution}\nEvaluation: {source_evaluation}", replay.source_name), - || Ok(serde_json::json!({ - "problem": replay.source_name, - "solver": "external", - "reduced_to": replay.target_name, - "status": "feasible", - "solution": source_solution, - "evaluation": source_evaluation, - "intermediate": { - "problem": replay.target_name, - "status": "feasible", - "solution": solution, - "evaluation": actual, - }, - })), - ) - } + if let Some(value) = &args.value { + let value = serde_json::from_str(value).context("Target value is not valid JSON")?; + let source_value = extract_bundle_value(&bundle, value)?; + return out.emit( + || format!("Problem: {}\nValue: {source_value}", bundle.source.problem_type), + || Ok(serde_json::json!({"problem": bundle.source.problem_type, "value": source_value})), + ); } -} - -fn emit_completed(replay: &BundleReplay, target: SolveOutcome, out: &OutputConfig) -> Result<()> { - let source = replay.extract_result(&target)?; + let solution = serde_json::from_str( + args.config + .as_deref() + .context("--config or --value is required")?, + ) + .context("Target config is not valid JSON")?; + let replay = BundleReplay::prepare(&bundle)?; + let target_evaluation = replay + .target + .evaluate_witness_dyn(&solution)? + .context("target witness is infeasible")?; + let (source_solution, source_evaluation) = replay.extract(&solution)?; out.emit( || { - let mut text = format!("Problem: {}", replay.source_name); - super::solve::append_outcome_text(&mut text, &source); - text + format!( + "Problem: {}\nSolution: {source_solution}\nEvaluation: {source_evaluation}", + replay.source_name + ) }, || { - let mut json = serde_json::to_value(&source)?; - json["problem"] = serde_json::json!(replay.source_name); - json["intermediate"] = serde_json::to_value(&target)?; - Ok(json) + Ok(serde_json::json!({ + "problem": replay.source_name, + "solver": "external", + "reduced_to": replay.target_name, + "solution": source_solution, + "evaluation": source_evaluation, + "intermediate": { + "problem": replay.target_name, + "solution": solution, + "evaluation": target_evaluation, + }, + })) }, ) } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 0e033e6f3..00bbd3234 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -25,32 +25,20 @@ fn pred() -> Command { Command::new(env!("CARGO_BIN_EXE_pred")) } -fn extract_target_result( +fn extract_target_config( bundle: &std::path::Path, - result: serde_json::Value, + config: serde_json::Value, ) -> std::process::Output { - use std::io::Write; - use std::process::Stdio; - let mut child = pred() + pred() .args([ "extract", bundle.to_str().unwrap(), - "--result", - "-", + "--config", + &config.to_string(), "--json", ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() + .output() .unwrap() - .write_all(result.to_string().as_bytes()) - .unwrap(); - child.wait_with_output().unwrap() } fn write_named_route(source: &str, target: &str, names: &[&str], output: &std::path::Path) { @@ -9618,286 +9606,96 @@ fn extract_test_solve_bundle(bundle_file: &std::path::Path) -> (String, String) } #[test] -fn test_completed_decision_recovery_and_aggregate_cli() { +fn test_decision_extract_checks_bound_while_solve_recovers_no() { use problemreductions::models::{graph::MinimumVertexCover, Decision}; + use problemreductions::rules::{ReduceTo, ReductionResult}; use problemreductions::topology::SimpleGraph; use serde_json::json; - let dir = std::env::temp_dir().join(format!("pred-completed-recovery-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let input = dir.join("source.json"); - let route = dir.join("route.json"); - let bundle = dir.join("bundle.json"); - let result = dir.join("result.json"); - let variant = json!({"graph":"SimpleGraph", "weight":"i64"}); - std::fs::write( - &route, - json!({"path":[{ - "from":{"name":"DecisionMinimumVertexCover","variant":variant}, - "to":{"name":"MinimumVertexCover","variant":variant} - }]}) - .to_string(), - ) - .unwrap(); + let bundle = + std::env::temp_dir().join(format!("pred-decision-extract-{}.json", std::process::id())); + let variant = json!({"graph":"SimpleGraph","weight":"i64"}); for bound in [1, 2] { - let problem = Decision::new( - MinimumVertexCover::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), - vec![1i64; 3], - ), + let source = Decision::new( + MinimumVertexCover::new(SimpleGraph::cycle(3), vec![1i64; 3]), bound, ); - std::fs::write( - &input, - json!({"type":"DecisionMinimumVertexCover", "variant":variant,"data":problem}) - .to_string(), - ) - .unwrap(); - let reduced = pred() - .args([ - "reduce", - input.to_str().unwrap(), - "--via", - route.to_str().unwrap(), - "--aggregate", - "-o", - bundle.to_str().unwrap(), - ]) - .output() - .unwrap(); - assert!( - reduced.status.success(), - "{}", - String::from_utf8_lossy(&reduced.stderr) - ); - let solved = pred() - .args([ - "solve", - bundle.to_str().unwrap(), - "--solver", - "brute-force", - "--json", - ]) - .output() - .unwrap(); - assert!( - solved.status.success(), - "{}", - String::from_utf8_lossy(&solved.stderr) - ); - let solved: serde_json::Value = serde_json::from_slice(&solved.stdout).unwrap(); - let expected = if bound == 1 { "infeasible" } else { "optimal" }; - assert_eq!(solved["status"], expected); - - let target_file = dir.join("target.json"); - let bundle_json: serde_json::Value = - serde_json::from_slice(&std::fs::read(&bundle).unwrap()).unwrap(); - std::fs::write(&target_file, bundle_json["target"].to_string()).unwrap(); - let target_solve = pred() - .args([ - "solve", - target_file.to_str().unwrap(), - "--solver", - "brute-force", - "-o", - result.to_str().unwrap(), - ]) - .output() - .unwrap(); - assert!( - target_solve.status.success(), - "{}", - String::from_utf8_lossy(&target_solve.stderr) - ); - let recovered = pred() + let reduction = + ReduceTo::>::reduce_to(&source).unwrap(); + std::fs::write(&bundle, json!({ + "source":{"type":"DecisionMinimumVertexCover","variant":variant,"data":source}, + "target":{"type":"MinimumVertexCover","variant":variant,"data":reduction.target_problem()}, + "path":[{"name":"DecisionMinimumVertexCover","variant":variant}, + {"name":"MinimumVertexCover","variant":variant}] + }).to_string()).unwrap(); + let mapped = pred() .args([ "extract", bundle.to_str().unwrap(), - "--result", - result.to_str().unwrap(), + "--value", + "2", "--json", ]) .output() .unwrap(); assert!( - recovered.status.success(), + mapped.status.success(), "{}", - String::from_utf8_lossy(&recovered.stderr) + String::from_utf8_lossy(&mapped.stderr) ); - let recovered: serde_json::Value = serde_json::from_slice(&recovered.stdout).unwrap(); - assert_eq!(recovered["status"], expected); - - let numerical = pred() + let mapped: serde_json::Value = serde_json::from_slice(&mapped.stdout).unwrap(); + assert_eq!(mapped["value"], json!(bound == 2)); + assert!(mapped.get("status").is_none()); + assert!(mapped.get("solution").is_none()); + let invalid = pred() .args([ - "solve", + "extract", bundle.to_str().unwrap(), - "--solver", - "ilp", + "--value", + "true", "--json", ]) .output() .unwrap(); - assert!( - numerical.status.success(), - "{}", - String::from_utf8_lossy(&numerical.stderr) - ); - let numerical: serde_json::Value = serde_json::from_slice(&numerical.stdout).unwrap(); - assert_eq!(numerical["status"], expected); - - for (evaluation, solver) in [ - (None, "brute-force"), - (Some("Min(2)"), "brute-force"), - (None, "ilp"), - (Some("Min(2)"), "ilp"), - ] { - let mut external = json!({"status":"optimal","solution":[true,true,false],"problem":"MinimumVertexCover","solver":{"kind":solver}}); - if let Some(evaluation) = evaluation { - external["evaluation"] = json!(evaluation); - } - std::fs::write(&result, external.to_string()).unwrap(); - let extracted = pred() + assert!(!invalid.status.success()); + assert!(String::from_utf8_lossy(&invalid.stderr).contains("deserialization failed")); + let extracted = extract_target_config(&bundle, json!([true, true, false])); + if bound == 1 { + assert!(!extracted.status.success()); + assert!(String::from_utf8_lossy(&extracted.stderr).contains("decision bound")); + } else { + assert!( + extracted.status.success(), + "{}", + String::from_utf8_lossy(&extracted.stderr) + ); + let output: serde_json::Value = serde_json::from_slice(&extracted.stdout).unwrap(); + assert_eq!(output["evaluation"], "Or(true)"); + assert!(output.get("status").is_none()); + } + for solver in ["brute-force", "ilp"] { + let solved = pred() .args([ - "extract", + "solve", bundle.to_str().unwrap(), - "--result", - result.to_str().unwrap(), + "--solver", + solver, "--json", ]) .output() .unwrap(); assert!( - extracted.status.success(), + solved.status.success(), "{}", - String::from_utf8_lossy(&extracted.stderr) + String::from_utf8_lossy(&solved.stderr) + ); + let output: serde_json::Value = serde_json::from_slice(&solved.stdout).unwrap(); + assert_eq!( + output["status"], + if bound == 1 { "infeasible" } else { "optimal" } ); - let recovered: serde_json::Value = serde_json::from_slice(&extracted.stdout).unwrap(); - assert_eq!(recovered["status"], expected); - } - let value = - extract_target_result(&bundle, serde_json::json!({"status":"complete","value":2})); - assert!( - value.status.success(), - "{}", - String::from_utf8_lossy(&value.stderr) - ); - let value: serde_json::Value = serde_json::from_slice(&value.stdout).unwrap(); - assert_eq!(value["status"], "complete"); - assert_eq!(value["value"], json!(bound == 2)); - let candidate = extract_target_result( - &bundle, - serde_json::json!({"status":"feasible","solution":[true,true,false],"evaluation":"Min(2)"}), - ); - assert_eq!(candidate.status.success(), bound == 2); - if bound == 2 { - let recovered: serde_json::Value = serde_json::from_slice(&candidate.stdout).unwrap(); - assert_eq!(recovered["status"], "feasible"); - assert_eq!(recovered["evaluation"], "Or(true)"); } } - for invalid in [ - json!({"solution":[true,true,false]}), - json!({"value":2}), - json!({"status":"feasible"}), - json!({"status":"feasible", "solution":[true,true,false], "value":2}), - json!({"status":"feasible", "solution":[true,true,false], "evaluation":"Min(99)"}), - json!({"status":"feasible", "solution":[true,true,false], "evaluation":null}), - json!({"status":"optimal", "solution":[true,true,false], "value":2}), - json!({"status":"infeasible", "solution":[true,true,false]}), - json!({"status":"infeasible", "value":2}), - json!({"status":"complete"}), - json!({"status":"complete", "value":2, "solution":[true,true,false]}), - json!({"status":"complete", "value":2, "evaluation":"Min(2)"}), - json!({"status":"complete", "value":true}), - json!({"status":"complete", "value":2, "unexpected":true}), - json!({"status":"optimal", "solution":[true,true,false], "evaluation":"Min(99)"}), - json!({"status":"optimal", "solution":[true,true,false], "evaluation":99}), - json!({"status":"optimal", "solution":[true,true,false], "evaluation":null}), - json!({"status":"optimal", "solution":[false,false,false]}), - json!({"status":"timeout"}), - json!({"status":"infeasible", "evaluation":"Min(2)"}), - json!({"status":"optimal", "solution":[true,true,false], "evaluation":"Min(99)", "solver":{"kind":"ilp"}}), - ] { - std::fs::write(&result, invalid.to_string()).unwrap(); - let output = pred() - .args([ - "extract", - bundle.to_str().unwrap(), - "--result", - result.to_str().unwrap(), - ]) - .output() - .unwrap(); - assert!(!output.status.success()); - } - for options in [ - vec![], - vec!["--value", "2", "--config", "[true,true,false]"], - vec!["--value", "true"], - ] { - let output = pred() - .args(["extract", bundle.to_str().unwrap()]) - .args(options) - .output() - .unwrap(); - assert!(!output.status.success()); - } - let sat = problemreductions::models::formula::Satisfiability::new( - 1, - vec![ - problemreductions::models::formula::CNFClause::new(vec![1]), - problemreductions::models::formula::CNFClause::new(vec![-1]), - ], - ); - std::fs::write( - &input, - json!({"type":"Satisfiability", "data":sat}).to_string(), - ) - .unwrap(); - std::fs::write( - &route, - json!({"path":[{ - "from":{"name":"Satisfiability","variant":{}}, - "to":{"name":"NAESatisfiability","variant":{}} - }]}) - .to_string(), - ) - .unwrap(); - let reduced = pred() - .args([ - "reduce", - input.to_str().unwrap(), - "--via", - route.to_str().unwrap(), - "-o", - bundle.to_str().unwrap(), - ]) - .output() - .unwrap(); - assert!(reduced.status.success()); - std::fs::write( - &result, - json!({"status":"infeasible", "solver":{"kind":"ilp"}}).to_string(), - ) - .unwrap(); - let recovered = pred() - .args([ - "extract", - bundle.to_str().unwrap(), - "--result", - result.to_str().unwrap(), - "--json", - ]) - .output() - .unwrap(); - assert!( - recovered.status.success(), - "{}", - String::from_utf8_lossy(&recovered.stderr) - ); - let recovered: serde_json::Value = serde_json::from_slice(&recovered.stdout).unwrap(); - assert_eq!(recovered["status"], "infeasible"); - assert!(recovered.get("solution").is_none()); - std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_file(bundle).unwrap(); } #[test] @@ -9926,28 +9724,21 @@ fn test_extract_rejects_infeasible_target_even_when_decoded_source_is_feasible() .unwrap(); // Both assignments decode to start time 0; only C=1 satisfies C-start >= 1. - for status in ["feasible", "optimal"] { - for evaluation in [None, Some("Min(None)")] { - let mut result = json!({"status":status,"solution":[0,0]}); - if let Some(evaluation) = evaluation { - result["evaluation"] = json!(evaluation); - } - let output = extract_target_result(&bundle, result); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("target witness is infeasible"), "{stderr}"); - } - let output = extract_target_result(&bundle, json!({"status":status,"solution":[0,1]})); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(result["status"], status); - assert_eq!(result["solution"], json!([0])); - assert_eq!(result["evaluation"], "Min(1)"); - } + let output = extract_target_config(&bundle, json!([0, 0])); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("target witness is infeasible"), "{stderr}"); + let output = extract_target_config(&bundle, json!([0, 1])); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["solution"], json!([0])); + assert_eq!(result["evaluation"], "Min(1)"); + assert!(result.get("status").is_none()); + assert!(result["intermediate"].get("status").is_none()); std::fs::remove_file(bundle).unwrap(); } @@ -9992,10 +9783,8 @@ fn test_extract_roundtrip_mis_to_qubo() { // independent of the reduction path selected by the graph search. let (target_cfg, expected_source_eval) = extract_test_solve_bundle(&bundle_file); - let extract_out = extract_target_result( - &bundle_file, - serde_json::json!({"status":"feasible","solution":serde_json::from_str::(&target_cfg).unwrap()}), - ); + let extract_out = + extract_target_config(&bundle_file, serde_json::from_str(&target_cfg).unwrap()); assert!( extract_out.status.success(), "extract stderr: {}", @@ -10072,9 +9861,9 @@ fn test_extract_rejects_structurally_invalid_one_hot_config() { String::from_utf8_lossy(&reduce_out.stderr) ); - let extract_out = extract_target_result( + let extract_out = extract_target_config( &bundle_file, - serde_json::json!({"status":"feasible","solution":[false,false,false,false,false,false,false,false,false]}), + serde_json::json!([false, false, false, false, false, false, false, false, false]), ); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); @@ -10104,10 +9893,7 @@ fn test_extract_rejects_plain_problem_file() { .unwrap(); assert!(create_out.status.success()); - let extract_out = extract_target_result( - &problem_file, - serde_json::json!({"status":"feasible","solution":[false,true,false]}), - ); + let extract_out = extract_target_config(&problem_file, serde_json::json!([false, true, false])); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -10148,10 +9934,7 @@ fn test_extract_rejects_wrong_config_length() { &bundle_file, ); - let extract_out = extract_target_result( - &bundle_file, - serde_json::json!({"status":"feasible","solution":[false,true]}), - ); + let extract_out = extract_target_config(&bundle_file, serde_json::json!([false, true])); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -10198,12 +9981,7 @@ fn test_extract_rejects_non_boolean_solution_value() { let (target_cfg, _) = extract_test_solve_bundle(&bundle_file); let mut bad_cfg: serde_json::Value = serde_json::from_str(&target_cfg).unwrap(); bad_cfg.as_array_mut().unwrap()[0] = serde_json::json!(9); - let bad_cfg = bad_cfg.to_string(); - - let extract_out = extract_target_result( - &bundle_file, - serde_json::json!({"status":"feasible","solution":serde_json::from_str::(&bad_cfg).unwrap()}), - ); + let extract_out = extract_target_config(&bundle_file, bad_cfg); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -10255,10 +10033,8 @@ fn test_extract_rejects_malformed_bundle_path_source_mismatch() { let mut f = std::fs::File::create(&tampered_file).unwrap(); f.write_all(bundle.to_string().as_bytes()).unwrap(); - let extract_out = extract_target_result( - &tampered_file, - serde_json::json!({"status":"feasible","solution":[false,true,false]}), - ); + let extract_out = + extract_target_config(&tampered_file, serde_json::json!([false, true, false])); assert!( !extract_out.status.success(), "expected failure on malformed bundle; stdout: {}", @@ -10320,10 +10096,8 @@ fn test_extract_rejects_tampered_target_data() { // Any config long enough to reach the coherence check; it must fail before // config validation kicks in because prepare() runs first. let (target_cfg, _) = extract_test_solve_bundle(&bundle_file); - let extract_out = extract_target_result( - &tampered_file, - serde_json::json!({"status":"feasible","solution":serde_json::from_str::(&target_cfg).unwrap()}), - ); + let extract_out = + extract_target_config(&tampered_file, serde_json::from_str(&target_cfg).unwrap()); assert!( !extract_out.status.success(), "expected failure on tampered target.data; stdout: {}", @@ -10393,18 +10167,8 @@ fn test_extract_reads_bundle_from_stdin() { let (target_cfg, _) = extract_test_solve_bundle(&bundle_file); let bundle_text = std::fs::read_to_string(&bundle_file).unwrap(); - let result_file = bundle_file.with_extension("result.json"); - std::fs::write(&result_file, serde_json::json!({ - "status":"feasible", "solution":serde_json::from_str::(&target_cfg).unwrap(), - }).to_string()).unwrap(); let mut child = pred() - .args([ - "--json", - "extract", - "-", - "--result", - result_file.to_str().unwrap(), - ]) + .args(["--json", "extract", "-", "--config", &target_cfg]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -10417,7 +10181,6 @@ fn test_extract_reads_bundle_from_stdin() { .write_all(bundle_text.as_bytes()) .unwrap(); let output = child.wait_with_output().unwrap(); - std::fs::remove_file(&result_file).unwrap(); assert!( output.status.success(), "stderr: {}", From 8291c5b21d5b579d3dc0fd78f9fc5736ffefe6d7 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 21 Sep 2026 15:24:36 +0800 Subject: [PATCH 25/44] Simplify CLI and MCP solver execution --- problemreductions-cli/src/commands/solve.rs | 20 ++++++-------- problemreductions-cli/src/dispatch.rs | 12 +++------ problemreductions-cli/src/mcp/tests.rs | 26 +++++++++++------- problemreductions-cli/src/mcp/tools.rs | 30 ++++++++------------- 4 files changed, 38 insertions(+), 50 deletions(-) diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 7b0280cbd..be960dfa3 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -87,27 +87,23 @@ pub fn solve( let timeout_seconds = u64::try_from(timeout).map_err(|_| anyhow::anyhow!("timeout must be a nonnegative i64"))?; + let run = move |out: &OutputConfig| match parsed { + SolveInput::Problem(pj) => { + solve_problem(&pj.problem_type, &pj.variant, pj.data, request, out) + } + SolveInput::Bundle(b) => solve_bundle(b, request, out), + }; if timeout_seconds > 0 { 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, request, &out) - } - SolveInput::Bundle(b) => solve_bundle(b, request, &out), - }; + let result = run(&out); tx.send(result).ok(); }); rx.recv_timeout(Duration::from_secs(timeout_seconds)) .map_err(|error| crate::dispatch::solve_worker_error(error, timeout_seconds))? } else { - match parsed { - SolveInput::Problem(pj) => { - solve_problem(&pj.problem_type, &pj.variant, pj.data, request, out) - } - SolveInput::Bundle(b) => solve_bundle(b, request, out), - } + run(out) } } diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index b397e1413..2c393f047 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -86,15 +86,6 @@ pub fn solver_capabilities_view(problem: &LoadedProblem) -> Result Result anyhow::Result { + if is_bundle { + let bundle: ReductionBundle = serde_json::from_value(json)?; + solve_bundle_inner(bundle, request) + } else { + let pj: ProblemJson = serde_json::from_value(json)?; + solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, request) + } + }; if timeout_secs > 0 { - let json_clone = json.clone(); 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, 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, request) - } - Err(e) => Err(anyhow::Error::from(e)), - } - }; + let result = run(); tx.send(result).ok(); }); rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) .map_err(|error| crate::dispatch::solve_worker_error(error, timeout_secs))? - } else if is_bundle { - let bundle: ReductionBundle = serde_json::from_value(json)?; - solve_bundle_inner(bundle, request) } else { - let pj: ProblemJson = serde_json::from_value(json)?; - solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, request) + run() } } } From 29097e9bcbffc0352ae50b5690b0a4ba20e93135 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 12:15:32 +0800 Subject: [PATCH 26/44] Reject oversized QUBO allocations and zero-color KColoring reduction panic - QUBO sparse loader reserves the dense matrix fallibly, returning a ConstructionError instead of aborting on huge num_vars. - KColoring -> PartitionIntoCliques maps k = 0 on a nonempty graph to a NO instance instead of panicking in PartitionIntoCliques::new. Co-Authored-By: Claude Opus 5.5 --- src/models/algebraic/qubo.rs | 14 +++++++++++++- src/rules/kcoloring_partitionintocliques.rs | 8 ++++---- src/unit_tests/models/algebraic/qubo.rs | 10 ++++++++++ .../rules/kcoloring_partitionintocliques.rs | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 7a6b326b7..1344062f2 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -119,7 +119,19 @@ impl TryFrom> for QUBO { ))); } } - let mut matrix = vec![vec![W::default(); data.num_vars]; data.num_vars]; + let n = data.num_vars; + let allocation_error = + || ConstructionError::Conversion(format!("QUBO with {n} variables is too large")); + let mut matrix = Vec::new(); + matrix + .try_reserve_exact(n) + .map_err(|_| allocation_error())?; + for _ in 0..n { + let mut row = Vec::new(); + row.try_reserve_exact(n).map_err(|_| allocation_error())?; + row.resize(n, W::default()); + matrix.push(row); + } for (row, column, value) in data.entries { matrix[row][column] = value; } diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs index df14ee2b7..ac8dd670c 100644 --- a/src/rules/kcoloring_partitionintocliques.rs +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -65,12 +65,12 @@ impl ReduceTo> for KColoring fn reduce_to(&self) -> Result { let n = self.graph().num_vertices(); - let target = if self.graph().edges().iter().any(|&(u, v)| u == v) { - // A loop is uncolorable; two isolated vertices do not form one clique. - PartitionIntoCliques::new(SimpleGraph::empty(2), 1) - } else if n == 0 { + let target = if n == 0 { // The empty source is colorable; the target requires a nonempty graph. PartitionIntoCliques::new(SimpleGraph::empty(1), 1) + } else if self.num_colors() == 0 || self.graph().edges().iter().any(|&(u, v)| u == v) { + // Zero colors or a loop is uncolorable; two isolated vertices do not form one clique. + PartitionIntoCliques::new(SimpleGraph::empty(2), 1) } else { PartitionIntoCliques::new( SimpleGraph::new(n, complement_edges(self.graph())), diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 0a8f12a15..d56058ee3 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -269,3 +269,13 @@ fn test_integer_qubo_reports_objective_overflow() { Err(crate::traits::EvaluationError::IntegerOverflow(_)) )); } + +#[test] +fn test_qubo_entries_reject_oversized_num_vars() { + let error = QUBO::::try_from(QuboData { + num_vars: usize::MAX, + entries: vec![], + }) + .unwrap_err(); + assert!(error.to_string().contains("too large"), "{error}"); +} diff --git a/src/unit_tests/rules/kcoloring_partitionintocliques.rs b/src/unit_tests/rules/kcoloring_partitionintocliques.rs index c05b094c7..9789f9052 100644 --- a/src/unit_tests/rules/kcoloring_partitionintocliques.rs +++ b/src/unit_tests/rules/kcoloring_partitionintocliques.rs @@ -55,3 +55,19 @@ fn test_kcoloring_to_partitionintocliques_unsat_preserved() { assert!(solver.solve(&source).unwrap().is_none()); assert!(solver.solve(reduction.target_problem()).unwrap().is_none()); } + +#[test] +fn test_kcoloring_to_partitionintocliques_zero_colors() { + let solver = BruteForce::new(); + let source = KColoring::::with_k(SimpleGraph::empty(2), 0); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + assert!(solver.solve(&source).unwrap().is_none()); + assert!(solver.solve(reduction.target_problem()).unwrap().is_none()); + + // With no vertices, zero colors suffice. + let source = KColoring::::with_k(SimpleGraph::empty(0), 0); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + assert!(solver.solve(reduction.target_problem()).unwrap().is_some()); +} From 7077637c1cf5236350c892f670e1a073a6622574 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 13:21:41 +0800 Subject: [PATCH 27/44] Cap persisted QUBO size and guard TSP-to-QUBO tour energy range - The sparse QUBO loader rejects num_vars above 8192 before densifying; the previous try_reserve guard was ineffective under memory overcommit. - TSP -> QUBO requires -2nA to fit i64 so every valid tour's energy is representable, instead of producing a target no solver can evaluate. Co-Authored-By: Claude Opus 5.5 --- src/models/algebraic/qubo.rs | 24 +++++++++---------- src/rules/travelingsalesman_qubo.rs | 4 ++++ src/unit_tests/models/algebraic/qubo.rs | 14 ++++++----- .../rules/travelingsalesman_qubo.rs | 21 ++++++++++++++++ 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 1344062f2..2a018a317 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -98,6 +98,9 @@ impl Serialize for QUBO { } } +/// Largest `num_vars` accepted from the sparse persisted format. +const MAX_PERSISTED_QUBO_VARS: usize = 8192; + impl TryFrom> for QUBO { type Error = ConstructionError; @@ -119,19 +122,16 @@ impl TryFrom> for QUBO { ))); } } - let n = data.num_vars; - let allocation_error = - || ConstructionError::Conversion(format!("QUBO with {n} variables is too large")); - let mut matrix = Vec::new(); - matrix - .try_reserve_exact(n) - .map_err(|_| allocation_error())?; - for _ in 0..n { - let mut row = Vec::new(); - row.try_reserve_exact(n).map_err(|_| allocation_error())?; - row.resize(n, W::default()); - matrix.push(row); + // ponytail: the sparse format still loads into a dense matrix, so cap + // num_vars (8192^2 cells) to keep a tiny file from demanding n^2 memory. + // Store the matrix sparsely if larger persisted QUBOs are needed. + if data.num_vars > MAX_PERSISTED_QUBO_VARS { + return Err(ConstructionError::Conversion(format!( + "QUBO with {} variables is too large to load (at most {MAX_PERSISTED_QUBO_VARS})", + data.num_vars + ))); } + let mut matrix = vec![vec![W::default(); data.num_vars]; data.num_vars]; for (row, column, value) in data.entries { matrix[row][column] = value; } diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index 17a1224af..a91f8da20 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -228,6 +228,10 @@ impl ReduceTo> for TravelingSalesman { // A >= |shift| makes this offset positive. Check its transport once. let objective_offset = i64::try_from(omitted_constant + n as i128 * i128::from(shift)) .map_err(|_| overflow("computing the tour objective offset"))?; + // A valid tour's energy is its shifted cost minus 2nA, and every + // partial sum stays within [-2nA, shifted cost]; -2nA must fit i64. + i64::try_from(omitted_constant) + .map_err(|_| overflow("computing the tour penalty constant"))?; let feasible_energy_upper = i128::from(a) - omitted_constant; // Build n^2 x n^2 upper-triangular QUBO matrix diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index d56058ee3..2130db53b 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -272,10 +272,12 @@ fn test_integer_qubo_reports_objective_overflow() { #[test] fn test_qubo_entries_reject_oversized_num_vars() { - let error = QUBO::::try_from(QuboData { - num_vars: usize::MAX, - entries: vec![], - }) - .unwrap_err(); - assert!(error.to_string().contains("too large"), "{error}"); + for num_vars in [MAX_PERSISTED_QUBO_VARS + 1, 20_000, usize::MAX] { + let error = QUBO::::try_from(QuboData { + num_vars, + entries: vec![], + }) + .unwrap_err(); + assert!(error.to_string().contains("too large"), "{error}"); + } } diff --git a/src/unit_tests/rules/travelingsalesman_qubo.rs b/src/unit_tests/rules/travelingsalesman_qubo.rs index 894abd136..eed7d6b60 100644 --- a/src/unit_tests/rules/travelingsalesman_qubo.rs +++ b/src/unit_tests/rules/travelingsalesman_qubo.rs @@ -222,3 +222,24 @@ fn test_travelingsalesman_to_qubo_weighted_corpus_regression() { "weighted TSP position encoding", ); } + +#[test] +fn test_tour_penalty_constant_must_fit_valid_tour_energy() { + // n = 3 and all weights -w give A = 3w + 1, so valid tours have energy -6A. + let too_negative = + TravelingSalesman::new(SimpleGraph::complete(3), vec![-600_000_000_000_000_000; 3]); + assert!(matches!( + ReduceTo::>::reduce_to(&too_negative), + Err(crate::rules::ReductionError::IntegerOverflow { .. }) + )); + + let source = + TravelingSalesman::new(SimpleGraph::complete(3), vec![-500_000_000_000_000_000; 3]); + let result = ReduceTo::>::reduce_to(&source).unwrap(); + let identity_tour = vec![true, false, false, false, true, false, false, false, true]; + let energy = result.target_problem().evaluate(&identity_tour).unwrap(); + assert_eq!( + crate::rules::AggregateReductionResult::extract_value(&result, energy), + Min(Some(-1_500_000_000_000_000_000)) + ); +} From 6dfdaa7bc811a7f71ab44d5c9692200f67cd8e53 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:45 +0800 Subject: [PATCH 28/44] fix(cvp): prefer representable witnesses among tied optima Co-Authored-By: Codex --- .../customized/closest_vector_problem.rs | 49 +++++++++++++------ .../customized/closest_vector_problem.rs | 14 ++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/solvers/customized/closest_vector_problem.rs b/src/solvers/customized/closest_vector_problem.rs index b04025bd0..ce382485f 100644 --- a/src/solvers/customized/closest_vector_problem.rs +++ b/src/solvers/customized/closest_vector_problem.rs @@ -2,6 +2,7 @@ use crate::models::algebraic::ClosestVectorProblem; use crate::solvers::SolveError; +use crate::traits::Problem; use num_bigint::BigInt; use num_rational::BigRational; use num_traits::{Signed, ToPrimitive, Zero}; @@ -35,7 +36,10 @@ pub(crate) fn solve(problem: &ClosestVectorProblem) -> Result, SolveErr let mut coefficients = vec![BigInt::zero(); n]; let mut best = coefficients.clone(); + let mut best_representable = problem.evaluate(&vec![0; n]).is_ok(); enumerate( + problem, + &mut best_representable, n - 1, BigRational::zero(), &mu, @@ -93,6 +97,8 @@ fn gram_schmidt(basis: &[Vec], target: &[BigRational]) -> GramSchmi #[allow(clippy::too_many_arguments)] fn enumerate( + problem: &ClosestVectorProblem, + best_representable: &mut bool, level: usize, partial_squared: BigRational, mu: &[Vec], @@ -102,7 +108,8 @@ fn enumerate( best: &mut [BigInt], best_squared: &mut BigRational, ) { - if partial_squared >= *best_squared { + if partial_squared > *best_squared || (partial_squared == *best_squared && *best_representable) + { return; } @@ -121,25 +128,39 @@ fn enumerate( coefficients[level] = candidate.clone(); let delta = BigRational::from_integer(candidate.clone()) - ¢er; let next_squared = &partial_squared + &norms[level] * &delta * δ - if next_squared >= *best_squared { + if next_squared > *best_squared || (next_squared == *best_squared && *best_representable) { break; } if level == 0 { *best_squared = next_squared; best.clone_from_slice(coefficients); - break; + // Equal optima may differ in whether checked evaluation can represent + // their lattice coordinates. Keep searching ties until one fits. + *best_representable = coefficients + .iter() + .map(ToPrimitive::to_i64) + .collect::>>() + .is_some_and(|solution| problem.evaluate(&solution).is_ok()); + if *best_representable { + break; + } + } else { + enumerate( + problem, + best_representable, + level - 1, + next_squared, + mu, + norms, + alpha, + coefficients, + best, + best_squared, + ); } - enumerate( - level - 1, - next_squared, - mu, - norms, - alpha, - coefficients, - best, - best_squared, - ); - if partial_squared >= *best_squared { + if partial_squared > *best_squared + || (partial_squared == *best_squared && *best_representable) + { break; } // Differences +1,-2,+3,... (or -1,+2,-3,...) alternate around the center. diff --git a/src/unit_tests/solvers/customized/closest_vector_problem.rs b/src/unit_tests/solvers/customized/closest_vector_problem.rs index 3c942db5c..25c4c4c5a 100644 --- a/src/unit_tests/solvers/customized/closest_vector_problem.rs +++ b/src/unit_tests/solvers/customized/closest_vector_problem.rs @@ -162,3 +162,17 @@ fn decision_cvp_uses_the_exact_optimum_and_bound() { } } } + +#[test] +fn test_cvp_solver_prefers_representable_tied_optima() { + for (basis, target, expected) in [ + (2, i64::MAX, (1_i64 << 62) - 1), + (-2, i64::MAX, -((1_i64 << 62) - 1)), + (2, i64::MIN + 1, -(1_i64 << 62)), + ] { + let problem = ClosestVectorProblem::new(vec![vec![basis]], vec![target]).unwrap(); + let solution = solve(&problem).unwrap(); + assert_eq!(solution, vec![expected]); + assert_eq!(problem.evaluate(&solution).unwrap().0, Some(1)); + } +} From 91c89f9f60dc0aa74365f4d23f8248d1a7a48968 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:45 +0800 Subject: [PATCH 29/44] docs(paper): align decision bounds and TSP coefficients Co-Authored-By: Codex --- docs/paper/reductions.typ | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 2a232b988..1cf9d13b8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -12105,7 +12105,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m ($arrow.r.double$) Given a proper coloring, set exactly its indicated color bit at each vertex. Both penalty sums vanish, so its QUBO energy is $-2P n$, attaining the global lower bound. ($arrow.l.double$) If a target configuration has energy $-2P n$, each nonnegative penalty vanishes. Every vertex therefore has a unique selected color, and the edge penalties imply a proper source coloring. If the graph has no proper coloring, every target configuration has energy strictly greater than $-2P n$; an optimal target configuration alone is not a coloring certificate. - _Aggregation and extraction._ Map a finite target optimum equal to $-2P n$ to true, and every other target value to false. Validate a target configuration once and apply this same equality test before reading its unique selected color in each row. In particular, reject one-hot configurations with monochromatic edges, as well as rows with zero or multiple selected colors. The omitted constant and matrix dimensions are computed with checked integer arithmetic before allocation. For $n = 0$ the empty coloring attains energy zero, including $k = 0$; for $n > 0$, $k = 0$, the empty target configuration has energy zero greater than the negative threshold and certifies no coloring. + _Aggregation and extraction._ The DecisionQUBO target has bound $-2P n$ and accepts energies $E <= -2P n$. Map its completed `Or` value identically. Validate a target configuration once and check this bound before reading its unique selected color in each row. In particular, reject one-hot configurations with monochromatic edges, as well as rows with zero or multiple selected colors. The omitted constant and matrix dimensions are computed with checked integer arithmetic before allocation. For $n = 0$ the empty coloring attains energy zero, including $k = 0$; for $n > 0$, $k = 0$, the empty target configuration has energy zero greater than the negative threshold and certifies no coloring. ] #reduction-rule("MaximumSetPacking", "QUBO")[ @@ -12133,9 +12133,9 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Correctness._ For every source assignment, the minimum target energy over auxiliaries is the number of falsified clauses minus $C$. Every clause expression is nonnegative before subtracting $C$. Hence the formula is satisfiable iff the global target minimum is exactly $-C$. A satisfying assignment lifts by setting each cubic auxiliary to $y_1 y_2$; every zero-penalty target configuration projects to a satisfying source assignment. If an empty clause occurs, its constant penalty 1 prevents the threshold from being attained. Empty formulas have $C=0$ and every source assignment satisfies them. - _Extraction._ Formal target validation precedes decoding. The registered aggregate decoder maps `Min(E)` to `Or(E == Some(-C))`; direct witness extraction rejects other energies and reads the first $n$ coordinates only at the threshold. It does not solve SAT or repair auxiliary assignments. Target optimality must be established before interpreting an aggregate result as the source decision. + _Extraction._ Formal target validation precedes decoding. The DecisionQUBO target checks the bound $E <= -C$, and the registered aggregate decoder maps its completed `Or` value identically. Direct witness extraction rejects energies above the bound and reads the first $n$ coordinates only for a satisfying target witness. It does not solve SAT or repair auxiliary assignments. A completed target decision gives the source decision. - _Domain and overhead._ The K2 variant has $n$ variables; K3 reserves $n+m$, with unused auxiliaries mathematically free for short clauses. Native `new_allow_less` permits widths up to K; CLI and serde still require exactly K per actual clause. Source `Or` maps to target `Min` with an explicitly stored signed threshold. Matrix and constant accumulation use checked arithmetic; literal indices come from the formal `CNFClause::variables` API. Neither endpoint nor variant changes. + _Domain and overhead._ The K2 variant has $n$ variables; K3 reserves $n+m$, with unused auxiliaries mathematically free for short clauses. Native `new_allow_less` permits widths up to K; CLI and serde still require exactly K per actual clause. Source and target both use `Or`; the target wraps the integer QUBO with the signed bound $-C$. Matrix and constant accumulation use checked arithmetic; literal indices come from the formal `CNFClause::variables` API. Neither endpoint nor variant changes. ] #let ksat_qc = load-example("KSatisfiability", "QuadraticCongruences") @@ -14028,7 +14028,7 @@ The following reductions to Integer Linear Programming are straightforward formu *Step 1 -- Encode each tour position as a binary variable.* A tour is a permutation of $n$ vertices. Introduce $n^2 = #tsp_qubo.target.instance.num_vars$ binary variables $x_(v,p)$: vertex $v$ is at position $p$. $ underbrace(x_(0,0) x_(0,1) x_(0,2), "vertex 0") #h(4pt) underbrace(x_(1,0) x_(1,1) x_(1,2), "vertex 1") #h(4pt) underbrace(x_(2,0) x_(2,1) x_(2,2), "vertex 2") $ - *Step 2 -- Penalize invalid permutations.* The penalty $A = 1 + |w_(01)| + |w_(02)| + |w_(12)| = 1 + 1 + 2 + 3 = 7$ ensures any row/column constraint violation outweighs any tour cost. Row constraints (each vertex at exactly one position) and column constraints (each position has one vertex) contribute diagonal $-7$ and off-diagonal $+14$ within each group.\ + *Step 2 -- Penalize invalid permutations.* The penalty $A = 1 + |w_(01)| + |w_(02)| + |w_(12)| = 1 + 1 + 2 + 3 = 7$ ensures any row/column constraint violation outweighs any tour cost. Row constraints (each vertex at exactly one position) and column constraints (each position has one vertex) contribute a combined diagonal $-2A = -14$ and off-diagonal $+14$ within each group.\ *Step 3 -- Encode edge costs.* For each edge $(u,v)$ and position $p$, the products $x_(u,p) x_(v,(p+1) mod 3)$ and $x_(v,p) x_(u,(p+1) mod 3)$ add the edge weight $w_(u v)$ when vertices $u,v$ are consecutive in the tour. Since $K_3$ is complete, all pairs are edges with their actual weights.\ @@ -14047,7 +14047,7 @@ The following reductions to Integer Linear Programming are straightforward formu _Correctness._ ($arrow.r.double$) A valid tour defines a permutation matrix with $H_A = H_B = 0$ and $H_C <= sum_e c_e < A$. ($arrow.l.double$) All objective terms are nonnegative before dropping the constant. A violated permutation constraint or a permutation using a missing edge costs at least $A$. Consequently, a source tour exists iff the target optimum satisfies $E < A - 2n A$. Below that bound, every optimum encodes a valid tour, and shifting costs preserves their ordering. Choosing the cheapest parallel edge preserves the source optimum. - _Solution extraction._ Require energy below $A - 2n A$. For each position $p$, find the unique vertex $v$ with $x^*_(v n + p) = 1$ and map consecutive pairs to the recorded cheapest edge indices. Aggregate recovery returns the source optimum $E + 2n A + n s$ below the bound, or infeasibility otherwise. Construction checks the coefficient arithmetic and requires the nonnegative offset $2n A + n s$ to fit `i64`. + _Solution extraction._ Require energy below $A - 2n A$. For each position $p$, find the unique vertex $v$ with $x^*_(v n + p) = 1$ and map consecutive pairs to the recorded cheapest edge indices. Aggregate recovery returns the source optimum $E + 2n A + n s$ below the bound, or infeasibility otherwise. Construction checks the coefficient arithmetic and requires both $2n A$ and the nonnegative offset $2n A + n s$ to fit `i64`. _Small instances._ The source model uses a connected degree-two edge set: for one vertex, the optimum is its cheapest loop; for two vertices, it is the two cheapest parallel edges joining them. If those edges do not exist, or if there are no vertices, the source is infeasible. These cases map to a zero QUBO with $n^2$ variables and a constant solution/value mapping recording that exact answer. ] @@ -15633,11 +15633,11 @@ The following reductions to Integer Linear Programming are straightforward formu )[ @garey1979 This $O(m)$ reduction copies the graph unchanged and assigns unit weight to every edge ($n$ target vertices, $m$ target edges). A Hamiltonian circuit exists iff the optimal circuit length equals $n$. ][ - _Construction._ Given a Hamiltonian Circuit instance $G = (V, E)$ with $n = |V|$ and $m = |E|$, construct a Longest Circuit instance on the same graph $G' = G$ with edge lengths $l(e) = 1$ for every $e in E$. + _Construction._ Given a Hamiltonian Circuit instance $G = (V, E)$ with $n = |V|$ and $m = |E|$, construct a DecisionLongestCircuit instance with bound $n$ on the same graph $G' = G$ with edge lengths $l(e) = 1$ for every $e in E$. Its decision condition is circuit length $>= n$. _Correctness._ ($arrow.r.double$) If $G$ has a Hamiltonian circuit $v_0, v_1, dots, v_(n-1), v_0$, then this circuit uses $n$ edges each of length 1, giving total length $n$. Since a simple circuit on $n$ vertices can use at most $n$ edges, this is optimal. ($arrow.l.double$) If the longest circuit in $G'$ has length $n$, it uses $n$ unit-weight edges and therefore visits $n$ distinct vertices, i.e., every vertex exactly once. This circuit is therefore a Hamiltonian circuit in $G$. - _Solution extraction._ Evaluate the target selection once and require a feasible circuit of length $n$. Reject infeasible selections and shorter circuits before decoding. Then traverse the selected cycle and return its vertex permutation. This criterion applies to every target configuration, without requiring an optimality claim from the caller. If the target has no feasible circuit, or its proven optimum is less than $n$, the source answer is NO. In particular, simple graphs with fewer than three vertices have no circuit; an empty edge selection is not a witness, including on the empty graph. + _Solution extraction._ Evaluate the target selection once and require a feasible circuit of length $n$. Reject infeasible selections and shorter circuits before decoding. Then traverse the selected cycle and return its vertex permutation. This criterion applies to every target configuration, without requiring an optimality claim from the caller. If no target circuit meets the bound $n$, the source answer is NO. In particular, simple graphs with fewer than three vertices have no circuit; an empty edge selection is not a witness, including on the empty graph. ] #reduction-rule("LongestCircuit", "ILP")[ @@ -18960,7 +18960,7 @@ The following table shows concrete target-variable counts for example instances, _Odd sums and zero duration._ If $S$ is odd, each machine's load is $S+Q=3Q+1>D$, so the threshold cannot be attained. The only legal source with $Q=0$ is the singleton size one; the special job then has zero duration, but the positive element job prevents makespan zero. Thus no alternate endpoint or parity-specific construction is required. - _Aggregation and extraction._ Map a finite optimum equal to $D$ to true and all other values to false. Validate a target configuration once, apply this same certificate, then identify the middle machine and select its element jobs completing by $Q$. Reject invalid schedules and feasible schedules that do not attain the certificate. The existing checked target constructor validates its total horizon $3(S+Q)$ before computing $D$, so the smaller nonnegative certificate is representable. Target construction failures retain their formal error type. + _Aggregation and extraction._ The DecisionOpenShopScheduling target checks makespan $<= D$. Map its completed `Or` value identically. Validate a target configuration once, check this bound, then identify the middle machine and select its element jobs completing by $Q$. Reject invalid schedules and feasible schedules that do not attain the certificate. The existing checked target constructor validates its total horizon $3(S+Q)$ before computing $D$, so the smaller nonnegative certificate is representable. Target construction failures retain their formal error type. ] // 13. NAESatisfiability → MaxCut (#166) #let nae_mc = load-example("NAESatisfiability", "DecisionMaxCut") From 74f896fd7920d5f39956f14f9578b6f6d3ae9e58 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:45 +0800 Subject: [PATCH 30/44] test: cover K2 solver and short SAT to decision cover reductions Co-Authored-By: Codex --- src/unit_tests/example_db.rs | 2 +- ...tisfiability_decisionminimumvertexcover.rs | 29 +++++++++++++++++++ src/unit_tests/solvers/customized/solver.rs | 22 ++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index d9e5eebd4..870f7a3bc 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -960,7 +960,7 @@ fn test_find_rule_example_satisfiability_to_naesatisfiability() { // PR #779 rules #[test] -fn test_find_rule_example_ksatisfiability_to_minimumvertexcover() { +fn test_find_rule_example_ksatisfiability_to_decisionminimumvertexcover() { let source = ProblemRef { name: "KSatisfiability".to_string(), variant: BTreeMap::from([("k".to_string(), "K3".to_string())]), diff --git a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs index abe45504c..3def3bbe3 100644 --- a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -86,3 +86,32 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_extract_solution() { vec![false, false, true] ); } + +#[test] +fn test_ksatisfiability_to_decisionminimumvertexcover_short_clauses_closed_loop() { + let source = KSatisfiability::::new_allow_less( + 2, + vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1, 2])], + ); + let reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "short clauses -> Decision MVC", + ); +} + +#[test] +fn test_ksatisfiability_to_decisionminimumvertexcover_empty_clause() { + let source = KSatisfiability::::new_allow_less(0, vec![CNFClause::new(vec![])]); + let reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); + assert_eq!(reduction.target_problem().bound(), &2); + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + assert!(BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .is_none()); + assert!(reduction.extract_solution(&vec![true; 3]).is_err()); +} diff --git a/src/unit_tests/solvers/customized/solver.rs b/src/unit_tests/solvers/customized/solver.rs index 7d9ebabde..59be07853 100644 --- a/src/unit_tests/solvers/customized/solver.rs +++ b/src/unit_tests/solvers/customized/solver.rs @@ -539,3 +539,25 @@ fn test_customized_solver_matches_exhaustive_search_for_small_rooted_tree_arrang } } } + +#[test] +fn test_solve_two_coloring_direct_graph_cases() { + use crate::models::graph::KColoring; + use crate::variant::K2; + for (name, n, edges, feasible) in [ + ("bipartite", 4, vec![(0, 1), (1, 2), (2, 3), (3, 0)], true), + ("odd cycle", 3, vec![(0, 1), (1, 2), (2, 0)], false), + ("self-loop", 1, vec![(0, 0)], false), + ("isolated vertices", 3, vec![], true), + ("disconnected", 5, vec![(0, 1), (2, 3)], true), + ("empty", 0, vec![], true), + ] { + let problem = KColoring::::new(SimpleGraph::new(n, edges)); + let solution = super::solve_two_coloring(&problem); + assert_eq!(solution.is_some(), feasible, "{name}"); + if let Some(solution) = solution { + assert_eq!(solution.len(), n, "{name}"); + assert!(problem.evaluate(&solution).unwrap().0, "{name}"); + } + } +} From c1ecfb30ce3cacb47c039f4a09d242bcd18ee74c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:45 +0800 Subject: [PATCH 31/44] docs: remove unregistered K4 and K5 diagram nodes Co-Authored-By: Codex --- docs/src/static/variant-hierarchy-dark.svg | 2 +- docs/src/static/variant-hierarchy.svg | 2 +- docs/src/static/variant-hierarchy.typ | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/src/static/variant-hierarchy-dark.svg b/docs/src/static/variant-hierarchy-dark.svg index b6b7d8a8a..40f3133f7 100644 --- a/docs/src/static/variant-hierarchy-dark.svg +++ b/docs/src/static/variant-hierarchy-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/src/static/variant-hierarchy.svg b/docs/src/static/variant-hierarchy.svg index 04252bf5b..4049e8bdf 100644 --- a/docs/src/static/variant-hierarchy.svg +++ b/docs/src/static/variant-hierarchy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/src/static/variant-hierarchy.typ b/docs/src/static/variant-hierarchy.typ index 9db5aadca..4148d65c7 100644 --- a/docs/src/static/variant-hierarchy.typ +++ b/docs/src/static/variant-hierarchy.typ @@ -47,8 +47,6 @@ node((4.2, 1), [K1], fill: k-fill, corner-radius: 5pt, inset: 6pt), node((4.6, 1), [K2], fill: k-fill, corner-radius: 5pt, inset: 6pt), node((5, 1), [K3], fill: k-fill, corner-radius: 5pt, inset: 6pt), - node((5.4, 1), [K4], fill: k-fill, corner-radius: 5pt, inset: 6pt), - node((5.8, 1), [K5], fill: k-fill, corner-radius: 5pt, inset: 6pt), ) v(3mm) From b7920de2403fba8aefc2572a72671c04a8c4066b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:45 +0800 Subject: [PATCH 32/44] fix(help): describe decision inputs and bound directions in CLI help Co-Authored-By: Codex --- .../src/commands/create/schema_support.rs | 29 +++++++++++++++- problemreductions-cli/src/create_args.rs | 34 +++++++++++++++++-- .../algebraic/closest_vector_problem.rs | 4 +-- src/models/algebraic/quadratic_assignment.rs | 4 +-- src/models/algebraic/qubo.rs | 4 +-- .../formula/maximum_2_satisfiability.rs | 4 +-- src/models/graph/longest_circuit.rs | 10 +++--- src/models/graph/longest_path.rs | 12 +++---- src/models/graph/max_cut.rs | 10 +++--- src/models/graph/min_max_multicenter.rs | 10 +++--- .../graph/minimum_covering_by_cliques.rs | 4 +-- src/models/graph/minimum_sum_multicenter.rs | 14 ++++---- src/models/graph/rural_postman.rs | 12 +++---- src/models/graph/spin_glass.rs | 4 +-- src/models/misc/open_shop_scheduling.rs | 4 +-- ...equencing_to_minimize_tardy_task_weight.rs | 4 +-- src/models/misc/stacker_crane.rs | 4 +-- src/unit_tests/models/decision.rs | 28 +++++++++++++++ 18 files changed, 139 insertions(+), 56 deletions(-) diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 42d0e5fd5..0ce1b4062 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -20,6 +20,7 @@ pub(crate) enum InputValueKind { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CreateInput { pub name: String, + pub description: String, pub kind: InputValueKind, } @@ -375,9 +376,35 @@ pub(crate) fn create_inputs_for( } } + let schema = problemreductions::registry::find_problem_type(canonical); + let registered_inputs = variant_entry.inputs(); + let random_inputs = variant_entry + .random + .map(|random| (random.inputs)()) + .unwrap_or_default(); inputs .into_iter() - .map(|(name, (kind, _))| CreateInput { name, kind }) + .map(|(name, (kind, origin))| { + let description = schema + .as_ref() + .and_then(|schema| schema.fields.iter().find(|field| field.name == origin)) + .map(|field| field.description) + .filter(|description| !description.is_empty()) + .or_else(|| { + registered_inputs + .iter() + .chain(&random_inputs) + .find(|input| input.name == origin && !input.description.is_empty()) + .map(|input| input.description) + }) + .unwrap_or(&origin) + .to_string(); + CreateInput { + name, + kind, + description, + } + }) .collect() } diff --git a/problemreductions-cli/src/create_args.rs b/problemreductions-cli/src/create_args.rs index 8c69eb2c0..0b559465f 100644 --- a/problemreductions-cli/src/create_args.rs +++ b/problemreductions-cli/src/create_args.rs @@ -160,7 +160,9 @@ fn add_selected_problem_args( let inputs = crate::commands::create::create_inputs_for(canonical, variant); for input in inputs { - let mut arg = Arg::new(input.name.clone()).long(input.name.clone()); + let mut arg = Arg::new(input.name.clone()) + .long(input.name.clone()) + .help(input.description); if input.kind == crate::commands::create::InputValueKind::Bool { arg = arg.action(ArgAction::SetTrue); } else { @@ -207,8 +209,8 @@ pub(crate) fn command_for_selected_problem( let mut selected_command = Command::new(canonical_spec.clone()) .about(problem.description) .long_about(format!( - "Create a {} instance ({canonical_spec})", - problem.canonical_name + "Create a {} instance ({canonical_spec})\n\n{}", + problem.canonical_name, problem.description )) .disable_help_subcommand(true); if selected != canonical_spec { @@ -279,3 +281,29 @@ fn add_value_parser(arg: Arg, kind: crate::commands::create::InputValueKind) -> InputValueKind::Bool => unreachable!("boolean inputs use SetTrue"), } } + +#[cfg(test)] +mod tests { + #[test] + fn decision_create_help_includes_field_descriptions_and_bound_direction() { + for (spec, direction) in [("DecisionMaxCut", ">="), ("DecisionQUBO", "<=")] { + let error = crate::cli::Cli::try_parse_from(["pred", "create", spec, "--help"]) + .err() + .unwrap(); + let help = error.to_string(); + assert!( + help.contains(&format!("objective value {direction} the bound")), + "{help}" + ); + assert!( + help.contains(&format!("Accept objective values {direction} this bound")), + "{help}" + ); + if spec == "DecisionMaxCut" { + for description in ["Graph edges", "Number of vertices", "Weights for each edge"] { + assert!(help.contains(description), "{help}"); + } + } + } + } +} diff --git a/src/models/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index 1b5b7b7db..bce15f48f 100644 --- a/src/models/algebraic/closest_vector_problem.rs +++ b/src/models/algebraic/closest_vector_problem.rs @@ -227,11 +227,11 @@ inventory::submit! { crate::registry::ProblemSchemaEntry { name: "DecisionClosestVectorProblem", display_name: "Decision ClosestVectorProblem", aliases: &[], dimensions: &[VariantDimension::new("coefficient", "i64", &["i64"])], category: crate::registry::ProblemCategory::Algebraic, module_path: module_path!(), - description: "Does a feasible solution meet the objective bound?", + description: "Does a feasible solution have objective value <= the bound?", fields: &[ crate::registry::FieldInfo { name: "basis", type_name: "Vec>", description: "Basis matrix as semicolon-separated column vectors." }, crate::registry::FieldInfo { name: "target_vec", type_name: "Vec", description: "Target vector." }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], } } diff --git a/src/models/algebraic/quadratic_assignment.rs b/src/models/algebraic/quadratic_assignment.rs index b993f7792..8afd85804 100644 --- a/src/models/algebraic/quadratic_assignment.rs +++ b/src/models/algebraic/quadratic_assignment.rs @@ -250,13 +250,13 @@ mod tests; crate::decision_problem_meta!(QuadraticAssignment, "DecisionQuadraticAssignment"); crate::register_decision_variant!( QuadraticAssignment, "DecisionQuadraticAssignment", "factorial(num_facilities)", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Algebraic, dims: [], fields: [ crate::registry::FieldInfo { name: "cost_matrix", type_name: "Vec>", description: "Flow/cost matrix between facilities" }, crate::registry::FieldInfo { name: "distance_matrix", type_name: "Vec>", description: "Distance matrix between locations" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| indices ); diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 2a018a317..49b092274 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -319,12 +319,12 @@ mod tests; crate::decision_problem_meta!(QUBO, "DecisionQUBO"); crate::register_decision_variant!( QUBO, "DecisionQUBO", "2^num_vars", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Algebraic, dims: [VariantDimension::new("weight", "i64", &["i64"])], fields: [ crate::registry::FieldInfo { name: "matrix", type_name: "Vec>", description: "Q matrix; the number of variables is its row count." }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| crate::config::config_to_bits(&indices) ); diff --git a/src/models/formula/maximum_2_satisfiability.rs b/src/models/formula/maximum_2_satisfiability.rs index 343a0f55e..67954de0a 100644 --- a/src/models/formula/maximum_2_satisfiability.rs +++ b/src/models/formula/maximum_2_satisfiability.rs @@ -196,13 +196,13 @@ mod tests; crate::decision_problem_meta!(Maximum2Satisfiability, "DecisionMaximum2Satisfiability"); crate::register_decision_variant!( Maximum2Satisfiability, "DecisionMaximum2Satisfiability", "2^(0.7905 * num_vars)", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value >= the bound?", category: crate::registry::ProblemCategory::Formula, dims: [], fields: [ crate::registry::FieldInfo { name: "num_vars", type_name: "usize", description: "Number of Boolean variables" }, crate::registry::FieldInfo { name: "clauses", type_name: "Vec", description: "Collection of 2-literal clauses" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values >= this bound" }, ], decode: |_, indices: Vec| crate::config::config_to_bits(&indices) ); diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 4002c4662..9b0a08dcf 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -364,17 +364,17 @@ mod tests; crate::decision_problem_meta!(LongestCircuit, "DecisionLongestCircuit"); crate::register_decision_variant!( LongestCircuit, "DecisionLongestCircuit", "2^num_vertices * num_vertices^2", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value >= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i64", &["i64"]), ], fields: [ - crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, - crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Graph edges as comma-separated vertex pairs." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices, including isolated vertices." }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "Weights for each edge in graph order." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values >= this bound" }, ], decode: |_, indices: Vec| crate::config::config_to_bits(&indices) ); diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index 3cb7946e1..ed19dd43a 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -331,18 +331,18 @@ mod tests; crate::decision_problem_meta!(LongestPath, "DecisionLongestPath"); crate::register_decision_variant!( LongestPath, "DecisionLongestPath", "num_vertices * 2^num_vertices", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value >= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "One", &["One"]), ], fields: [ - crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, - crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "source_vertex", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "target_vertex", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Graph edges as comma-separated vertex pairs." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices, including isolated vertices." }, + crate::registry::FieldInfo { name: "source_vertex", type_name: "usize", description: "Start vertex of the path." }, + crate::registry::FieldInfo { name: "target_vertex", type_name: "usize", description: "End vertex of the path." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values >= this bound" }, ], decode: |_, indices: Vec| crate::config::config_to_bits(&indices) ); diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 152f7dfb6..0aaa4f98a 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -352,17 +352,17 @@ mod tests; crate::decision_problem_meta!(MaxCut, "DecisionMaxCut"); crate::register_decision_variant!( MaxCut, "DecisionMaxCut", "2^(2.372 * num_vertices / 3)", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value >= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i64", &["i64"]), ], fields: [ - crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, - crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Graph edges as comma-separated vertex pairs." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices, including isolated vertices." }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "Weights for each edge in graph order." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values >= this bound" }, ], decode: |_, indices: Vec| crate::config::config_to_bits(&indices) ); diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 986d0fbac..af3f8b85f 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -420,17 +420,17 @@ mod tests; crate::decision_problem_meta!(MinMaxMulticenter, "DecisionMinMaxMulticenter"); crate::register_decision_variant!( MinMaxMulticenter, "DecisionMinMaxMulticenter", "1.4969^num_vertices", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "One", &["One"]), ], fields: [ - crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, - crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "k", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Graph edges as comma-separated vertex pairs." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices, including isolated vertices." }, + crate::registry::FieldInfo { name: "k", type_name: "usize", description: "Number of centers." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| crate::config::config_to_bits(&indices) ); diff --git a/src/models/graph/minimum_covering_by_cliques.rs b/src/models/graph/minimum_covering_by_cliques.rs index 0b8f2da3d..f664ced7b 100644 --- a/src/models/graph/minimum_covering_by_cliques.rs +++ b/src/models/graph/minimum_covering_by_cliques.rs @@ -230,14 +230,14 @@ crate::decision_problem_meta!( ); crate::register_decision_variant!( MinimumCoveringByCliques, "DecisionMinimumCoveringByCliques", "2^num_edges", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), ], fields: [ crate::registry::FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, -crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, +crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| indices ); diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 66c718ee2..3d6e51dbf 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -425,19 +425,19 @@ mod tests; crate::decision_problem_meta!(MinimumSumMulticenter, "DecisionMinimumSumMulticenter"); crate::register_decision_variant!( MinimumSumMulticenter, "DecisionMinimumSumMulticenter", "2^num_vertices", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i64", &["i64"]), ], fields: [ - crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, - crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "weights", type_name: "Vec", description: "" }, - crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, - crate::registry::FieldInfo { name: "k", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Graph edges as comma-separated vertex pairs." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices, including isolated vertices." }, + crate::registry::FieldInfo { name: "weights", type_name: "Vec", description: "Weights for each vertex." }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "Weights for each edge in graph order." }, + crate::registry::FieldInfo { name: "k", type_name: "usize", description: "Number of centers." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| crate::config::config_to_bits(&indices) ); diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 19f0d2561..ed44b8dd7 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -422,18 +422,18 @@ mod tests; crate::decision_problem_meta!(RuralPostman, "DecisionRuralPostman"); crate::register_decision_variant!( RuralPostman, "DecisionRuralPostman", "2^num_vertices * num_vertices^2", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i64", &["i64"]), ], fields: [ - crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, - crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, - crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, - crate::registry::FieldInfo { name: "required_edges", type_name: "Vec", description: "" }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Graph edges as comma-separated vertex pairs." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Number of vertices, including isolated vertices." }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "Weights for each edge in graph order." }, + crate::registry::FieldInfo { name: "required_edges", type_name: "Vec", description: "Indices of edges that the route must traverse." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| indices ); diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index 353df1fc8..d2cacd015 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -448,7 +448,7 @@ mod tests; crate::decision_problem_meta!(SpinGlass, "DecisionSpinGlass"); crate::register_decision_variant!( SpinGlass, "DecisionSpinGlass", "2^num_spins", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Graph, dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), @@ -459,7 +459,7 @@ crate::register_decision_variant!( crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Vertex count, needed to preserve isolated spins." }, crate::registry::FieldInfo { name: "couplings", type_name: "Vec", description: "Pairwise couplings; defaults to one per edge." }, crate::registry::FieldInfo { name: "fields", type_name: "Vec", description: "On-site fields; defaults to zero per vertex." }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| SpinGlass::::config_to_spins(&indices).expect("enumerated spin bits are valid") ); diff --git a/src/models/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 04f7a236e..a08efa1a7 100644 --- a/src/models/misc/open_shop_scheduling.rs +++ b/src/models/misc/open_shop_scheduling.rs @@ -333,13 +333,13 @@ mod tests; crate::decision_problem_meta!(OpenShopScheduling, "DecisionOpenShopScheduling"); crate::register_decision_variant!( OpenShopScheduling, "DecisionOpenShopScheduling", "(schedule_horizon + 1)^(num_jobs * num_machines)", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Misc, dims: [], fields: [ crate::registry::FieldInfo { name: "num_processors", type_name: "usize", description: "Number of machines m." }, crate::registry::FieldInfo { name: "processing_times", type_name: "Vec>", description: "Processing time of each job on each machine (n x m)." }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| indices ); diff --git a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs index fe93f562e..cae8f9a6d 100644 --- a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -267,14 +267,14 @@ crate::decision_problem_meta!( ); crate::register_decision_variant!( SequencingToMinimizeTardyTaskWeight, "DecisionSequencingToMinimizeTardyTaskWeight", "factorial(num_tasks)", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Misc, dims: [], fields: [ crate::registry::FieldInfo { name: "lengths", type_name: "Vec", description: "Lengths" }, crate::registry::FieldInfo { name: "weights", type_name: "Option>", description: "Weights" }, crate::registry::FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadlines" }, -crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, +crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| indices ); diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index 4c1c51a5a..c30c09594 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -427,7 +427,7 @@ mod tests; crate::decision_problem_meta!(StackerCrane, "DecisionStackerCrane"); crate::register_decision_variant!( StackerCrane, "DecisionStackerCrane", "num_vertices^2 * 2^num_arcs", &[], - "Does a feasible solution meet the objective bound?", + "Does a feasible solution have objective value <= the bound?", category: crate::registry::ProblemCategory::Misc, dims: [], fields: [ @@ -436,7 +436,7 @@ crate::register_decision_variant!( crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Vertex count, needed to preserve isolated vertices." }, crate::registry::FieldInfo { name: "arc_lengths", type_name: "Vec", description: "Required-arc lengths; defaults to one per arc." }, crate::registry::FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Connector-edge lengths; defaults to one per edge." }, - crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Accept objective values <= this bound" }, ], decode: |_, indices: Vec| indices ); diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs index dd3562f5f..20fda0ef7 100644 --- a/src/unit_tests/models/decision.rs +++ b/src/unit_tests/models/decision.rs @@ -418,3 +418,31 @@ fn test_decision_mis_unit_dynamic_identity_edges() { assert!(reverse.reduce_fn.is_none()); assert_eq!((reverse.parameter_declarations_fn)().fields.len(), 2); } + +#[test] +fn decision_help_describes_fields_and_bound_direction() { + let schemas = crate::registry::collect_schemas(); + for (name, direction) in [ + ("DecisionQUBO", "<="), + ("DecisionQuadraticAssignment", "<="), + ("DecisionClosestVectorProblem", "<="), + ("DecisionMaximum2Satisfiability", ">="), + ("DecisionStackerCrane", "<="), + ("DecisionLongestPath", ">="), + ("DecisionSequencingToMinimizeTardyTaskWeight", "<="), + ("DecisionMinMaxMulticenter", "<="), + ("DecisionRuralPostman", "<="), + ("DecisionMaxCut", ">="), + ("DecisionMinimumCoveringByCliques", "<="), + ("DecisionOpenShopScheduling", "<="), + ("DecisionSpinGlass", "<="), + ("DecisionLongestCircuit", ">="), + ("DecisionMinimumSumMulticenter", "<="), + ] { + let schema = schemas.iter().find(|schema| schema.name == name).unwrap(); + assert!(schema.description.contains(direction), "{name}"); + for field in &schema.fields { + assert!(!field.description.is_empty(), "{name}: {}", field.name); + } + } +} From 3690a98aadb293f3a19d4ccaef2a5e0606097ff9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:46 +0800 Subject: [PATCH 33/44] fix: restore actual and expected counts in length errors Co-Authored-By: Codex --- .../src/commands/create/tests.rs | 4 ++-- problemreductions-cli/tests/cli_tests.rs | 12 +++++------ src/models/graph/acyclic_partition.rs | 12 +++++++++-- .../graph/bottleneck_traveling_salesman.rs | 6 +++++- .../bounded_component_spanning_forest.rs | 6 +++++- .../graph/bounded_diameter_spanning_tree.rs | 6 +++++- .../directed_two_commodity_integral_flow.rs | 6 +++++- src/models/graph/integral_flow_bundles.rs | 6 +++++- .../graph/integral_flow_homologous_arcs.rs | 6 +++++- .../graph/integral_flow_with_multipliers.rs | 12 +++++++++-- src/models/graph/kth_best_spanning_tree.rs | 6 +++++- src/models/graph/longest_circuit.rs | 6 +++++- src/models/graph/longest_path.rs | 6 +++++- src/models/graph/max_cut.rs | 6 +++++- src/models/graph/maximal_is.rs | 6 +++++- src/models/graph/maximum_clique.rs | 6 +++++- src/models/graph/maximum_co_k_plex.rs | 6 +++++- .../graph/maximum_edge_weighted_k_clique.rs | 6 ++++-- src/models/graph/maximum_independent_set.rs | 6 +++++- src/models/graph/maximum_matching.rs | 6 +++++- src/models/graph/min_max_multicenter.rs | 12 +++++++++-- .../minimum_capacitated_spanning_tree.rs | 12 +++++++++-- .../graph/minimum_cut_into_bounded_sets.rs | 6 +++++- src/models/graph/minimum_dominating_set.rs | 6 +++++- src/models/graph/minimum_feedback_arc_set.rs | 6 +++++- .../graph/minimum_feedback_vertex_set.rs | 6 +++++- src/models/graph/minimum_multiway_cut.rs | 6 +++++- src/models/graph/minimum_sum_multicenter.rs | 12 +++++++++-- src/models/graph/minimum_vertex_cover.rs | 6 +++++- src/models/graph/mixed_chinese_postman.rs | 14 +++++++++---- .../graph/multiple_copy_file_allocation.rs | 12 +++++++++-- .../graph/path_constrained_network_flow.rs | 8 +++++--- .../graph/prize_collecting_steiner_forest.rs | 12 +++++++---- src/models/graph/rural_postman.rs | 6 +++++- .../graph/shortest_weight_constrained_path.rs | 6 +++++- src/models/graph/spin_glass.rs | 12 +++++++---- src/models/graph/steiner_tree.rs | 20 ++++++++++++------- src/models/graph/traveling_salesman.rs | 6 +++++- .../graph/undirected_flow_lower_bounds.rs | 12 +++++++++-- .../undirected_two_commodity_integral_flow.rs | 6 +++++- src/models/misc/capacity_assignment.rs | 12 +++++++++-- src/models/misc/stacker_crane.rs | 16 +++++++++------ src/models/set/maximum_set_packing.rs | 6 ++++-- src/registry/variant.rs | 6 ++++++ .../graph/bounded_diameter_spanning_tree.rs | 2 +- .../models/graph/kth_best_spanning_tree.rs | 2 +- src/unit_tests/models/graph/longest_path.rs | 2 +- .../models/graph/maximum_co_k_plex.rs | 2 +- .../graph/maximum_edge_weighted_k_clique.rs | 2 +- .../models/graph/min_max_multicenter.rs | 4 ++-- .../minimum_capacitated_spanning_tree.rs | 4 ++-- .../models/graph/minimum_multiway_cut.rs | 2 +- .../models/graph/minimum_sum_multicenter.rs | 4 ++-- .../models/graph/mixed_chinese_postman.rs | 4 ++-- .../graph/path_constrained_network_flow.rs | 2 +- .../graph/prize_collecting_steiner_forest.rs | 4 ++-- src/unit_tests/models/graph/steiner_tree.rs | 2 +- .../undirected_two_commodity_integral_flow.rs | 2 +- .../models/set/maximum_set_packing.rs | 9 +++++++++ 59 files changed, 309 insertions(+), 100 deletions(-) diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index 767cbe971..c4794de1a 100644 --- a/problemreductions-cli/src/commands/create/tests.rs +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -1436,7 +1436,7 @@ fn test_create_capacity_assignment_rejects_matrix_width_mismatch() { let err = create(&args, &out).unwrap_err().to_string(); assert!(err.contains("cost row 0")); - assert!(err.contains("capacities length")); + assert!(err.contains("has length 2, expected 3")); } #[test] @@ -1978,7 +1978,7 @@ fn test_create_stacker_crane_rejects_mismatched_arc_lengths() { }; let err = create(&args, &out).unwrap_err().to_string(); - assert!(err.contains("arc_lengths length must match arcs length")); + assert!(err.contains("arc_lengths has length 4, expected 5")); } #[test] diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 00bbd3234..7dbf37d12 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -15,7 +15,7 @@ fn test_evaluate_rejects_invalid_model_json_without_panicking() { assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); assert!( - stderr.contains("weights length must match graph num_vertices"), + stderr.contains("weights has length 0, expected 1"), "{stderr}" ); assert!(!stderr.contains("panicked"), "{stderr}"); @@ -1008,7 +1008,7 @@ fn test_create_undirected_two_commodity_integral_flow_rejects_wrong_capacity_cou .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("capacities length must match graph edge count")); + assert!(stderr.contains("capacities has length 2, expected 3")); assert!(stderr.contains("Usage: pred create UndirectedTwoCommodityIntegralFlow")); } @@ -1176,7 +1176,7 @@ fn test_create_integral_flow_bundles_rejects_wrong_bundle_capacity_count() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("bundles length must match bundle_capacities length")); + assert!(stderr.contains("bundles has length 3, expected 2")); assert!(stderr.contains("Usage: pred create IntegralFlowBundles")); } @@ -1410,7 +1410,7 @@ fn test_create_integral_flow_with_multipliers_rejects_wrong_multiplier_count() { .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("multipliers length must match num_vertices")); + assert!(stderr.contains("multipliers has length 3, expected 4")); assert!(stderr.contains("Usage: pred create IntegralFlowWithMultipliers")); } @@ -3104,7 +3104,7 @@ fn test_create_mixed_chinese_postman_rejects_edge_weight_length_mismatch() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("edge_weights length must match num_edges"), + stderr.contains("edge_weights has length 2, expected 4"), "expected edge-weight mismatch diagnostic, got: {stderr}" ); } @@ -8680,7 +8680,7 @@ fn test_create_shortest_weight_constrained_path_edge_length_count_mismatch() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("edge lengths length must match num_edges"), + stderr.contains("edge lengths has length 7, expected 8"), "stderr: {stderr}" ); } diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 86452c38f..b56ec16c6 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -175,7 +175,11 @@ impl AcyclicPartition { vertex_weights: &[W], ) -> Result<(), crate::registry::ConstructionError> { if vertex_weights.len() != graph.num_vertices() { - return Err("vertex_weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "vertex_weights", + vertex_weights.len(), + graph.num_vertices(), + )); } Ok(()) } @@ -191,7 +195,11 @@ impl AcyclicPartition { arc_costs: &[W], ) -> Result<(), crate::registry::ConstructionError> { if arc_costs.len() != graph.num_arcs() { - return Err("arc_costs length must match graph num_arcs".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "arc_costs", + arc_costs.len(), + graph.num_arcs(), + )); } Ok(()) } diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs index 3f3ff671d..50c44c09f 100644 --- a/src/models/graph/bottleneck_traveling_salesman.rs +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -132,7 +132,11 @@ impl BottleneckTravelingSalesman { weights: &[i64], ) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); } Ok(()) } diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index a11593628..8da209d50 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -111,7 +111,11 @@ impl BoundedComponentSpanningForest { max_weight: W::Sum, ) -> Result { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } if !weights .iter() diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index 812ea5244..243789d13 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -213,7 +213,11 @@ impl BoundedDiameterSpanningTree { fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); } if !weights .iter() diff --git a/src/models/graph/directed_two_commodity_integral_flow.rs b/src/models/graph/directed_two_commodity_integral_flow.rs index f40e40dba..4f36d45c5 100644 --- a/src/models/graph/directed_two_commodity_integral_flow.rs +++ b/src/models/graph/directed_two_commodity_integral_flow.rs @@ -161,7 +161,11 @@ impl DirectedTwoCommodityIntegralFlow { ) -> Result { let n = graph.num_vertices(); if capacities.len() != graph.num_arcs() { - return Err("capacities length must match graph num_arcs".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "capacities", + capacities.len(), + graph.num_arcs(), + )); } if capacities.iter().any(|&capacity| capacity < 0) { return Err("capacities must be nonnegative".into()); diff --git a/src/models/graph/integral_flow_bundles.rs b/src/models/graph/integral_flow_bundles.rs index cd5e55536..25d4fc572 100644 --- a/src/models/graph/integral_flow_bundles.rs +++ b/src/models/graph/integral_flow_bundles.rs @@ -138,7 +138,11 @@ impl IntegralFlowBundles { return Err("source and sink must be distinct".into()); } if bundles.len() != bundle_capacities.len() { - return Err("bundles length must match bundle_capacities length".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "bundles", + bundles.len(), + bundle_capacities.len(), + )); } if requirement <= 0 { return Err("requirement must be positive".into()); diff --git a/src/models/graph/integral_flow_homologous_arcs.rs b/src/models/graph/integral_flow_homologous_arcs.rs index 75a1b1599..67e8d9e12 100644 --- a/src/models/graph/integral_flow_homologous_arcs.rs +++ b/src/models/graph/integral_flow_homologous_arcs.rs @@ -141,7 +141,11 @@ impl IntegralFlowHomologousArcs { let num_arcs = graph.num_arcs(); if capacities.len() != num_arcs { - return Err("capacities length must match graph.num_arcs()".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "capacities", + capacities.len(), + num_arcs, + )); } if source >= num_vertices { return Err(format!( diff --git a/src/models/graph/integral_flow_with_multipliers.rs b/src/models/graph/integral_flow_with_multipliers.rs index b0af094d5..bcb3bc0bd 100644 --- a/src/models/graph/integral_flow_with_multipliers.rs +++ b/src/models/graph/integral_flow_with_multipliers.rs @@ -124,10 +124,18 @@ impl IntegralFlowWithMultipliers { requirement: i64, ) -> Result { if capacities.len() != graph.num_arcs() { - return Err("capacities length must match graph num_arcs".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "capacities", + capacities.len(), + graph.num_arcs(), + )); } if multipliers.len() != graph.num_vertices() { - return Err("multipliers length must match num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "multipliers", + multipliers.len(), + graph.num_vertices(), + )); } let num_vertices = graph.num_vertices(); diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs index 1fb3b795d..1d20f3f3f 100644 --- a/src/models/graph/kth_best_spanning_tree.rs +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -132,7 +132,11 @@ impl KthBestSpanningTree { bound: W::Sum, ) -> Result { if weights.len() != graph.num_edges() { - return Err("weights length must match graph num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); } if k == 0 { return Err("k must be positive".into()); diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 9b0a08dcf..4ac915a1e 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -153,7 +153,11 @@ impl LongestCircuit { fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_edges() { - return Err("edge_lengths length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); } if !weights .iter() diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index ed19dd43a..a40483fa0 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -181,7 +181,11 @@ impl LongestPath { fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_edges() { - return Err("edge_lengths length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); } if !weights .iter() diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 0aaa4f98a..ed736b430 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -160,7 +160,11 @@ impl MaxCut { fn try_new(graph: G, edge_weights: Vec) -> Result { if edge_weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); } Ok(Self { graph, diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index a071b6b89..cb12e3190 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -101,7 +101,11 @@ impl MaximalIS { fn try_new(graph: G, weights: Vec) -> Result { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } Ok(Self { graph, weights }) } diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index d4551ce84..9d98c1717 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -104,7 +104,11 @@ impl MaximumClique { fn try_new(graph: G, weights: Vec) -> Result { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } Ok(Self { graph, weights }) } diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index 2c513f469..473980f6e 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -136,7 +136,11 @@ impl MaximumCoKPlex { bound_k: usize, ) -> Result { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } if bound_k == 0 { return Err("co-k-plex parameter k must be at least 1".into()); diff --git a/src/models/graph/maximum_edge_weighted_k_clique.rs b/src/models/graph/maximum_edge_weighted_k_clique.rs index 9ba384908..4794fcbbf 100644 --- a/src/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/models/graph/maximum_edge_weighted_k_clique.rs @@ -120,8 +120,10 @@ impl MaximumEdgeWeightedKClique { k: usize, ) -> Result { if edge_weights.len() != graph.num_edges() { - return Err(ConstructionError::Conversion( - "edge_weights length must match graph num_edges".into(), + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), )); } for (index, weight) in edge_weights.iter().enumerate() { diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index d7a25323f..27b0152a1 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -215,7 +215,11 @@ impl MaximumIndependentSet { fn try_new(graph: G, weights: Vec) -> Result { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } Ok(Self { graph, weights }) } diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index 9b2e81b0a..00393c3fb 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -227,7 +227,11 @@ impl MaximumMatching { edge_weights: &[W], ) -> Result<(), crate::registry::ConstructionError> { if edge_weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); } Ok(()) } diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index af3f8b85f..165aee0d8 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -174,10 +174,18 @@ impl MinMaxMulticenter { k: usize, ) -> Result { if vertex_weights.len() != graph.num_vertices() { - return Err("vertex_weights length must match num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "vertex_weights", + vertex_weights.len(), + graph.num_vertices(), + )); } if edge_lengths.len() != graph.num_edges() { - return Err("edge_lengths length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_lengths", + edge_lengths.len(), + graph.num_edges(), + )); } let zero = W::Sum::zero(); if !vertex_weights diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 5648aa2dc..eb9fec5c0 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -151,7 +151,11 @@ impl MinimumCapacitatedSpanningTree { ) -> Result { Self::check_weights(&graph, &weights)?; if requirements.len() != graph.num_vertices() { - return Err("requirements length must match num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "requirements", + requirements.len(), + graph.num_vertices(), + )); } if root >= graph.num_vertices() { return Err(format!( @@ -190,7 +194,11 @@ impl MinimumCapacitatedSpanningTree { fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_edges() { - return Err("weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); } Ok(()) } diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 4830f55a1..b18db9deb 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -158,7 +158,11 @@ impl MinimumCutIntoBoundedSets { size_bound: usize, ) -> Result { if edge_weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); } if source >= graph.num_vertices() { return Err("source vertex out of bounds".into()); diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index d1ebc35b3..bcf4d598b 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -102,7 +102,11 @@ impl MinimumDominatingSet { fn try_new(graph: G, weights: Vec) -> Result { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } Ok(Self { graph, weights }) } diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index 5b7bb7ab2..f4ebfee82 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -130,7 +130,11 @@ impl MinimumFeedbackArcSet { weights: &[W], ) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_arcs() { - return Err("weights length must match graph num_arcs".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_arcs(), + )); } Ok(()) } diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index 0956a7f1f..ef796bb35 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -126,7 +126,11 @@ impl MinimumFeedbackVertexSet { weights: &[W], ) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } Ok(()) } diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 23f8980f9..944509185 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -107,7 +107,11 @@ impl MinimumMultiwayCut { edge_weights: Vec, ) -> Result { if edge_weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); } if terminals.len() < 2 { return Err("need at least 2 terminals".into()); diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 3d6e51dbf..cf0f2bcfd 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -173,10 +173,18 @@ impl MinimumSumMulticenter { k: usize, ) -> Result { if vertex_weights.len() != graph.num_vertices() { - return Err("vertex_weights length must match num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "vertex_weights", + vertex_weights.len(), + graph.num_vertices(), + )); } if edge_lengths.len() != graph.num_edges() { - return Err("edge_lengths length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_lengths", + edge_lengths.len(), + graph.num_edges(), + )); } if k == 0 { return Err("k must be positive".into()); diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index b5115bee3..be7910b79 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -105,7 +105,11 @@ impl MinimumVertexCover { fn try_new(graph: G, weights: Vec) -> Result { if weights.len() != graph.num_vertices() { - return Err("weights length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); } Ok(Self { graph, weights }) } diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs index 9a352504f..0d6ea73b6 100644 --- a/src/models/graph/mixed_chinese_postman.rs +++ b/src/models/graph/mixed_chinese_postman.rs @@ -161,12 +161,18 @@ impl> MixedChinesePostman { edge_weights: Vec, ) -> Result { if arc_weights.len() != graph.num_arcs() { - return Err("arc_weights length must match num_arcs".to_string().into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "arc_weights", + arc_weights.len(), + graph.num_arcs(), + )); } if edge_weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges" - .to_string() - .into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); } for (index, weight) in arc_weights.iter().enumerate() { if !matches!( diff --git a/src/models/graph/multiple_copy_file_allocation.rs b/src/models/graph/multiple_copy_file_allocation.rs index 91b3dd44d..328efa986 100644 --- a/src/models/graph/multiple_copy_file_allocation.rs +++ b/src/models/graph/multiple_copy_file_allocation.rs @@ -112,10 +112,18 @@ impl MultipleCopyFileAllocation { storage: Vec, ) -> Result { if usage.len() != graph.num_vertices() { - return Err("usage length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "usage", + usage.len(), + graph.num_vertices(), + )); } if storage.len() != graph.num_vertices() { - return Err("storage length must match graph num_vertices".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "storage", + storage.len(), + graph.num_vertices(), + )); } Ok(Self { graph, diff --git a/src/models/graph/path_constrained_network_flow.rs b/src/models/graph/path_constrained_network_flow.rs index f1a095295..fa94bc4cf 100644 --- a/src/models/graph/path_constrained_network_flow.rs +++ b/src/models/graph/path_constrained_network_flow.rs @@ -158,9 +158,11 @@ impl PathConstrainedNetworkFlow { ) -> Result { let num_vertices = graph.num_vertices(); if capacities.len() != graph.num_arcs() { - return Err("capacities length must match graph num_arcs" - .to_string() - .into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "capacities", + capacities.len(), + graph.num_arcs(), + )); } if source >= num_vertices { return Err(format!("source ({source}) >= num_vertices ({num_vertices})").into()); diff --git a/src/models/graph/prize_collecting_steiner_forest.rs b/src/models/graph/prize_collecting_steiner_forest.rs index 5020daea2..a29bfae3d 100644 --- a/src/models/graph/prize_collecting_steiner_forest.rs +++ b/src/models/graph/prize_collecting_steiner_forest.rs @@ -212,13 +212,17 @@ impl PrizeCollectingSteinerForest { omega: W, ) -> Result { if vertex_prizes.len() != graph.num_vertices() { - return Err(ConstructionError::Conversion( - "vertex_prizes length must match graph num_vertices".into(), + return Err(crate::registry::ConstructionError::length_mismatch( + "vertex_prizes", + vertex_prizes.len(), + graph.num_vertices(), )); } if edge_costs.len() != graph.num_edges() { - return Err(ConstructionError::Conversion( - "edge_costs length must match graph num_edges".into(), + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_costs", + edge_costs.len(), + graph.num_edges(), )); } for (index, prize) in vertex_prizes.iter().enumerate() { diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index ed44b8dd7..66e041f7a 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -207,7 +207,11 @@ impl RuralPostman { fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { if weights.len() != graph.num_edges() { - return Err("edge_lengths length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); } Ok(()) } diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index c6cf2e6c1..3fcee5ce2 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -139,7 +139,11 @@ impl ShortestWeightConstrainedPath { label: &str, ) -> Result<(), crate::registry::ConstructionError> { if values.len() != graph.num_edges() { - return Err(format!("{label} length must match num_edges").into()); + return Err(crate::registry::ConstructionError::length_mismatch( + label, + values.len(), + graph.num_edges(), + )); } if !values.iter().all(|value| value.to_sum() > N::Sum::zero()) { return Err(format!("All {label} must be positive (> 0)").into()); diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index d2cacd015..bfe522cc8 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -216,13 +216,17 @@ impl SpinGlass { fields: Vec, ) -> Result { if couplings.len() != graph.num_edges() { - return Err(ConstructionError::Conversion( - "couplings length must match num_edges".into(), + return Err(crate::registry::ConstructionError::length_mismatch( + "couplings", + couplings.len(), + graph.num_edges(), )); } if fields.len() != graph.num_vertices() { - return Err(ConstructionError::Conversion( - "fields length must match num_vertices".into(), + return Err(crate::registry::ConstructionError::length_mismatch( + "fields", + fields.len(), + graph.num_vertices(), )); } for (index, coupling) in couplings.iter().enumerate() { diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index 27714a427..585174952 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -98,14 +98,22 @@ struct SteinerTreeCreateSpec { impl TryFrom> for SteinerTree { type Error = crate::registry::ConstructionError; fn try_from(spec: SteinerTreeCreateSpec) -> Result { - Self::try_new(spec.graph, spec.edge_weights, spec.terminals).map_err(Into::into) + Self::try_new(spec.graph, spec.edge_weights, spec.terminals) } } impl SteinerTree { - fn try_new(graph: G, edge_weights: Vec, terminals: Vec) -> Result { + fn try_new( + graph: G, + edge_weights: Vec, + terminals: Vec, + ) -> Result { if edge_weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); } if terminals.is_empty() { return Err("at least one terminal required".into()); @@ -116,9 +124,7 @@ impl SteinerTree { } let n = graph.num_vertices(); if let Some(&terminal) = terminals.iter().find(|&&terminal| terminal >= n) { - return Err(format!( - "terminal {terminal} out of range (num_vertices = {n})" - )); + return Err(format!("terminal {terminal} out of range (num_vertices = {n})").into()); } Ok(Self { graph, @@ -343,7 +349,7 @@ impl TryFrom for SteinerTree { type Error = crate::registry::ConstructionError; fn try_from(spec: SteinerTreeOneCreateSpec) -> Result { let weights = vec![One; spec.graph.num_edges()]; - Self::try_new(spec.graph, weights, spec.terminals).map_err(Into::into) + Self::try_new(spec.graph, weights, spec.terminals) } } diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index e6d26142f..155ec4b87 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -132,7 +132,11 @@ impl TravelingSalesman { fn try_new(graph: G, edge_weights: Vec) -> Result { if edge_weights.len() != graph.num_edges() { - return Err("edge_weights length must match num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); } Ok(Self { graph, diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index d9e610056..c8a270ba6 100644 --- a/src/models/graph/undirected_flow_lower_bounds.rs +++ b/src/models/graph/undirected_flow_lower_bounds.rs @@ -94,10 +94,18 @@ impl UndirectedFlowLowerBounds { requirement: i64, ) -> Result { if capacities.len() != graph.num_edges() { - return Err("capacities length must match graph num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "capacities", + capacities.len(), + graph.num_edges(), + )); } if lower_bounds.len() != graph.num_edges() { - return Err("lower_bounds length must match graph num_edges".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "lower_bounds", + lower_bounds.len(), + graph.num_edges(), + )); } let num_vertices = graph.num_vertices(); diff --git a/src/models/graph/undirected_two_commodity_integral_flow.rs b/src/models/graph/undirected_two_commodity_integral_flow.rs index f06144eb4..72161a497 100644 --- a/src/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/models/graph/undirected_two_commodity_integral_flow.rs @@ -161,7 +161,11 @@ impl UndirectedTwoCommodityIntegralFlow { requirement_2: i64, ) -> Result { if capacities.len() != graph.num_edges() { - return Err("capacities length must match graph edge count".into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "capacities", + capacities.len(), + graph.num_edges(), + )); } let num_vertices = graph.num_vertices(); diff --git a/src/models/misc/capacity_assignment.rs b/src/models/misc/capacity_assignment.rs index 59483ddd1..aa18cea41 100644 --- a/src/models/misc/capacity_assignment.rs +++ b/src/models/misc/capacity_assignment.rs @@ -87,7 +87,11 @@ impl CapacityAssignment { let num_capacities = capacities.len(); for (link, row) in cost.iter().enumerate() { if row.len() != num_capacities { - return Err(format!("cost row {link} length must match capacities length").into()); + return Err(crate::registry::ConstructionError::length_mismatch( + &format!("cost row {link}"), + row.len(), + num_capacities, + )); } if row.windows(2).any(|w| w[0] > w[1]) { return Err(format!("cost row {link} must be non-decreasing").into()); @@ -95,7 +99,11 @@ impl CapacityAssignment { } for (link, row) in delay.iter().enumerate() { if row.len() != num_capacities { - return Err(format!("delay row {link} length must match capacities length").into()); + return Err(crate::registry::ConstructionError::length_mismatch( + &format!("delay row {link}"), + row.len(), + num_capacities, + )); } if row.windows(2).any(|w| w[0] < w[1]) { return Err(format!("delay row {link} must be non-increasing").into()); diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index c30c09594..50736c6b4 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -150,14 +150,18 @@ impl StackerCrane { edge_lengths: Vec, ) -> Result { if arc_lengths.len() != arcs.len() { - return Err("arc_lengths length must match arcs length" - .to_string() - .into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "arc_lengths", + arc_lengths.len(), + arcs.len(), + )); } if edge_lengths.len() != edges.len() { - return Err("edge_lengths length must match edges length" - .to_string() - .into()); + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_lengths", + edge_lengths.len(), + edges.len(), + )); } for (arc_index, &(tail, head)) in arcs.iter().enumerate() { if tail >= num_vertices || head >= num_vertices { diff --git a/src/models/set/maximum_set_packing.rs b/src/models/set/maximum_set_packing.rs index 40542427c..7f654db01 100644 --- a/src/models/set/maximum_set_packing.rs +++ b/src/models/set/maximum_set_packing.rs @@ -111,8 +111,10 @@ impl MaximumSetPacking { W: WeightElement, { if sets.len() != weights.len() { - return Err(ConstructionError::Conversion( - "weights length must match number of sets".into(), + return Err(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + sets.len(), )); } for (index, weight) in weights.iter().enumerate() { diff --git a/src/registry/variant.rs b/src/registry/variant.rs index 18a9df76d..4f2dc0fb8 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -121,6 +121,12 @@ pub enum ConstructionError { InexactFloatConversion(#[from] crate::types::ExactI64ToF64Error), } +impl ConstructionError { + pub(crate) fn length_mismatch(field: &str, actual: usize, expected: usize) -> Self { + Self::Conversion(format!("{field} has length {actual}, expected {expected}")) + } +} + impl From for ConstructionError { fn from(message: String) -> Self { Self::Conversion(message) diff --git a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs index eaf568cfd..75663d77f 100644 --- a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs @@ -183,7 +183,7 @@ fn test_bounded_diameter_spanning_tree_zero_diameter_panics() { } #[test] -#[should_panic(expected = "edge_weights length must match num_edges")] +#[should_panic(expected = "weights has length 1, expected 2")] fn test_bounded_diameter_spanning_tree_wrong_weights_length_panics() { let _ = BoundedDiameterSpanningTree::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1], 5, 2); diff --git a/src/unit_tests/models/graph/kth_best_spanning_tree.rs b/src/unit_tests/models/graph/kth_best_spanning_tree.rs index ad5a3a13c..6672b8aee 100644 --- a/src/unit_tests/models/graph/kth_best_spanning_tree.rs +++ b/src/unit_tests/models/graph/kth_best_spanning_tree.rs @@ -180,7 +180,7 @@ fn test_kthbestspanningtree_single_vertex_rejects_multiple_empty_trees() { } #[test] -#[should_panic(expected = "weights length must match graph num_edges")] +#[should_panic(expected = "weights has length 1, expected 2")] fn test_kthbestspanningtree_creation_rejects_weight_length_mismatch() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let _ = KthBestSpanningTree::::new(graph, vec![1], 1, 2); diff --git a/src/unit_tests/models/graph/longest_path.rs b/src/unit_tests/models/graph/longest_path.rs index 23e1be559..6df940f4c 100644 --- a/src/unit_tests/models/graph/longest_path.rs +++ b/src/unit_tests/models/graph/longest_path.rs @@ -207,7 +207,7 @@ fn test_longest_path_problem_name() { } #[test] -#[should_panic(expected = "edge_lengths length must match num_edges")] +#[should_panic(expected = "weights has length 1, expected 2")] fn test_longest_path_rejects_wrong_edge_lengths_len() { LongestPath::new(SimpleGraph::path(3), vec![1], 0, 2); } diff --git a/src/unit_tests/models/graph/maximum_co_k_plex.rs b/src/unit_tests/models/graph/maximum_co_k_plex.rs index d391d2c53..ac610314a 100644 --- a/src/unit_tests/models/graph/maximum_co_k_plex.rs +++ b/src/unit_tests/models/graph/maximum_co_k_plex.rs @@ -200,7 +200,7 @@ fn test_maximum_co_k_plex_rejects_zero_k() { } #[test] -#[should_panic(expected = "weights length must match graph num_vertices")] +#[should_panic(expected = "weights has length 4, expected 5")] fn test_maximum_co_k_plex_rejects_weight_length_mismatch() { let _ = MaximumCoKPlex::<_, One, KN>::with_k(c5(), vec![One; 4], 2); } diff --git a/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs b/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs index a00cd688c..1db45c2fa 100644 --- a/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/unit_tests/models/graph/maximum_edge_weighted_k_clique.rs @@ -235,7 +235,7 @@ fn test_maximum_edge_weighted_k_clique_rejects_weight_length_mismatch() { assert!(matches!( error, crate::registry::ConstructionError::Conversion(message) - if message == "edge_weights length must match graph num_edges" + if message == "edge_weights has length 4, expected 5" )); } diff --git a/src/unit_tests/models/graph/min_max_multicenter.rs b/src/unit_tests/models/graph/min_max_multicenter.rs index 7c10c527a..8ec290c2b 100644 --- a/src/unit_tests/models/graph/min_max_multicenter.rs +++ b/src/unit_tests/models/graph/min_max_multicenter.rs @@ -239,14 +239,14 @@ fn test_minmaxmulticenter_nonunit_edge_lengths() { } #[test] -#[should_panic(expected = "vertex_weights length must match num_vertices")] +#[should_panic(expected = "vertex_weights has length 2, expected 3")] fn test_minmaxmulticenter_wrong_vertex_weights_len() { let graph = SimpleGraph::new(3, vec![(0, 1)]); MinMaxMulticenter::new(graph, vec![1i64; 2], vec![1i64; 1], 1); } #[test] -#[should_panic(expected = "edge_lengths length must match num_edges")] +#[should_panic(expected = "edge_lengths has length 2, expected 1")] fn test_minmaxmulticenter_wrong_edge_lengths_len() { let graph = SimpleGraph::new(3, vec![(0, 1)]); MinMaxMulticenter::new(graph, vec![1i64; 3], vec![1i64; 2], 1); diff --git a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs index 0fcf62e90..9dec7081b 100644 --- a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs @@ -86,14 +86,14 @@ fn test_creation() { } #[test] -#[should_panic(expected = "weights length must match num_edges")] +#[should_panic(expected = "weights has length 3, expected 2")] fn test_rejects_wrong_weight_count() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let _ = MinimumCapacitatedSpanningTree::new(graph, vec![1, 1, 1], 0, vec![0, 1, 1], 3); } #[test] -#[should_panic(expected = "requirements length must match num_vertices")] +#[should_panic(expected = "requirements has length 2, expected 3")] fn test_rejects_wrong_requirements_count() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let _ = MinimumCapacitatedSpanningTree::new(graph, vec![1, 1], 0, vec![0, 1], 3); diff --git a/src/unit_tests/models/graph/minimum_multiway_cut.rs b/src/unit_tests/models/graph/minimum_multiway_cut.rs index 7a1fc9ca0..110e70296 100644 --- a/src/unit_tests/models/graph/minimum_multiway_cut.rs +++ b/src/unit_tests/models/graph/minimum_multiway_cut.rs @@ -150,7 +150,7 @@ fn test_minimummultiwaycut_name() { } #[test] -#[should_panic(expected = "edge_weights length must match num_edges")] +#[should_panic(expected = "edge_weights has length 1, expected 2")] fn test_minimummultiwaycut_panic_wrong_weights_len() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); MinimumMultiwayCut::new(graph, vec![0, 2], vec![1i64]); diff --git a/src/unit_tests/models/graph/minimum_sum_multicenter.rs b/src/unit_tests/models/graph/minimum_sum_multicenter.rs index 3f2501ef6..22e24a296 100644 --- a/src/unit_tests/models/graph/minimum_sum_multicenter.rs +++ b/src/unit_tests/models/graph/minimum_sum_multicenter.rs @@ -232,14 +232,14 @@ fn test_min_sum_multicenter_all_centers() { } #[test] -#[should_panic(expected = "vertex_weights length must match num_vertices")] +#[should_panic(expected = "vertex_weights has length 2, expected 3")] fn test_min_sum_multicenter_wrong_vertex_weights_len() { let graph = SimpleGraph::new(3, vec![(0, 1)]); MinimumSumMulticenter::new(graph, vec![1i64; 2], vec![1i64; 1], 1); } #[test] -#[should_panic(expected = "edge_lengths length must match num_edges")] +#[should_panic(expected = "edge_lengths has length 2, expected 1")] fn test_min_sum_multicenter_wrong_edge_lengths_len() { let graph = SimpleGraph::new(3, vec![(0, 1)]); MinimumSumMulticenter::new(graph, vec![1i64; 3], vec![1i64; 2], 1); diff --git a/src/unit_tests/models/graph/mixed_chinese_postman.rs b/src/unit_tests/models/graph/mixed_chinese_postman.rs index 319188360..c2a6a9967 100644 --- a/src/unit_tests/models/graph/mixed_chinese_postman.rs +++ b/src/unit_tests/models/graph/mixed_chinese_postman.rs @@ -190,12 +190,12 @@ fn test_mixed_chinese_postman_deserialization_rejects_invalid_weights() { ( "arc_weights", serde_json::json!([2, 3, 1]), - "arc_weights length must match num_arcs", + "arc_weights has length 3, expected 4", ), ( "edge_weights", serde_json::json!([2, 3, 1, 2, 7]), - "edge_weights length must match num_edges", + "edge_weights has length 5, expected 4", ), ( "arc_weights", diff --git a/src/unit_tests/models/graph/path_constrained_network_flow.rs b/src/unit_tests/models/graph/path_constrained_network_flow.rs index be52fdc44..6df403538 100644 --- a/src/unit_tests/models/graph/path_constrained_network_flow.rs +++ b/src/unit_tests/models/graph/path_constrained_network_flow.rs @@ -201,7 +201,7 @@ fn test_path_constrained_network_flow_deserialization_rejects_invalid_instances( ( "capacities", serde_json::json!([1, 1]), - "capacities length must match graph num_arcs", + "capacities has length 2, expected 10", ), ( "source", diff --git a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs index fa4c06e45..a141e5241 100644 --- a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs +++ b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs @@ -185,7 +185,7 @@ fn test_prize_collecting_steiner_forest_rejects_vertex_prizes_length_mismatch() assert!(matches!( error, crate::registry::ConstructionError::Conversion(message) - if message == "vertex_prizes length must match graph num_vertices" + if message == "vertex_prizes has length 2, expected 3" )); } @@ -202,7 +202,7 @@ fn test_prize_collecting_steiner_forest_rejects_edge_costs_length_mismatch() { assert!(matches!( error, crate::registry::ConstructionError::Conversion(message) - if message == "edge_costs length must match graph num_edges" + if message == "edge_costs has length 3, expected 2" )); } diff --git a/src/unit_tests/models/graph/steiner_tree.rs b/src/unit_tests/models/graph/steiner_tree.rs index ec2706725..f0f8e3d82 100644 --- a/src/unit_tests/models/graph/steiner_tree.rs +++ b/src/unit_tests/models/graph/steiner_tree.rs @@ -219,7 +219,7 @@ fn test_steiner_tree_rejects_out_of_range_terminal() { } #[test] -#[should_panic(expected = "edge_weights length must match num_edges")] +#[should_panic(expected = "edge_weights has length 3, expected 2")] fn test_steiner_tree_rejects_wrong_weight_count() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let _ = SteinerTree::new(graph, vec![1, 1, 1], vec![0, 2]); diff --git a/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs index 874845871..bb4a50624 100644 --- a/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/unit_tests/models/graph/undirected_two_commodity_integral_flow.rs @@ -207,7 +207,7 @@ fn test_undirected_two_commodity_integral_flow_shared_capacity_exceeded() { } #[test] -#[should_panic(expected = "capacities length must match")] +#[should_panic(expected = "capacities has length 1, expected 2")] fn test_undirected_two_commodity_integral_flow_panics_wrong_capacity_count() { UndirectedTwoCommodityIntegralFlow::new( SimpleGraph::new(3, vec![(0, 1), (1, 2)]), diff --git a/src/unit_tests/models/set/maximum_set_packing.rs b/src/unit_tests/models/set/maximum_set_packing.rs index 9993ca2ef..fdf1b3308 100644 --- a/src/unit_tests/models/set/maximum_set_packing.rs +++ b/src/unit_tests/models/set/maximum_set_packing.rs @@ -191,3 +191,12 @@ fn test_setpacking_paper_example() { fn test_maximum_set_packing_rejects_non_finite_weight() { assert!(MaximumSetPacking::with_weights(vec![vec![0]], vec![f64::NEG_INFINITY]).is_err()); } + +#[test] +fn test_set_packing_weight_length_error_reports_counts() { + let error = MaximumSetPacking::with_weights(vec![vec![0], vec![1]], vec![1_i64]).unwrap_err(); + assert_eq!( + error, + crate::registry::ConstructionError::Conversion("weights has length 1, expected 2".into()) + ); +} From 942bc21ef8a44b342cd9acb1458a6640aba3692a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:46 +0800 Subject: [PATCH 34/44] fix(cli): clarify raw aggregate extraction and omit solver metadata Co-Authored-By: Codex --- problemreductions-cli/src/cli.rs | 26 ++++---- problemreductions-cli/src/commands/extract.rs | 65 +++++++++---------- problemreductions-cli/tests/cli_tests.rs | 4 +- 3 files changed, 46 insertions(+), 49 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 7ec1e5d9b..b841d6a1f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -215,9 +215,11 @@ Examples: pred extract bundle.json --config '[true,false]' pred extract bundle.json --config '[true,false]' -o source.json pred extract bundle.json --value 2 + pred extract - --config '[true,false]' < bundle.json --config calls the rules' solution mapping; --value calls their aggregate mapping. -Supply the completed target aggregate for --value, such as an optimum or count. +Supply raw JSON of the completed target aggregate for --value: 2, true, or null. +Do not use wrapper syntax such as Min(2). Extraction does not solve the target or prove that the supplied value is optimal.")] Extract(ExtractArgs), /// Start MCP (Model Context Protocol) server for AI assistant integration @@ -575,17 +577,15 @@ mod tests { .is_ok()); assert!(Cli::try_parse_from(["pred", "extract", "bundle.json"]).is_err()); assert!(Cli::try_parse_from(["pred", "extract", "bundle.json", "--value", "2"]).is_ok()); - for flag in ["--result", "--value", "--status"] { - assert!(Cli::try_parse_from([ - "pred", - "extract", - "bundle.json", - "--config", - "[true,false]", - flag, - "2" - ]) - .is_err()); - } + assert!(Cli::try_parse_from([ + "pred", + "extract", + "bundle.json", + "--config", + "[true,false]", + "--value", + "2" + ]) + .is_err()); } } diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index e34d54ca0..55289543e 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -25,38 +25,35 @@ pub fn extract(args: &ExtractArgs, out: &OutputConfig) -> Result<()> { || Ok(serde_json::json!({"problem": bundle.source.problem_type, "value": source_value})), ); } - let solution = serde_json::from_str( - args.config - .as_deref() - .context("--config or --value is required")?, - ) - .context("Target config is not valid JSON")?; - let replay = BundleReplay::prepare(&bundle)?; - let target_evaluation = replay - .target - .evaluate_witness_dyn(&solution)? - .context("target witness is infeasible")?; - let (source_solution, source_evaluation) = replay.extract(&solution)?; - out.emit( - || { - format!( - "Problem: {}\nSolution: {source_solution}\nEvaluation: {source_evaluation}", - replay.source_name - ) - }, - || { - Ok(serde_json::json!({ - "problem": replay.source_name, - "solver": "external", - "reduced_to": replay.target_name, - "solution": source_solution, - "evaluation": source_evaluation, - "intermediate": { - "problem": replay.target_name, - "solution": solution, - "evaluation": target_evaluation, - }, - })) - }, - ) + if let Some(config) = &args.config { + let solution = serde_json::from_str(config).context("Target config is not valid JSON")?; + let replay = BundleReplay::prepare(&bundle)?; + let target_evaluation = replay + .target + .evaluate_witness_dyn(&solution)? + .context("target witness is infeasible")?; + let (source_solution, source_evaluation) = replay.extract(&solution)?; + out.emit( + || { + format!( + "Problem: {}\nSolution: {source_solution}\nEvaluation: {source_evaluation}", + replay.source_name + ) + }, + || { + Ok(serde_json::json!({ + "problem": replay.source_name, + "reduced_to": replay.target_name, + "solution": source_solution, + "evaluation": source_evaluation, + "intermediate": { + "problem": replay.target_name, + "solution": solution, + "evaluation": target_evaluation, + }, + })) + }, + )?; + } + Ok(()) } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7dbf37d12..4f9cd1949 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -9794,7 +9794,7 @@ fn test_extract_roundtrip_mis_to_qubo() { let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(json["problem"].as_str().unwrap(), "MaximumIndependentSet"); assert_eq!(json["reduced_to"].as_str().unwrap(), "QUBO"); - assert_eq!(json["solver"].as_str().unwrap(), "external"); + assert!(json.get("solver").is_none()); // extract on pred-solve's own target config must round-trip to the same source evaluation. assert_eq!(json["evaluation"].as_str().unwrap(), expected_source_eval); assert_eq!(json["intermediate"]["problem"].as_str().unwrap(), "QUBO"); @@ -10190,7 +10190,7 @@ fn test_extract_reads_bundle_from_stdin() { let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(json["problem"].as_str().unwrap(), "MaximumIndependentSet"); assert_eq!(json["reduced_to"].as_str().unwrap(), "QUBO"); - assert_eq!(json["solver"].as_str().unwrap(), "external"); + assert!(json.get("solver").is_none()); assert_eq!(json["evaluation"].as_str().unwrap(), "Max(2)"); std::fs::remove_file(&problem_file).ok(); From 1096b78e9461df436aa545cbe72b61348d8eec91 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:46 +0800 Subject: [PATCH 35/44] fix: explain legacy CVP and QUBO persisted formats Co-Authored-By: Codex --- src/models/algebraic/qubo.rs | 17 ++++++++++++----- src/registry/problem_ref.rs | 10 ++++++++-- src/unit_tests/models/algebraic/qubo.rs | 10 ++++++++++ src/unit_tests/registry/problem_type.rs | 14 ++++++++++++++ 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 49b092274..c753a0f72 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -55,11 +55,7 @@ inventory::submit! { /// // Optimal is x = [0, 1] with value -2 /// assert!(solutions.contains(&vec![false, true])); /// ``` -#[derive(Debug, Clone, Deserialize)] -#[serde( - try_from = "QuboData", - bound(deserialize = "W: WeightElement + Deserialize<'de>") -)] +#[derive(Debug, Clone)] pub struct QUBO { /// Number of variables. num_vars: usize, @@ -75,6 +71,17 @@ struct QuboData { entries: Vec<(usize, usize, W)>, } +impl<'de, W: WeightElement + Deserialize<'de>> Deserialize<'de> for QUBO { + fn deserialize>(deserializer: D) -> Result { + let data = QuboData::deserialize(deserializer).map_err(|error| { + serde::de::Error::custom(format!( + "{error}; expected QUBO format: num_vars and sparse entries [row, col, value] with row <= col" + )) + })?; + Self::try_from(data).map_err(serde::de::Error::custom) + } +} + impl Serialize for QUBO { fn serialize(&self, serializer: S) -> Result { let entries = self diff --git a/src/registry/problem_ref.rs b/src/registry/problem_ref.rs index 64a4dfd6e..a67b07bdf 100644 --- a/src/registry/problem_ref.rs +++ b/src/registry/problem_ref.rs @@ -135,8 +135,14 @@ impl ProblemRef { .any(|dimension| !variant.contains_key(dimension.key)) { return Err(format!( - "Variant for {} must specify a prefix of its dimensions", - problem_type.canonical_name + "Variant for {} must specify a prefix of its dimension keys: {}", + problem_type.canonical_name, + problem_type + .dimensions + .iter() + .map(|dimension| dimension.key) + .collect::>() + .join(", ") ) .into()); } diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 2130db53b..4eebe5336 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -281,3 +281,13 @@ fn test_qubo_entries_reject_oversized_num_vars() { assert!(error.to_string().contains("too large"), "{error}"); } } + +#[test] +fn test_qubo_legacy_matrix_error_explains_sparse_format() { + let error = serde_json::from_value::>(serde_json::json!({"matrix": [[1]]})) + .unwrap_err() + .to_string(); + for hint in ["num_vars", "sparse entries [row, col, value]", "row <= col"] { + assert!(error.contains(hint), "{error}"); + } +} diff --git a/src/unit_tests/registry/problem_type.rs b/src/unit_tests/registry/problem_type.rs index 3e8744cc4..99384ee4b 100644 --- a/src/unit_tests/registry/problem_type.rs +++ b/src/unit_tests/registry/problem_type.rs @@ -349,3 +349,17 @@ fn concrete_rule_and_solver_variants_have_standard_registration() { crate::solvers::solver_capabilities(&key).expect("all concrete solvers must be registered"); } } + +#[test] +fn legacy_cvp_variant_names_expected_dimension_key() { + let problem = find_problem_type("ClosestVectorProblem").unwrap(); + for key in ["target", "weight"] { + let error = + ProblemRef::from_prefix_map(&problem, [(key.to_string(), "i64".to_string())].into()) + .unwrap_err(); + assert!( + error.to_string().contains("dimension keys: coefficient"), + "{error}" + ); + } +} From 039d1b210d0f19e4d53fddbda77af12e6671fb6c Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:46 +0800 Subject: [PATCH 36/44] docs(cli): use runnable QUBO and aggregate recovery examples Co-Authored-By: Codex --- docs/src/cli-commands.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/src/cli-commands.md b/docs/src/cli-commands.md index 3930d3000..08870f8fc 100644 --- a/docs/src/cli-commands.md +++ b/docs/src/cli-commands.md @@ -70,7 +70,7 @@ Other input structures: ```bash pred create SAT --num-vars 3 --clauses '1,2;-1,3' -o sat.json # signed one-based literals; ';' separates clauses -pred create QUBO --matrix '1,0.5;0.5,2' -o qubo.json # ';' separates rows +pred create QUBO --matrix '1,1;0,2' -o qubo.json # ';' separates rows pred create X3C --universe-size 6 --subsets '0,1,2;3,4,5;0,3,4' -o x3c.json pred create Factoring --target 6 --m 2 --n 2 -o factoring.json ``` @@ -90,11 +90,13 @@ For a problem file, JSON inspection includes `parameter_values`, the model's act ## Reduce ```bash -pred path MIS QUBO --json -o paths.json +pred create DecisionMinimumVertexCover --graph 0-1,1-2,0-2 --weights 1,1,1 --bound 2 -o decision-mvc.json +pred path DecisionMinimumVertexCover MinimumVertexCover --json -o paths.json python3 -c 'import json; print(json.dumps(json.load(open("paths.json"))["paths"][0]))' > path.json -pred reduce problem.json --via path.json -o reduced.json -pred extract reduced.json --config '[true,false]' -o source-solution.json +pred reduce decision-mvc.json --via path.json --aggregate -o reduced.json pred extract reduced.json --value 2 +pred extract reduced.json --config '[true,true,false]' -o source-solution.json +pred reduce decision-mvc.json --via path.json --aggregate | pred extract - --value 2 ``` The bundle contains the source instance, the target instance, and the variant-level path; keep it whole to preserve solution recovery. `--via` replays one route extracted from the `paths` envelope, whose source variant must match the input. @@ -111,8 +113,9 @@ MinimumVertexCover maps target optimum `2` to source value `false`. Its witness mapping cannot produce a cover of size at most 1 from a two-vertex cover; that mapping returns an error. These are the rule's two distinct contracts. -The example inputs above are illustrative; use the actual target's configuration -or value encoding. Extraction requires no `status`, runs no solver, and does not +The triangle above has minimum cover size 2, so both extractions certify YES. +`--value` takes raw JSON such as `2`, `true`, or `null`, without wrappers such as +`Min(2)`. Extraction requires no `status`, runs no solver, and does not prove that a supplied aggregate is complete or optimal. Unsupported mappings and malformed inputs are errors. `pred solve reduced.json` still handles completed solver results internally. From 28d4db9ec22e8cf1018fc0a753a6a2809512a5ae Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:46 +0800 Subject: [PATCH 37/44] test: cross-check decision search on a five-cycle Co-Authored-By: Codex --- src/unit_tests/solvers/decision_search.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/unit_tests/solvers/decision_search.rs b/src/unit_tests/solvers/decision_search.rs index 5c6a02f75..a83129976 100644 --- a/src/unit_tests/solvers/decision_search.rs +++ b/src/unit_tests/solvers/decision_search.rs @@ -135,3 +135,23 @@ fn test_decision_search_infeasibility_and_evaluation_failure() { assert!(matches!(result, Err(SolveError::Evaluation(_)))); } } + +#[test] +fn test_decision_search_matches_brute_force_on_five_cycle() { + let graph = SimpleGraph::cycle(5); + let min = MinimumVertexCover::new(graph.clone(), vec![1_i64; 5]); + let max = MaximumIndependentSet::new(graph, vec![1_i64; 5]); + let solver = crate::solvers::BruteForce::new(); + let min_witness = solver.solve(&min).unwrap().unwrap(); + let max_witness = solver.solve(&max).unwrap().unwrap(); + assert_eq!(min.evaluate(&min_witness).unwrap().0, Some(3)); + assert_eq!(max.evaluate(&max_witness).unwrap().0, Some(2)); + assert_eq!( + solve_via_decision(&min, 0, 5).unwrap(), + min.evaluate(&min_witness).unwrap().0 + ); + assert_eq!( + solve_via_decision(&max, 0, 5).unwrap(), + max.evaluate(&max_witness).unwrap().0 + ); +} From 9ab6222a201ed8c61e0f2766e39d01f882848e6e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:47 +0800 Subject: [PATCH 38/44] feat(examples): add tiny decision scheduling circuit and unit cover examples Co-Authored-By: Codex --- docs/paper/reductions.typ | 2 +- src/example_db/model_builders.rs | 40 ++++++++++++++++++++++++++++++++ src/unit_tests/example_db.rs | 19 +++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 1cf9d13b8..781280a3a 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -1421,7 +1421,7 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| } #{ - let x = load-model-example("DecisionMinimumVertexCover") + let x = load-model-example("DecisionMinimumVertexCover", variant: (graph: "SimpleGraph", weight: "i64")) let inner = x.instance.inner let nv = graph-num-vertices(x.instance) let ne = graph-num-edges(x.instance) diff --git a/src/example_db/model_builders.rs b/src/example_db/model_builders.rs index a34433901..159bd3e23 100644 --- a/src/example_db/model_builders.rs +++ b/src/example_db/model_builders.rs @@ -7,6 +7,7 @@ pub fn build_model_examples() -> Vec { .chain(crate::models::set::canonical_model_example_specs()) .chain(crate::models::algebraic::canonical_model_example_specs()) .chain(crate::models::misc::canonical_model_example_specs()) + .chain(decision_model_examples()) .map(|spec| { let problem_name = spec.instance.problem_name().to_string(); let variant = spec.instance.variant_map(); @@ -21,3 +22,42 @@ pub fn build_model_examples() -> Vec { }) .collect() } + +fn decision_model_examples() -> Vec { + use crate::example_db::specs::ModelExampleSpec; + use crate::models::decision::Decision; + use crate::models::graph::{LongestCircuit, MinimumVertexCover}; + use crate::models::misc::OpenShopScheduling; + use crate::topology::SimpleGraph; + use crate::types::One; + + vec![ + ModelExampleSpec { + id: "decision_open_shop_scheduling", + instance: Box::new(Decision::new( + OpenShopScheduling::new(2, vec![vec![1, 1]]), + 2, + )), + optimal_config: serde_json::json!([0, 1]), + optimal_value: serde_json::json!(true), + }, + ModelExampleSpec { + id: "decision_longest_circuit", + instance: Box::new(Decision::new( + LongestCircuit::new(SimpleGraph::cycle(3), vec![1_i64; 3]), + 3, + )), + optimal_config: serde_json::json!([true, true, true]), + optimal_value: serde_json::json!(true), + }, + ModelExampleSpec { + id: "decision_minimum_vertex_cover_one", + instance: Box::new(Decision::new( + MinimumVertexCover::new(SimpleGraph::path(3), vec![One; 3]), + 1, + )), + optimal_config: serde_json::json!([false, true, false]), + optimal_value: serde_json::json!(true), + }, + ] +} diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 870f7a3bc..168df6c82 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -1400,3 +1400,22 @@ fn test_find_rule_example_maxcut_to_minimumcutintoboundedsets() { assert_eq!(example.source.problem, "MaxCut"); assert_eq!(example.target.problem, "MinimumCutIntoBoundedSets"); } + +#[test] +fn test_small_decision_model_examples_have_valid_witnesses() { + for spec in [ + "DecisionOpenShopScheduling", + "DecisionLongestCircuit", + "DecisionMinimumVertexCover/SimpleGraph/One", + ] { + let problem = crate::registry::parse_catalog_problem_ref(spec) + .unwrap() + .to_export_ref(); + let example = find_model_example(&problem).unwrap(); + let model = load_dyn(&example.problem, &example.variant, example.instance.clone()).unwrap(); + assert_eq!( + model.evaluate_witness_dyn(&example.optimal_config).unwrap(), + Some("Or(true)".to_string()) + ); + } +} From 206ad7d9338e2f666b9c46a1f6fc0a93edbcbb1a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:47 +0800 Subject: [PATCH 39/44] fix(qubo): reject lower-triangle entries and serialize the upper triangle Co-Authored-By: Codex --- docs/src/design.md | 9 +++--- src/models/algebraic/qubo.rs | 8 +++++ src/unit_tests/models/algebraic/qubo.rs | 43 +++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/src/design.md b/docs/src/design.md index df19f4716..fd22e6a95 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -505,10 +505,11 @@ let restored: MaximumIndependentSet = from_json(&json)?; ``` QUBO data uses `{"num_vars": 3, "entries": [[0,0,-2], [0,1,4]]}`. -Each entry is `[row, column, coefficient]` with zero-based indices; output lists -nonzero entries in row-major order. Duplicate and out-of-range coordinates are -errors. As with `from_matrix`, evaluation uses only the upper triangle, including -the diagonal. CLI creation still accepts `--matrix`. +Each entry is `[row, column, coefficient]` with zero-based indices and `row <= column`. +Output lists only nonzero upper-triangle entries in row-major order. Duplicate, +out-of-range, and lower-triangle coordinates are errors. For a lower-triangle +coordinate, use `(column, row)` instead. `from_matrix` and CLI `--matrix` still +accept full matrices; evaluation and serialization ignore their lower triangle. ## Contributing diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index c753a0f72..2aa46039d 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -92,6 +92,7 @@ impl Serialize for QUBO { values .iter() .enumerate() + .skip(row) .filter_map(move |(column, value)| { (!value.to_sum().is_zero()).then_some((row, column, value)) }) @@ -120,6 +121,13 @@ impl TryFrom> for QUBO { ))); } } + for &(row, column, _) in &data.entries { + if row > column { + return Err(ConstructionError::Conversion(format!( + "QUBO index ({row}, {column}) is below the diagonal; use ({column}, {row}) instead" + ))); + } + } data.entries.sort_by_key(|&(row, column, _)| (row, column)); for pair in data.entries.windows(2) { if (pair[0].0, pair[0].1) == (pair[1].0, pair[1].1) { diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 4eebe5336..215c9a3ed 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -7,13 +7,13 @@ include!("../../jl_helpers.rs"); #[test] fn test_qubo_entries_roundtrip() { - let data = serde_json::json!({"num_vars": 3, "entries": [[1,1,3],[0,1,-2],[1,0,4]]}); + let data = serde_json::json!({"num_vars": 3, "entries": [[1,1,3],[0,1,-2]]}); let problem: QUBO = serde_json::from_value(data).unwrap(); let encoded = serde_json::to_value(&problem).unwrap(); assert_eq!( encoded, serde_json::json!({ - "num_vars": 3, "entries": [[0,1,-2],[1,0,4],[1,1,3]] + "num_vars": 3, "entries": [[0,1,-2],[1,1,3]] }) ); let restored: QUBO = serde_json::from_value(encoded.clone()).unwrap(); @@ -291,3 +291,42 @@ fn test_qubo_legacy_matrix_error_explains_sparse_format() { assert!(error.contains(hint), "{error}"); } } + +#[test] +fn test_qubo_rejects_lower_triangle_entries() { + let error = QUBO::try_from(QuboData { + num_vars: 2, + entries: vec![(1, 0, 4_i64)], + }) + .unwrap_err(); + assert!( + matches!(error, ConstructionError::Conversion(ref message) if message.contains("use (0, 1) instead")) + ); + let error = serde_json::from_value::>( + serde_json::json!({"num_vars": 2, "entries": [[1, 0, 4]]}), + ) + .unwrap_err(); + assert!(error.to_string().contains("below the diagonal")); +} + +#[test] +fn test_qubo_matrix_serialization_omits_lower_triangle() { + let problem = QUBO::from_matrix(vec![vec![1, -2], vec![4, 3]]).unwrap(); + let encoded = serde_json::to_value(&problem).unwrap(); + assert_eq!( + encoded, + serde_json::json!({"num_vars": 2, "entries": [[0,0,1],[0,1,-2],[1,1,3]]}) + ); + let restored: QUBO = serde_json::from_value(encoded).unwrap(); + for config in [ + vec![false, false], + vec![false, true], + vec![true, false], + vec![true, true], + ] { + assert_eq!( + problem.evaluate(&config).unwrap(), + restored.evaluate(&config).unwrap() + ); + } +} From b8187410ac024555f0b1e2152eece15907113ffc Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:23:47 +0800 Subject: [PATCH 40/44] fix(kcoloring): allow zero colors in construction and random generation Co-Authored-By: Codex --- src/models/graph/kcoloring.rs | 6 ---- src/unit_tests/models/graph/kcoloring.rs | 39 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs index f0ca62ebe..4fca4b03a 100644 --- a/src/models/graph/kcoloring.rs +++ b/src/models/graph/kcoloring.rs @@ -138,9 +138,6 @@ impl TryFrom for KColoring { type Error = crate::registry::ConstructionError; fn try_from(spec: RuntimeKColoringCreateSpec) -> Result { - if spec.k == 0 { - return Err("k must be positive".to_string().into()); - } Ok(Self::with_k( simple_graph_from_create(spec.graph, spec.num_vertices)?, spec.k, @@ -320,9 +317,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec, crate::random::ColoringRandomSpec, |spec| { let k = spec.k.unwrap_or(3); - if k == 0 { - return Err("k must be positive".to_string().into()); - } Ok(KColoring::with_k(spec.graph()?, k)) }); crate::impl_random_generate!(KColoring, crate::random::ColoringRandomSpec, |spec| { diff --git a/src/unit_tests/models/graph/kcoloring.rs b/src/unit_tests/models/graph/kcoloring.rs index 3b63a6cc3..55e0260b7 100644 --- a/src/unit_tests/models/graph/kcoloring.rs +++ b/src/unit_tests/models/graph/kcoloring.rs @@ -312,3 +312,42 @@ fn runtime_color_counts_keep_their_native_domain_on_deserialization() { let restored: KColoring = serde_json::from_value(data).unwrap(); assert_eq!(restored.num_colors(), 0); } + +#[test] +fn test_kcoloring_zero_colors_create_evaluate_and_solve() { + for n in [0, 1, 3] { + let problem = KColoring::::try_from(RuntimeKColoringCreateSpec { + graph: vec![], + num_vertices: Some(n), + k: 0, + }) + .unwrap(); + assert_eq!(problem.num_colors(), 0); + let restored: KColoring = + serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); + assert_eq!(restored.num_colors(), 0); + assert_eq!( + BruteForce::new().solve(&problem).unwrap(), + if n == 0 { Some(vec![]) } else { None } + ); + if n == 0 { + assert!(problem.evaluate(&vec![]).unwrap().0); + } else { + assert!(problem.evaluate(&vec![0; n]).is_err()); + } + } +} + +#[test] +fn test_kcoloring_random_zero_colors() { + use crate::registry::RandomGenerate; + for n in [0, 3] { + let problem = KColoring::::generate(serde_json::json!({ + "num_vertices": n, "edge_prob": 0.5, "seed": 42, "k": 0, + })) + .unwrap(); + assert_eq!(problem.num_colors(), 0); + assert_eq!(problem.num_vertices(), n); + assert_eq!(BruteForce::new().solve(&problem).unwrap().is_some(), n == 0); + } +} From 056db2470f56c83ba207d8cd6aba0c1aafbfd9a8 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:46:52 +0800 Subject: [PATCH 41/44] refactor: unify completed-result recovery Co-Authored-By: Codex --- problemreductions-cli/src/dispatch.rs | 104 ++--------------- src/rules/graph.rs | 112 +++++++++++++++++- src/rules/mod.rs | 1 + src/solvers/registry.rs | 68 ++++++----- src/unit_tests/rules/graph.rs | 156 ++++++++++++++++++++++++++ 5 files changed, 320 insertions(+), 121 deletions(-) diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 2c393f047..883ebc1ca 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -205,12 +205,7 @@ pub struct BundleReplay { pub(crate) source_name: String, pub(crate) target: LoadedProblem, pub(crate) target_name: String, - steps: Vec, -} - -struct WitnessStep { chain: problemreductions::rules::ReductionChain, - source_variant: &'static problemreductions::registry::VariantEntry, } fn load_bundle_endpoints( @@ -313,33 +308,16 @@ impl BundleReplay { pub fn prepare(bundle: &ReductionBundle) -> Result { let (source, target, reduction_path) = load_bundle_endpoints(bundle)?; let graph = ReductionGraph::new(); - let mut steps: Vec = Vec::new(); - for edge in reduction_path.steps.windows(2) { - let input = steps - .last() - .map_or(source.as_any(), |step| step.chain.target_problem_any()); - let path = problemreductions::rules::ReductionPath { - steps: edge.to_vec(), - }; - let chain = graph.reduce_along_path(&path, input)?.ok_or_else(|| { - anyhow::anyhow!("Bundle requires a witness-capable reduction path") - })?; - let source_variant = - problemreductions::registry::find_variant_entry(&edge[0].name, &edge[0].variant) - .context("missing intermediate problem registration")?; - steps.push(WitnessStep { - chain, - source_variant, - }); - } - - validate_replayed_target(bundle, steps.last().unwrap().chain.target_problem_any())?; + let chain = graph + .reduce_along_path(&reduction_path, source.as_any())? + .context("Bundle requires a witness-capable reduction path")?; + validate_replayed_target(bundle, chain.target_problem_any())?; Ok(Self { source_name: source.problem_name().to_string(), target_name: target.problem_name().to_string(), source, target, - steps, + chain, }) } @@ -348,13 +326,7 @@ impl BundleReplay { &self, target_config: &serde_json::Value, ) -> Result<(serde_json::Value, String)> { - let source_config = self - .steps - .iter() - .rev() - .try_fold(target_config.clone(), |solution, step| { - step.chain.extract_solution_json(solution) - })?; + let source_config = self.chain.extract_solution_json(target_config.clone())?; let source_eval = self.source.evaluate_witness_dyn(&source_config)?.ok_or_else(|| { problemreductions::rules::ExtractionError::invalid(format!( "extracted solution is infeasible for {}; the reduction did not establish a source solution", @@ -368,62 +340,7 @@ impl BundleReplay { /// or infeasibility under its solver contract, including numerical tolerances; /// evaluating a candidate cannot establish it. pub(crate) fn extract_result(&self, result: &SolveOutcome) -> Result { - use problemreductions::rules::ExtractionError; - let steps = &self.steps; - let (mut witness, mut value, mut evaluation) = match result { - SolveOutcome::Optimal { - solution, - evaluation, - } => { - let value = self.target.evaluate_json(solution)?; - let actual = self - .target - .aggregate_witness_evaluation(&value)? - .ok_or_else(|| ExtractionError::invalid("target witness is infeasible"))?; - if evaluation != &actual { - return Err(ExtractionError::invalid( - "target evaluation does not match the witness", - ) - .into()); - } - (solution.clone(), value, actual) - } - SolveOutcome::Infeasible => return Ok(SolveOutcome::Infeasible), - }; - for (index, step) in steps.iter().enumerate().rev() { - let input: &dyn DynProblem = if index == 0 { - &*self.source - } else { - (step.source_variant.borrow_fn)(steps[index - 1].chain.target_problem_any()) - .context("intermediate problem type mismatch")? - }; - let mapped = if step.chain.has_value_mapping() { - Some(step.chain.extract_value(value)?) - } else { - None - }; - if let Some(mapped_value) = &mapped { - if input.aggregate_witness_evaluation(mapped_value)?.is_none() { - return Ok(SolveOutcome::Infeasible); - } - } - let solution = step.chain.extract_solution_json(witness)?; - value = input.evaluate_json(&solution)?; - evaluation = input - .aggregate_witness_evaluation(&value)? - .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?; - if mapped.is_some_and(|mapped| mapped != value) { - return Err(ExtractionError::invalid( - "extracted witness does not realize the mapped aggregate", - ) - .into()); - } - witness = solution; - } - Ok(SolveOutcome::Optimal { - solution: witness, - evaluation, - }) + Ok(self.chain.extract_result(&*self.source, result)?) } /// Solve the target and map the result back to the source problem. @@ -601,10 +518,7 @@ mod tests { problem_step::>(), ], ); - assert!(replay - .steps - .iter() - .all(|step| !step.chain.has_value_mapping())); + assert!(!replay.chain.has_value_mapping()); let result = replay.solve(SolverRequest::Ilp).unwrap(); if rank == 1 { assert!(matches!(result.target_outcome, SolveOutcome::Infeasible)); @@ -637,7 +551,7 @@ mod tests { ) .unwrap(); let replay = replay(&source, vec![problem_step::>()]); - assert!(!replay.steps[0].chain.has_value_mapping()); + assert!(!replay.chain.has_value_mapping()); let result = replay.solve(SolverRequest::Ilp).unwrap(); assert!(matches!(result.target_outcome, SolveOutcome::Infeasible)); assert!(matches!(result.source_outcome, SolveOutcome::Infeasible)); diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 18d4532ff..a0f50f77e 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -1530,18 +1530,127 @@ pub struct MatchedEntry { pub parameter_contract: Result, } +/// One executed edge and its source instance for completed-result recovery. +pub(crate) struct RecoveryStep<'a> { + pub result: &'a dyn DynReductionResult, + pub aggregate_view: Option, + pub source: &'a dyn crate::registry::DynProblem, +} + +/// Recover a completed result under the caller's solver contract. +/// `None` denotes established infeasibility, never an extraction failure. +pub(crate) fn recover_completed_result( + steps: &[RecoveryStep<'_>], + target: &dyn crate::registry::DynProblem, + outcome: &crate::solvers::SolveOutcome, +) -> crate::rules::ExtractionResult, String)>> { + use crate::rules::ExtractionError; + use crate::solvers::SolveOutcome; + let SolveOutcome::Optimal { + solution, + evaluation, + } = outcome + else { + return Ok(None); + }; + let mut value = target.evaluate_json(solution)?; + let mut actual = target + .aggregate_witness_evaluation(&value)? + .ok_or_else(|| ExtractionError::invalid("target witness is infeasible"))?; + if evaluation != &actual { + return Err(ExtractionError::invalid( + "target evaluation does not match the witness", + )); + } + let last = steps + .last() + .ok_or_else(|| ExtractionError::invalid("recovery requires a reduction edge"))?; + let mut witness = last.result.target_solution_from_json(solution.clone())?; + for step in steps.iter().rev() { + let mapped = step + .aggregate_view + .map(|view| view(step.result)?.extract_value_dyn(value.clone())) + .transpose()?; + if let Some(mapped_value) = &mapped { + if step + .source + .aggregate_witness_evaluation(mapped_value)? + .is_none() + { + return Ok(None); + } + } + witness = step.result.extract_solution_dyn(witness.as_ref())?; + value = step + .source + .evaluate_json(&step.result.source_solution_json(witness.as_ref())?)?; + actual = step + .source + .aggregate_witness_evaluation(&value)? + .ok_or_else(|| ExtractionError::invalid("extracted solution is infeasible"))?; + if mapped.is_some_and(|mapped| mapped != value) { + return Err(ExtractionError::invalid( + "extracted witness does not realize the mapped aggregate", + )); + } + } + Ok(Some((witness, actual))) +} + /// A composed reduction chain produced by [`ReductionGraph::reduce_along_path`]. /// /// Holds the intermediate reduction results from executing a multi-step /// reduction path. Provides access to the final target problem and /// solution and aggregate-value mappings back to the source problem space. -/// Solver status and result recovery belong to the execution layer. +/// Callers establish solver status before recovering a completed result. pub struct ReductionChain { steps: Vec>, aggregate_views: Vec>, + path: ReductionPath, } impl ReductionChain { + /// Recover a completed target result, checking mapped values against extracted witnesses. + /// The caller establishes optimality or infeasibility under its solver's contract. + pub fn extract_result( + &self, + source: &dyn crate::registry::DynProblem, + outcome: &crate::solvers::SolveOutcome, + ) -> crate::rules::ExtractionResult { + use crate::rules::ExtractionError; + use crate::solvers::SolveOutcome; + let borrow = |index: usize, problem| { + let node = &self.path.steps[index]; + crate::registry::find_variant_entry(&node.name, &node.variant) + .and_then(|entry| (entry.borrow_fn)(problem)) + .ok_or_else(|| ExtractionError::invalid("intermediate problem type mismatch")) + }; + let steps = self + .steps + .iter() + .enumerate() + .map(|(index, step)| { + Ok(RecoveryStep { + result: step.as_ref(), + aggregate_view: self.aggregate_views[index], + source: if index == 0 { + source + } else { + borrow(index, self.steps[index - 1].target_problem_any())? + }, + }) + }) + .collect::>>()?; + let target = borrow(self.steps.len(), self.target_problem_any())?; + match recover_completed_result(&steps, target, outcome)? { + None => Ok(SolveOutcome::Infeasible), + Some((witness, evaluation)) => Ok(SolveOutcome::Optimal { + solution: steps[0].result.source_solution_json(witness.as_ref())?, + evaluation, + }), + } + } + /// Whether every step can map an aggregate value using its existing construction. pub fn has_value_mapping(&self) -> bool { self.aggregate_views.iter().all(Option::is_some) @@ -1721,6 +1830,7 @@ impl ReductionGraph { Ok(Some(ReductionChain { steps, aggregate_views, + path: path.clone(), })) } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index b6bf6be9b..a55e411e4 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -29,6 +29,7 @@ pub(crate) mod exactcoverby3sets_staffscheduling; pub(crate) mod exactcoverby3sets_subsetproduct; pub(crate) mod factoring_circuit; mod graph; +pub(crate) use graph::{recover_completed_result, RecoveryStep}; pub(crate) mod graph_helpers; pub(crate) mod graphpartitioning_maxcut; pub(crate) mod graphpartitioning_qubo; diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index c0debd7c2..c86d70ede 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -142,37 +142,55 @@ impl CompiledIlpPipeline { let target = reductions .last() - .expect("non-empty fixed pipeline must produce a target") + .ok_or_else(|| crate::rules::ExtractionError::invalid("pipeline has no target"))? .target_problem_any(); - let solution = solver.solve_dyn(target)?; - let mut source_solution: Box = Box::new(solution); - for (index, step) in reductions.iter().enumerate().rev() { - if let Some(view) = self.reducers[index].1 { - let input = if index == 0 { - source - } else { - reductions[index - 1].target_problem_any() - }; - let aggregate = view(step.as_ref())?; - let value = aggregate.extract_value_from_solution_dyn(source_solution.as_ref())?; - let input_problem = crate::registry::find_variant_entry( - &self.path[index].name, - &self.path[index].variant, - ) - .and_then(|entry| (entry.borrow_fn)(input)) + let borrow = |index: usize, problem| { + crate::registry::find_variant_entry(&self.path[index].name, &self.path[index].variant) + .and_then(|entry| (entry.borrow_fn)(problem)) .ok_or_else(|| { crate::rules::ExtractionError::invalid("pipeline source type mismatch") + }) + }; + let target_problem = borrow(reductions.len(), target)?; + let outcome = match solver.solve_dyn(target) { + Ok(solution) => { + let solution = serde_json::to_value(solution).map_err(|error| { + crate::rules::ExtractionError::invalid(format!( + "ILP solution serialization failed: {error}" + )) })?; - if input_problem - .aggregate_witness_evaluation(&value) - .map_err(crate::rules::ExtractionError::from)? - .is_none() - { - return Err(super::ILPSolveError::Infeasible); + let evaluation = target_problem + .evaluate_dyn(&solution) + .map_err(crate::rules::ExtractionError::from)?; + super::SolveOutcome::Optimal { + solution, + evaluation, } } - source_solution = step.extract_solution_dyn(source_solution.as_ref())?; - } + Err(super::ILPSolveError::Infeasible) => super::SolveOutcome::Infeasible, + Err(error) => return Err(error), + }; + let steps = reductions + .iter() + .enumerate() + .map(|(index, step)| { + Ok(crate::rules::RecoveryStep { + result: step.as_ref(), + aggregate_view: self.reducers[index].1, + source: borrow( + index, + if index == 0 { + source + } else { + reductions[index - 1].target_problem_any() + }, + )?, + }) + }) + .collect::>>()?; + let (source_solution, _) = + crate::rules::recover_completed_result(&steps, target_problem, &outcome)? + .ok_or(super::ILPSolveError::Infeasible)?; finish(source_solution, Some(reductions[0].as_ref())) } diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 92d3fbfc2..013856e87 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -2112,3 +2112,159 @@ fn test_composed_path_parameters_transform_evaluation() { assert_eq!(final_size.get("num_vertices"), Some(10)); assert_eq!(final_size.get("num_edges"), Some(20)); } + +struct RecoveryFixture { + target: MaxCut, + mapped: crate::types::Max, + extraction_fails: bool, +} + +impl ReductionResult for RecoveryFixture { + type Source = MaxCut; + type Target = MaxCut; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, solution: &Vec) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(&self.target, solution)?; + if self.extraction_fails { + return Err(crate::rules::ExtractionError::invalid( + "fixture extraction failure", + )); + } + Ok(solution.clone()) + } +} + +impl AggregateReductionResult for RecoveryFixture { + type Source = MaxCut; + type Target = MaxCut; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_value(&self, _: crate::types::Max) -> crate::types::Max { + self.mapped + } +} + +fn recovery_fixture(mapped: Option, extraction_fails: bool) -> RecoveryFixture { + RecoveryFixture { + target: MaxCut::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1]), + mapped: crate::types::Max(mapped), + extraction_fails, + } +} + +fn recover_fixture( + fixture: &RecoveryFixture, + outcome: &crate::solvers::SolveOutcome, +) -> crate::rules::ExtractionResult, String)>> { + // The first edge has no value map. A NO from the second must bypass its decoder. + let upstream = recovery_fixture(Some(1), true); + let steps = [ + RecoveryStep { + result: &upstream, + aggregate_view: None, + source: &upstream.target, + }, + RecoveryStep { + result: fixture, + aggregate_view: Some(crate::rules::aggregate_view::), + source: &fixture.target, + }, + ]; + recover_completed_result(&steps, &fixture.target, outcome) +} + +fn completed_cut() -> crate::solvers::SolveOutcome { + crate::solvers::SolveOutcome::Optimal { + solution: json!([false, true]), + evaluation: "Max(1)".into(), + } +} + +#[test] +fn completed_recovery_propagates_target_infeasibility() { + assert!(recover_fixture( + &recovery_fixture(Some(1), true), + &crate::solvers::SolveOutcome::Infeasible + ) + .unwrap() + .is_none()); +} + +#[test] +fn completed_recovery_propagates_mapped_value_without_witness() { + assert!( + recover_fixture(&recovery_fixture(None, true), &completed_cut()) + .unwrap() + .is_none() + ); +} + +#[test] +fn completed_recovery_rejects_witness_not_realizing_mapped_value() { + let error = recover_fixture(&recovery_fixture(Some(2), false), &completed_cut()) + .err() + .unwrap(); + assert!(error + .to_string() + .contains("does not realize the mapped aggregate")); +} + +#[test] +fn completed_recovery_preserves_extraction_error() { + let error = recover_fixture(&recovery_fixture(Some(1), true), &completed_cut()) + .err() + .unwrap(); + assert!(matches!( + error, + crate::rules::ExtractionError::Reduction { .. } + )); + assert!(error.to_string().contains("fixture extraction failure")); +} + +#[test] +fn completed_recovery_checks_target_evaluation() { + let outcome = crate::solvers::SolveOutcome::Optimal { + solution: json!([false, true]), + evaluation: "Max(2)".into(), + }; + let error = recover_fixture(&recovery_fixture(Some(1), false), &outcome) + .err() + .unwrap(); + assert!(error + .to_string() + .contains("target evaluation does not match")); +} + +#[test] +fn completed_recovery_returns_realizing_witness_with_or_without_map() { + let fixture = recovery_fixture(Some(1), false); + for aggregate_view in [ + None, + Some( + crate::rules::aggregate_view:: + as crate::rules::registry::AggregateViewFn, + ), + ] { + let steps = [RecoveryStep { + result: &fixture, + aggregate_view, + source: &fixture.target, + }]; + let (solution, evaluation) = + recover_completed_result(&steps, &fixture.target, &completed_cut()) + .unwrap() + .unwrap(); + assert_eq!( + *solution.downcast::>().unwrap(), + vec![false, true] + ); + assert_eq!(evaluation, "Max(1)"); + } +} From 280b510494afa739092cd1ce05b0ec2ef9fd0013 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:46:52 +0800 Subject: [PATCH 42/44] refactor: share witness validation and aggregate mapping boilerplate Co-Authored-By: Codex --- .claude/CLAUDE.md | 6 +- docs/src/design.md | 30 ++++++- problemreductions-macros/src/lib.rs | 87 ++++++++++++++++++- src/rules/acyclicpartition_ilp.rs | 26 ++---- .../balancedcompletebipartitesubgraph_ilp.rs | 26 ++---- src/rules/biconnectivityaugmentation_ilp.rs | 27 ++---- src/rules/bottlenecktravelingsalesman_ilp.rs | 13 ++- .../boundedcomponentspanningforest_ilp.rs | 26 ++---- src/rules/circuit_ilp.rs | 27 ++---- src/rules/circuit_sat.rs | 26 ++---- src/rules/circuit_spinglass.rs | 28 ++---- src/rules/clustering_ilp.rs | 26 ++---- src/rules/coloring_ilp.rs | 22 ++--- src/rules/coloring_qubo.rs | 27 ++---- src/rules/consecutiveblockminimization_ilp.rs | 26 ++---- .../consecutiveonesmatrixaugmentation_ilp.rs | 26 ++---- src/rules/consecutiveonessubmatrix_ilp.rs | 26 ++---- ...onsistencyofdatabasefrequencytables_ilp.rs | 26 ++---- ...ximumindependentset_integralflowbundles.rs | 26 ++---- ...imumdominatingset_minimumsummulticenter.rs | 25 ++---- ...nminimumdominatingset_minmaxmulticenter.rs | 25 ++---- ...onminimumvertexcover_hamiltoniancircuit.rs | 25 ++---- src/rules/directedhamiltonianpath_ilp.rs | 26 ++---- .../directedtwocommodityintegralflow_ilp.rs | 26 ++---- src/rules/disjointconnectingpaths_ilp.rs | 26 ++---- src/rules/eulerianpath_ilp.rs | 26 ++---- ...tcoverby3sets_algebraicequationsovergf2.rs | 26 ++---- ...overby3sets_boundeddiameterspanningtree.rs | 26 ++---- src/rules/exactcoverby3sets_ilp.rs | 26 ++---- .../exactcoverby3sets_maximumsetpacking.rs | 13 ++- .../exactcoverby3sets_minimumaxiomset.rs | 13 ++- ...verby3sets_minimumfaultdetectiontestset.rs | 13 ++- .../exactcoverby3sets_staffscheduling.rs | 26 ++---- src/rules/exactcoverby3sets_subsetproduct.rs | 26 ++---- src/rules/factoring_circuit.rs | 26 ++---- src/rules/factoring_ilp.rs | 26 ++---- src/rules/feasibleregisterassignment_ilp.rs | 26 ++---- src/rules/flowshopscheduling_ilp.rs | 26 ++---- ...oniancircuit_biconnectivityaugmentation.rs | 23 ++--- ...niancircuit_bottlenecktravelingsalesman.rs | 13 ++- .../hamiltoniancircuit_hamiltonianpath.rs | 26 ++---- .../hamiltoniancircuit_longestcircuit.rs | 28 ++---- .../hamiltoniancircuit_quadraticassignment.rs | 28 ++---- src/rules/hamiltoniancircuit_ruralpostman.rs | 28 ++---- src/rules/hamiltoniancircuit_stackercrane.rs | 28 ++---- ...ncircuit_strongconnectivityaugmentation.rs | 23 ++--- .../hamiltoniancircuit_travelingsalesman.rs | 13 ++- ...onianpath_degreeconstrainedspanningtree.rs | 23 ++--- src/rules/hamiltonianpath_ilp.rs | 26 ++---- .../hamiltonianpath_isomorphicspanningtree.rs | 26 ++---- ...onianpathbetweentwovertices_longestpath.rs | 28 ++---- src/rules/ilp_qubo.rs | 13 ++- src/rules/integralflowbundles_ilp.rs | 26 ++---- src/rules/integralflowhomologousarcs_ilp.rs | 26 ++---- src/rules/integralflowwithmultipliers_ilp.rs | 26 ++---- src/rules/isomorphicspanningtree_ilp.rs | 26 ++---- ...lique_balancedcompletebipartitesubgraph.rs | 26 ++---- src/rules/kclique_conjunctivebooleanquery.rs | 26 ++---- src/rules/kclique_ilp.rs | 26 ++---- src/rules/kclique_subgraphisomorphism.rs | 26 ++---- src/rules/kcoloring_bicliquecover.rs | 13 ++- src/rules/kcoloring_clustering.rs | 26 ++---- src/rules/kcoloring_partitionintocliques.rs | 26 ++---- ...kcoloring_twodimensionalconsecutivesets.rs | 26 ++---- src/rules/ksatisfiability_acyclicpartition.rs | 28 ++---- src/rules/ksatisfiability_bicliquecover.rs | 13 ++- src/rules/ksatisfiability_cyclicordering.rs | 28 ++---- ...tisfiability_decisionminimumvertexcover.rs | 28 ++---- ...bility_directedtwocommodityintegralflow.rs | 28 ++---- ...tisfiability_feasibleregisterassignment.rs | 28 ++---- src/rules/ksatisfiability_kclique.rs | 28 ++---- src/rules/ksatisfiability_kernel.rs | 28 ++---- .../ksatisfiability_monochromatictriangle.rs | 28 ++---- ...satisfiability_oneinthreesatisfiability.rs | 28 ++---- .../ksatisfiability_preemptivescheduling.rs | 13 ++- .../ksatisfiability_quadraticcongruences.rs | 28 ++---- ...fiability_quadraticdiophantineequations.rs | 28 ++---- src/rules/ksatisfiability_qubo.rs | 52 ++++------- .../ksatisfiability_registersufficiency.rs | 26 ++---- ...atisfiability_simultaneousincongruences.rs | 28 ++---- src/rules/ksatisfiability_subsetsum.rs | 28 ++---- src/rules/ksatisfiability_timetabledesign.rs | 26 ++---- src/rules/longestcircuit_ilp.rs | 28 ++---- ...bycliques_minimumintersectiongraphbasis.rs | 14 ++- ...minimumcodegenerationunlimitedregisters.rs | 13 ++- ...nimumvertexcover_comparativecontainment.rs | 26 ++---- src/rules/monochromatictriangle_ilp.rs | 26 ++---- src/rules/multiplechoicebranching_ilp.rs | 26 ++---- src/rules/multiprocessorscheduling_ilp.rs | 26 ++---- src/rules/naesatisfiability_ilp.rs | 26 ++---- src/rules/naesatisfiability_maxcut.rs | 28 ++---- ...fiability_partitionintoperfectmatchings.rs | 28 ++---- src/rules/naesatisfiability_setsplitting.rs | 26 ++---- ...atching_numericalmatchingwithtargetsums.rs | 26 ++---- .../numericalmatchingwithtargetsums_ilp.rs | 26 ++---- src/rules/openshopscheduling_ilp.rs | 28 ++---- ...ement_consecutiveonesmatrixaugmentation.rs | 23 ++--- src/rules/partition_binpacking.rs | 13 ++- .../partition_cosineproductintegration.rs | 26 ++---- .../partition_integralflowwithmultipliers.rs | 22 ++--- src/rules/partition_knapsack.rs | 13 ++- .../partition_multiprocessorscheduling.rs | 26 ++---- src/rules/partition_openshopscheduling.rs | 28 ++---- src/rules/partition_productionplanning.rs | 26 ++---- ...ion_sequencingtominimizetardytaskweight.rs | 25 ++---- src/rules/partition_subsetsum.rs | 26 ++---- src/rules/partition_sumofsquarespartition.rs | 13 ++- src/rules/partitionintocliques_ilp.rs | 26 ++---- ...ionintocliques_minimumcoveringbycliques.rs | 25 ++---- ...flength2_boundedcomponentspanningforest.rs | 26 ++---- src/rules/partitionintopathsoflength2_ilp.rs | 26 ++---- src/rules/partitionintotriangles_ilp.rs | 26 ++---- src/rules/pathconstrainednetworkflow_ilp.rs | 26 ++---- .../precedenceconstrainedscheduling_ilp.rs | 26 ++---- ...rizecollectingsteinerforest_steinertree.rs | 13 ++- .../rectilinearpicturecompression_ilp.rs | 26 ++---- src/rules/registersufficiency_ilp.rs | 26 ++---- .../resourceconstrainedscheduling_ilp.rs | 26 ++---- ...arrangement_rootedtreestorageassignment.rs | 25 ++---- src/rules/rootedtreestorageassignment_ilp.rs | 26 ++---- src/rules/sat_circuitsat.rs | 26 ++---- src/rules/sat_coloring.rs | 26 ++---- src/rules/sat_ksat.rs | 50 ++++------- src/rules/sat_maximumindependentset.rs | 28 ++---- src/rules/sat_minimumdominatingset.rs | 28 ++---- ...tisfiability_integralflowhomologousarcs.rs | 26 ++---- .../satisfiability_maximum2satisfiability.rs | 28 ++---- src/rules/satisfiability_naesatisfiability.rs | 26 ++---- src/rules/satisfiability_nontautology.rs | 26 ++---- .../schedulingwithindividualdeadlines_ilp.rs | 26 ++---- ...sequencingtominimizetardytaskweight_ilp.rs | 13 ++- ...quencingtominimizeweightedtardiness_ilp.rs | 26 ++---- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 26 ++---- src/rules/sequencingwithinintervals_ilp.rs | 26 ++---- ...uencingwithreleasetimesanddeadlines_ilp.rs | 26 ++---- src/rules/setsplitting_betweenness.rs | 26 ++---- src/rules/setsplitting_ilp.rs | 26 ++---- src/rules/sparsematrixcompression_ilp.rs | 26 ++---- src/rules/steinertree_ilp.rs | 14 ++- src/rules/stringtostringcorrection_ilp.rs | 26 ++---- .../strongconnectivityaugmentation_ilp.rs | 26 ++---- src/rules/subgraphisomorphism_ilp.rs | 26 ++---- src/rules/subsetsum_closestvectorproblem.rs | 28 ++---- .../subsetsum_integerexpressionmembership.rs | 26 ++---- src/rules/subsetsum_partition.rs | 26 ++---- src/rules/threedimensionalmatching_ilp.rs | 26 ++---- ...mensionalmatching_minimumweightdecoding.rs | 13 ++- ...threedimensionalmatching_threepartition.rs | 26 ++---- ...partition_resourceconstrainedscheduling.rs | 26 ++---- ..._sequencingwithreleasetimesanddeadlines.rs | 26 ++---- src/rules/timetabledesign_ilp.rs | 26 ++---- src/rules/traits.rs | 16 ++++ src/rules/travelingsalesman_qubo.rs | 20 ++--- src/rules/undirectedflowlowerbounds_ilp.rs | 26 ++---- .../undirectedtwocommodityintegralflow_ilp.rs | 26 ++---- src/unit_tests/rules/traits.rs | 55 ++++++++++++ 156 files changed, 1360 insertions(+), 2592 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 05c2894c2..44934dcf8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -166,9 +166,9 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `BruteForce::solve()` returns `Result, SolveError>`; `None` means exhaustive search proved infeasibility - `BruteForce::find_all_witnesses()` is a reference-testing helper for collecting every optimal or satisfying solution - `ReductionResult` provides `target_problem()` and `extract_solution()` for witness/config workflows; `AggregateReductionResult` provides `extract_value()` for aggregate/value workflows. Neither requires a rule-category tag. When both are registered, completed-result recovery borrows both mappings from the same constructed reduction. -- Register a completed-value mapping with `#[aggregate_reduction]` on its concrete `AggregateReductionResult` implementation. Generic implementations use `register_aggregate_reduction!(ResultType)` for each concrete result type. These register implementations, not rule categories. Read resolved edges through `reduction_entries()`, not raw inventory entries. -- Reduction chains expose solution and aggregate-value mappings, not solver outcomes. Every witness reduction preserves existence: source feasibility implies target feasibility. Established target or intermediate infeasibility propagates to the source without a value map or witness extraction. CLI execution coordinates mappings for feasible target results; callers establish optimality or infeasibility under their solver's numerical contract. A missing required mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. -- Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. +- Register a completed-value mapping with `#[aggregate_reduction]` on its concrete `AggregateReductionResult` implementation. Use `#[aggregate_reduction(identity)]` or `#[aggregate_reduction(ilp_feasibility)]` on an empty impl for identity or ILP-feasibility maps; the shorthand reuses the witness result's source, target, and target accessor. Generic implementations use `register_aggregate_reduction!(ResultType)` for each concrete result type. These register implementations, not rule categories. Read resolved edges through `reduction_entries()`, not raw inventory entries. +- Reduction chains expose solution and aggregate-value mappings and recover completed results through `ReductionChain::extract_result()`. Every witness reduction preserves existence: source feasibility implies target feasibility. Established target or intermediate infeasibility propagates to the source without a value map or witness extraction. Shared library recovery checks each mapped value against the extracted witness; callers establish optimality or infeasibility under their solver's numerical contract. A missing required mapping or failed witness extraction is an error, not proof of infeasibility. Counting and universal aggregates use `AggregateReductionChain::extract_value()` without a representative witness. +- Every direct `extract_solution()` must validate once before decoding, using `validate_target_solution()` or `validate_target_witness(target, solution, certifies_source, message)`. The latter evaluates once, applies the rule's feasibility predicate or value-map threshold, and returns a typed `ExtractionError` with the rule's rejection reason; composed extractors delegate validation to the first direct decoder. - Decision-equivalence rules map completed `Or` values identically. Decision-to-optimization rules own their feasibility/threshold map; reject target configurations that do not certify YES instead of returning an invalid source witness. Optimization rules decode optimal witnesses and evaluate the source; register a value map only when mathematically defined. Counting and universal rules map completed folds without witnesses. Follow [result mappings](../docs/src/design.md#result-mappings); no mandatory rule-category tags. - Decode only the reduction's defined mathematical mapping. Reject malformed structure with `ExtractionError`; never panic, truncate, clamp, invent defaults, or add recovery branches. Explicit mathematical alternatives and sentinels are allowed. Test successful decoding and every rejected representation. - CLI-facing dynamic formatting uses aggregate wrapper names directly (for example `Max(2)`, `Min(None)`, `Or(true)`, or `Sum(56)`) diff --git a/docs/src/design.md b/docs/src/design.md index fd22e6a95..4caa126b6 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -281,8 +281,13 @@ impl ReductionResult for ReductionISToVC { and returns the source configuration defined by the reduction. Extraction is a fallible boundary, not a recovery mechanism: -1. In every direct extractor, call `validate_target_solution()` once before - indexing or decoding. Composed extractors delegate this check. +1. In every direct extractor, validate once before indexing or decoding. + Use `validate_target_solution()` when decoding needs the evaluated value. + Use `validate_target_witness(target, solution, certifies_source, message)` + when the target must certify a source witness. It evaluates once, applies + the supplied predicate, and returns `ExtractionError` on rejection. + Keep the rule's feasibility check or value-map threshold in that predicate. + Composed extractors delegate this check. 2. For decision sources, reject an infeasible target value or a failed rule-owned feasibility threshold. Validate structure required by the inverse mapping, such as exactly-one blocks, permutations, paths, flows, or schedules. @@ -361,6 +366,27 @@ register concrete instances of generic implementations, including belong to the same graph edge and share its constructed result. Aggregate-only rules use `ReduceToAggregate`. +Common maps use an empty implementation: + +```rust,ignore +#[aggregate_reduction(identity)] +impl AggregateReductionResult for ReductionSATToKSAT {} + +#[aggregate_reduction(ilp_feasibility)] +impl AggregateReductionResult for ReductionNAESATToILP {} +``` + +The shorthand reuses the `ReductionResult` source, target, and target accessor. +`identity` returns the value unchanged; `ilp_feasibility` returns +`Or(value.value.is_some())`. Custom maps keep their explicit implementation. +Generic shorthand implementations still need `register_aggregate_reduction!` +for each concrete variant. + +`ReductionChain::extract_result()` recovers completed results using the same +library implementation as fixed ILP pipelines. Recovery borrows intermediate +instances from the executed chain and requires each extracted witness to +realize its mapped aggregate. + Every witness reduction must construct a feasible target whenever the source is feasible. Established target infeasibility therefore implies source infeasibility, without a witness or a value map. This also applies when an diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 11f5b86a8..8d9154c9f 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -227,12 +227,22 @@ pub fn reduction(attr: TokenStream, item: TokenStream) -> TokenStream { /// Register the completed-value mapping implemented by a reduction result. /// The result must also belong to a registered `ReduceTo` construction. +/// Use `#[aggregate_reduction(identity)]` or `#[aggregate_reduction(ilp_feasibility)]` +/// on an empty impl to generate a common mapping using its `ReductionResult` types. /// Register concrete instances of generic implementations with `register_aggregate_reduction!`. #[proc_macro_attribute] pub fn aggregate_reduction(attr: TokenStream, item: TokenStream) -> TokenStream { - parse_macro_input!(attr as syn::parse::Nothing); + let mapping = if attr.is_empty() { + None + } else { + Some(parse_macro_input!(attr as syn::Ident)) + }; let implementation = parse_macro_input!(item as ItemImpl); - match generate_aggregate_impl(&implementation) { + let generated = match mapping { + None => generate_aggregate_impl(&implementation), + Some(mapping) => generate_common_aggregate_impl(&implementation, &mapping), + }; + match generated { Ok(tokens) => tokens.into(), Err(error) => error.to_compile_error().into(), } @@ -245,7 +255,53 @@ pub fn register_aggregate_reduction(input: TokenStream) -> TokenStream { generate_aggregate_entry(&result).into() } -fn generate_aggregate_impl(implementation: &ItemImpl) -> syn::Result { +fn generate_common_aggregate_impl( + implementation: &ItemImpl, + mapping: &syn::Ident, +) -> syn::Result { + if !implementation.items.is_empty() { + return Err(syn::Error::new_spanned( + implementation, + "aggregate shorthand requires an empty impl", + )); + } + let body = match mapping.to_string().as_str() { + "identity" => quote! { value }, + "ilp_feasibility" => quote! { crate::types::Or(value.value.is_some()) }, + _ => { + return Err(syn::Error::new_spanned( + mapping, + "expected identity or ilp_feasibility", + )) + } + }; + let mut implementation = implementation.clone(); + let members: ItemImpl = syn::parse_quote! { + impl crate::rules::AggregateReductionResult for Placeholder { + type Source = ::Source; + type Target = ::Target; + fn target_problem(&self) -> &Self::Target { + ::target_problem(self) + } + fn extract_value( + &self, + value: ::Value, + ) -> ::Value { + #body + } + } + }; + implementation.items = members.items; + // Generic maps keep their explicit registrations for concrete variants. + if implementation.generics.params.is_empty() { + generate_aggregate_impl(&implementation) + } else { + validate_aggregate_trait(&implementation)?; + Ok(quote! { #implementation }) + } +} + +fn validate_aggregate_trait(implementation: &ItemImpl) -> syn::Result<()> { if !implementation.trait_.as_ref().is_some_and(|(path, _)| { path.segments .last() @@ -256,6 +312,11 @@ fn generate_aggregate_impl(implementation: &ItemImpl) -> syn::Result syn::Result { + validate_aggregate_trait(implementation)?; if !implementation.generics.params.is_empty() { return Err(syn::Error::new_spanned( implementation, @@ -1430,4 +1491,24 @@ mod tests { assert!(!tokens.contains("factory : None")); assert!(!tokens.contains("serialize_fn : None")); } + #[test] + fn aggregate_shorthand_validates_declarations() { + let concrete: ItemImpl = syn::parse_quote! { impl AggregateReductionResult for Mapping {} }; + for name in ["identity", "ilp_feasibility"] { + let mapping = syn::Ident::new(name, proc_macro2::Span::call_site()); + let generated = generate_common_aggregate_impl(&concrete, &mapping).unwrap(); + syn::parse2::(generated).unwrap(); + } + let generic: ItemImpl = + syn::parse_quote! { impl AggregateReductionResult for Mapping {} }; + let identity = syn::parse_quote!(identity); + let generated = generate_common_aggregate_impl(&generic, &identity).unwrap(); + // A generic declaration must leave concrete registration to the caller. + syn::parse2::(generated).unwrap(); + let nonempty: ItemImpl = syn::parse_quote! { impl AggregateReductionResult for Mapping { type Source = Source; } }; + assert!(generate_common_aggregate_impl(&nonempty, &identity).is_err()); + let wrong: ItemImpl = syn::parse_quote! { impl ReductionResult for Mapping {} }; + assert!(generate_common_aggregate_impl(&wrong, &identity).is_err()); + assert!(generate_common_aggregate_impl(&concrete, &syn::parse_quote!(unknown)).is_err()); + } } diff --git a/src/rules/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index 921db9a78..9baca7955 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -29,29 +29,19 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionAcyclicPartitionToILP { - type Source = AcyclicPartition; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionAcyclicPartitionToILP {} #[reduction( transform = exact { diff --git a/src/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/rules/balancedcompletebipartitesubgraph_ilp.rs index 565dbdce5..5857dfc7e 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -28,13 +28,12 @@ impl ReductionResult for ReductionBCBSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution[..self.num_vertices] .iter() @@ -43,17 +42,8 @@ impl ReductionResult for ReductionBCBSToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionBCBSToILP { - type Source = BalancedCompleteBipartiteSubgraph; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionBCBSToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index 761a95ce4..37e43cae5 100644 --- a/src/rules/biconnectivityaugmentation_ilp.rs +++ b/src/rules/biconnectivityaugmentation_ilp.rs @@ -58,14 +58,12 @@ impl ReductionResult for ReductionBiconnAugToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .value - .is_none() - { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution[..self.num_candidates] .iter() @@ -74,17 +72,8 @@ impl ReductionResult for ReductionBiconnAugToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionBiconnAugToILP { - type Source = BiconnectivityAugmentation; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionBiconnAugToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/bottlenecktravelingsalesman_ilp.rs b/src/rules/bottlenecktravelingsalesman_ilp.rs index 04b268a97..50b2e2146 100644 --- a/src/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/rules/bottlenecktravelingsalesman_ilp.rs @@ -27,13 +27,12 @@ impl ReductionResult for ReductionBTSPToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.is_valid() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.is_valid(), + "target ILP assignment is infeasible", + )?; let n = self.num_vertices; Ok((0..self.num_edges) .map(|edge| { diff --git a/src/rules/boundedcomponentspanningforest_ilp.rs b/src/rules/boundedcomponentspanningforest_ilp.rs index 21f1b350e..3a8242872 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -31,29 +31,19 @@ impl ReductionResult for ReductionBCSFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode_rows(target_solution, self.n, self.k, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionBCSFToILP { - type Source = BoundedComponentSpanningForest; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionBCSFToILP {} #[reduction( transform = exact { diff --git a/src/rules/circuit_ilp.rs b/src/rules/circuit_ilp.rs index 785fafb20..bcde39553 100644 --- a/src/rules/circuit_ilp.rs +++ b/src/rules/circuit_ilp.rs @@ -40,14 +40,12 @@ impl ReductionResult for ReductionCircuitToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .value - .is_none() - { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ self.source_variables @@ -193,17 +191,8 @@ impl ILPBuilder { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionCircuitToILP { - type Source = CircuitSAT; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCircuitToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/circuit_sat.rs b/src/rules/circuit_sat.rs index 8a7fca926..4030b23ca 100644 --- a/src/rules/circuit_sat.rs +++ b/src/rules/circuit_sat.rs @@ -293,29 +293,19 @@ impl ReductionResult for ReductionCircuitSATToSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution[..self.source_var_count].to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionCircuitSATToSAT { - type Source = CircuitSAT; - type Target = Satisfiability; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionCircuitSATToSAT {} #[reduction( transform = unavailable { diff --git a/src/rules/circuit_spinglass.rs b/src/rules/circuit_spinglass.rs index 570aa2c2d..3acda5b81 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -229,13 +229,12 @@ impl ReductionResult for ReductionCircuitToSG { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "SpinGlass energy does not meet the circuit zero-penalty threshold", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "SpinGlass energy does not meet the circuit zero-penalty threshold", + )?; Ok(self .source_variables @@ -245,19 +244,8 @@ impl ReductionResult for ReductionCircuitToSG { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionCircuitToSG { - type Source = CircuitSAT; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionCircuitToSG {} /// Builder for constructing the combined SpinGlass from circuit gadgets. struct SpinGlassBuilder { diff --git a/src/rules/clustering_ilp.rs b/src/rules/clustering_ilp.rs index d9df792c6..00b2e91ee 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -30,13 +30,12 @@ impl ReductionResult for ReductionClusteringToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -47,17 +46,8 @@ impl ReductionResult for ReductionClusteringToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionClusteringToILP { - type Source = Clustering; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionClusteringToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/coloring_ilp.rs b/src/rules/coloring_ilp.rs index 65f06b31e..6dfcf29cc 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -48,13 +48,12 @@ where &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode_rows(target_solution, self.num_vertices, self.num_colors, 0) } @@ -107,17 +106,10 @@ fn reduce_kcoloring_to_ilp( crate::register_aggregate_reduction!(ReductionKColoringToILP); +#[crate::aggregate_reduction(ilp_feasibility)] impl crate::rules::AggregateReductionResult for ReductionKColoringToILP { - type Source = KColoring; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } } // Register only the KN variant in the reduction graph diff --git a/src/rules/coloring_qubo.rs b/src/rules/coloring_qubo.rs index 876d0fcb3..dfb7d2a32 100644 --- a/src/rules/coloring_qubo.rs +++ b/src/rules/coloring_qubo.rs @@ -38,13 +38,12 @@ impl ReductionResult for ReductionKColoringToQUBO { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target QUBO configuration does not certify a proper coloring", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target QUBO configuration does not certify a proper coloring", + )?; (0..self.num_vertices) .map(|vertex| { @@ -66,18 +65,8 @@ impl ReductionResult for ReductionKColoringToQUBO { crate::register_aggregate_reduction!(ReductionKColoringToQUBO); -impl crate::rules::AggregateReductionResult for ReductionKColoringToQUBO { - type Source = KColoring; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToQUBO {} /// Check dimensions and the omitted constant before allocating the matrix. fn coloring_qubo_parameters( diff --git a/src/rules/consecutiveblockminimization_ilp.rs b/src/rules/consecutiveblockminimization_ilp.rs index 30f6ce0ce..f61d3e4bb 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -28,29 +28,19 @@ impl ReductionResult for ReductionCBMToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionCBMToILP { - type Source = ConsecutiveBlockMinimization; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCBMToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs index 44827dae4..999a91899 100644 --- a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -29,29 +29,19 @@ impl ReductionResult for ReductionCOMAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionCOMAToILP { - type Source = ConsecutiveOnesMatrixAugmentation; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCOMAToILP {} #[reduction( transform = exact { diff --git a/src/rules/consecutiveonessubmatrix_ilp.rs b/src/rules/consecutiveonessubmatrix_ilp.rs index c1187d90a..a1affc72d 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -26,13 +26,12 @@ impl ReductionResult for ReductionCOSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ // Output the selection bits s_c (first num_cols variables) @@ -44,17 +43,8 @@ impl ReductionResult for ReductionCOSToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionCOSToILP { - type Source = ConsecutiveOnesSubmatrix; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCOSToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/rules/consistencyofdatabasefrequencytables_ilp.rs index 48fd15e1d..7d4d095a9 100644 --- a/src/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -95,13 +95,12 @@ impl ReductionResult for ReductionCDFTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let mut source_solution = Vec::with_capacity(self.source.num_assignment_variables()); @@ -133,17 +132,8 @@ impl ReductionResult for ReductionCDFTToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionCDFTToILP { - type Source = ConsistencyOfDatabaseFrequencyTables; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCDFTToILP {} #[reduction( transform = exact { diff --git a/src/rules/decisionmaximumindependentset_integralflowbundles.rs b/src/rules/decisionmaximumindependentset_integralflowbundles.rs index f0d631387..3f367ec5d 100644 --- a/src/rules/decisionmaximumindependentset_integralflowbundles.rs +++ b/src/rules/decisionmaximumindependentset_integralflowbundles.rs @@ -31,13 +31,12 @@ impl ReductionResult for ReductionDecisionMISToIFB { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let feasible = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !feasible.0 { - return Err(crate::rules::ExtractionError::invalid( - "target flow must satisfy conservation, bundle capacities, and the requirement", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |feasible| feasible.0, + "target flow must satisfy conservation, bundle capacities, and the requirement", + )?; Ok((0..self.num_source_vertices) .map(|i| target_solution[2 * i + 1] == 1) .collect()) @@ -76,17 +75,8 @@ fn flow_requirement(n: usize, bound: i64) -> Result>; - type Target = IntegralFlowBundles; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionDecisionMISToIFB {} #[reduction( transform = exact { diff --git a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs index d644f4b72..73e768287 100644 --- a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -31,32 +31,21 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMultic &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target placement does not certify a dominating set within the source bound", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target placement does not certify a dominating set within the source bound", + )?; // Original vertices precede the auxiliary isolated vertices. Ok(target_solution[..self.source_num_vertices].to_vec()) } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { - type Source = Decision>; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs index fc89de810..ac4b58d98 100644 --- a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -30,31 +30,20 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticente &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target placement does not certify a dominating set: radius must be at most one", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target placement does not certify a dominating set: radius must be at most one", + )?; Ok(target_solution[..self.source_num_vertices].to_vec()) } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { - type Source = Decision>; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 5d47f1f48..e2cea1103 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -256,13 +256,12 @@ impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not a Hamiltonian circuit", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target configuration is not a Hamiltonian circuit", + )?; Ok({ match &self.construction { @@ -295,20 +294,10 @@ fn insert_edge(edges: &mut BTreeSet<(usize, usize)>, a: usize, b: usize) { edges.insert(edge); } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { - type Source = Decision>; - type Target = HamiltonianCircuit; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/directedhamiltonianpath_ilp.rs b/src/rules/directedhamiltonianpath_ilp.rs index a5a2c877c..d201e5bb2 100644 --- a/src/rules/directedhamiltonianpath_ilp.rs +++ b/src/rules/directedhamiltonianpath_ilp.rs @@ -34,13 +34,12 @@ impl ReductionResult for ReductionDirectedHamiltonianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let n = self.num_vertices; @@ -51,17 +50,8 @@ impl ReductionResult for ReductionDirectedHamiltonianPathToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionDirectedHamiltonianPathToILP { - type Source = DirectedHamiltonianPath; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDirectedHamiltonianPathToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/directedtwocommodityintegralflow_ilp.rs b/src/rules/directedtwocommodityintegralflow_ilp.rs index e5579ce1b..ff9089ec1 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -41,29 +41,19 @@ impl ReductionResult for ReductionD2CIFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(&target_solution[..2 * self.num_arcs]) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionD2CIFToILP { - type Source = DirectedTwoCommodityIntegralFlow; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionD2CIFToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/disjointconnectingpaths_ilp.rs b/src/rules/disjointconnectingpaths_ilp.rs index 0fc8ae6d0..7dfdbed51 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -40,13 +40,12 @@ impl ReductionResult for ReductionDCPToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; let mut result = vec![false; self.edges.len()]; for (k, &(source, sink)) in self.terminal_pairs.iter().enumerate() { @@ -91,17 +90,8 @@ impl ReductionResult for ReductionDCPToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionDCPToILP { - type Source = DisjointConnectingPaths; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDCPToILP {} #[reduction( transform = exact { diff --git a/src/rules/eulerianpath_ilp.rs b/src/rules/eulerianpath_ilp.rs index 1aa646d24..5543806d8 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -74,13 +74,12 @@ impl ReductionResult for ReductionEulerianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let m = self.num_arcs; @@ -145,17 +144,8 @@ fn compatible_pairs(arcs: &[(usize, usize)]) -> Vec<(usize, usize)> { pairs } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionEulerianPathToILP { - type Source = EulerianPath; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionEulerianPathToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs index 0946e026b..b177223cf 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -22,29 +22,19 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { - type Source = ExactCoverBy3Sets; - type Target = AlgebraicEquationsOverGF2; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 {} #[reduction( transform = upper_bound { diff --git a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index 92b1ca71d..b3459a9e4 100644 --- a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -98,13 +98,12 @@ impl ReductionResult for ReductionX3CToBoundedDiameterSpanningTree { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target edge selection is not a feasible bounded-diameter spanning tree", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target edge selection is not a feasible bounded-diameter spanning tree", + )?; Ok({ let m = self.source_num_subsets; @@ -116,17 +115,8 @@ impl ReductionResult for ReductionX3CToBoundedDiameterSpanningTree { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionX3CToBoundedDiameterSpanningTree { - type Source = ExactCoverBy3Sets; - type Target = BoundedDiameterSpanningTree; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionX3CToBoundedDiameterSpanningTree {} #[reduction( transform = upper_bound { diff --git a/src/rules/exactcoverby3sets_ilp.rs b/src/rules/exactcoverby3sets_ilp.rs index 06ccac157..2a7ea358d 100644 --- a/src/rules/exactcoverby3sets_ilp.rs +++ b/src/rules/exactcoverby3sets_ilp.rs @@ -25,29 +25,19 @@ impl ReductionResult for ReductionX3CToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution.iter().map(|&value| value == 1).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionX3CToILP { - type Source = ExactCoverBy3Sets; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionX3CToILP {} #[reduction( transform = exact { diff --git a/src/rules/exactcoverby3sets_maximumsetpacking.rs b/src/rules/exactcoverby3sets_maximumsetpacking.rs index 57350fb32..8aa93e9c5 100644 --- a/src/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/rules/exactcoverby3sets_maximumsetpacking.rs @@ -34,13 +34,12 @@ impl ReductionResult for ReductionXC3SToMaximumSetPacking { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; Ok(target_solution.to_vec()) } diff --git a/src/rules/exactcoverby3sets_minimumaxiomset.rs b/src/rules/exactcoverby3sets_minimumaxiomset.rs index 32fb10094..023075498 100644 --- a/src/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/rules/exactcoverby3sets_minimumaxiomset.rs @@ -33,13 +33,12 @@ impl ReductionResult for ReductionXC3SToMinimumAxiomSet { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; Ok({ let set_offset = self.source_universe_size; diff --git a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index 27f496f7f..bc8ff97fd 100644 --- a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -29,13 +29,12 @@ impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; if self.source_universe_size == 0 { return Ok(vec![]); diff --git a/src/rules/exactcoverby3sets_staffscheduling.rs b/src/rules/exactcoverby3sets_staffscheduling.rs index 744f23af3..c54084d5d 100644 --- a/src/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/rules/exactcoverby3sets_staffscheduling.rs @@ -37,29 +37,19 @@ impl ReductionResult for ReductionXC3SToStaffScheduling { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution.iter().map(|&count| count > 0).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionXC3SToStaffScheduling { - type Source = ExactCoverBy3Sets; - type Target = StaffScheduling; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionXC3SToStaffScheduling {} #[reduction( transform = exact { diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index 61906ca0d..1e902948b 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -30,13 +30,12 @@ impl ReductionResult for ReductionX3CToSubsetProduct { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution.to_vec()) } @@ -64,17 +63,8 @@ fn assigned_primes(universe_size: usize) -> Vec { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionX3CToSubsetProduct { - type Source = ExactCoverBy3Sets; - type Target = SubsetProduct; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionX3CToSubsetProduct {} #[reduction( transform = exact { diff --git a/src/rules/factoring_circuit.rs b/src/rules/factoring_circuit.rs index 21c57590a..ddbe91027 100644 --- a/src/rules/factoring_circuit.rs +++ b/src/rules/factoring_circuit.rs @@ -47,13 +47,12 @@ impl ReductionResult for ReductionFactoringToCircuit { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment does not satisfy the multiplication circuit", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment does not satisfy the multiplication circuit", + )?; Ok({ let var_names = self.target.variable_names(); @@ -212,17 +211,8 @@ fn build_multiplier_cell( (assignments, ancillas) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionFactoringToCircuit { - type Source = Factoring; - type Target = CircuitSAT; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionFactoringToCircuit {} #[reduction( transform = upper_bound { diff --git a/src/rules/factoring_ilp.rs b/src/rules/factoring_ilp.rs index b71ef7adb..f7e6fe5fb 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -80,13 +80,12 @@ impl ReductionResult for ReductionFactoringToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ // Extract p bits (first factor) @@ -111,17 +110,8 @@ impl ReductionResult for ReductionFactoringToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionFactoringToILP { - type Source = Factoring; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionFactoringToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/feasibleregisterassignment_ilp.rs b/src/rules/feasibleregisterassignment_ilp.rs index d88a2b783..20ea224a6 100644 --- a/src/rules/feasibleregisterassignment_ilp.rs +++ b/src/rules/feasibleregisterassignment_ilp.rs @@ -33,29 +33,19 @@ impl ReductionResult for ReductionFeasibleRegisterAssignmentToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(&target_solution[..self.num_vertices]) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionFeasibleRegisterAssignmentToILP { - type Source = FeasibleRegisterAssignment; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionFeasibleRegisterAssignmentToILP {} #[reduction( transform = exact { diff --git a/src/rules/flowshopscheduling_ilp.rs b/src/rules/flowshopscheduling_ilp.rs index a35457c13..8bbce62e5 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -41,13 +41,12 @@ impl ReductionResult for ReductionFSSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let n = self.num_jobs; @@ -69,17 +68,8 @@ impl ReductionResult for ReductionFSSToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionFSSToILP { - type Source = FlowShopScheduling; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionFSSToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index 53e9b6eea..654ee26f5 100644 --- a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -51,13 +51,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target augmentation is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target augmentation is infeasible", + )?; Ok({ let n = self.num_vertices; @@ -117,18 +116,10 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation { - type Source = HamiltonianCircuit; - type Target = BiconnectivityAugmentation; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index e9f03e42c..8d2e27df1 100644 --- a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -27,13 +27,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToBottleneckTravelingSalesma &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } diff --git a/src/rules/hamiltoniancircuit_hamiltonianpath.rs b/src/rules/hamiltoniancircuit_hamiltonianpath.rs index 1abd25bf4..06bd1b027 100644 --- a/src/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -40,13 +40,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok({ let n = self.num_original_vertices; @@ -84,17 +83,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { - type Source = HamiltonianCircuit; - type Target = HamiltonianPath; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToHamiltonianPath {} #[reduction( transform = upper_bound { diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs index fa1372314..03ab5871c 100644 --- a/src/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -28,13 +28,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target circuit does not certify a Hamiltonian circuit", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target circuit does not certify a Hamiltonian circuit", + )?; crate::rules::graph_helpers::edges_to_cycle_order( self.target.inner().graph(), @@ -43,19 +42,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLongestCircuit { - type Source = HamiltonianCircuit; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLongestCircuit {} #[reduction( transform = exact { diff --git a/src/rules/hamiltoniancircuit_quadraticassignment.rs b/src/rules/hamiltoniancircuit_quadraticassignment.rs index db71da71d..37a43e35f 100644 --- a/src/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -30,32 +30,20 @@ impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment does not certify a Hamiltonian circuit", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment does not certify a Hamiltonian circuit", + )?; // Zero cost makes this permutation itself a Hamiltonian circuit. Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { - type Source = HamiltonianCircuit; - type Target = Decision; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment {} #[reduction( transform = upper_bound { diff --git a/src/rules/hamiltoniancircuit_ruralpostman.rs b/src/rules/hamiltoniancircuit_ruralpostman.rs index 28ab7cb5d..1ea99e400 100644 --- a/src/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -51,13 +51,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not certify a YES answer for the source", + )?; Ok({ // The target solution is edge multiplicities. @@ -110,19 +109,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToRuralPostman { - type Source = HamiltonianCircuit; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToRuralPostman {} #[reduction( transform = exact { diff --git a/src/rules/hamiltoniancircuit_stackercrane.rs b/src/rules/hamiltoniancircuit_stackercrane.rs index 15bdf65a6..b98b6f720 100644 --- a/src/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -37,31 +37,19 @@ impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target tour does not certify a Hamiltonian circuit", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target tour does not certify a Hamiltonian circuit", + )?; // Service arc i corresponds to source vertex i. Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToStackerCrane { - type Source = HamiltonianCircuit; - type Target = Decision; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToStackerCrane {} #[reduction( transform = exact { diff --git a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index d15b7724e..089159a34 100644 --- a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -31,13 +31,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok({ let n = self.n; @@ -80,18 +79,10 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmentation { - type Source = HamiltonianCircuit; - type Target = StrongConnectivityAugmentation; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/hamiltoniancircuit_travelingsalesman.rs b/src/rules/hamiltoniancircuit_travelingsalesman.rs index a95ddefd9..989b2caa2 100644 --- a/src/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -27,13 +27,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } diff --git a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index bdf2d2838..26bfe460f 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -25,30 +25,21 @@ impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; extract_hamiltonian_order(self.target.graph(), target_solution) } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree { - type Source = HamiltonianPath; - type Target = DegreeConstrainedSpanningTree; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index 3e155b67f..2cd1e8c0d 100644 --- a/src/rules/hamiltonianpath_ilp.rs +++ b/src/rules/hamiltonianpath_ilp.rs @@ -39,29 +39,19 @@ impl ReductionResult for ReductionHamiltonianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode(target_solution, self.num_vertices, self.num_vertices, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHamiltonianPathToILP { - type Source = HamiltonianPath; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianPathToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/rules/hamiltonianpath_isomorphicspanningtree.rs index 18ab0b1ea..f37750098 100644 --- a/src/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -32,29 +32,19 @@ impl ReductionResult for ReductionHPToIST { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHPToIST { - type Source = HamiltonianPath; - type Target = IsomorphicSpanningTree; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHPToIST {} #[reduction( transform = exact { diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index cfe9b9197..fd68c11c7 100644 --- a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -34,13 +34,12 @@ impl ReductionResult for ReductionHPBTVToLP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target path does not certify a Hamiltonian source-target path", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target path does not certify a Hamiltonian source-target path", + )?; let mut adjacency = vec![Vec::new(); self.target.inner().num_vertices()]; for (&selected, (u, v)) in target_solution @@ -72,19 +71,8 @@ impl ReductionResult for ReductionHPBTVToLP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP { - type Source = HamiltonianPathBetweenTwoVertices; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP {} #[reduction( transform = exact { diff --git a/src/rules/ilp_qubo.rs b/src/rules/ilp_qubo.rs index 283212873..ca826cec4 100644 --- a/src/rules/ilp_qubo.rs +++ b/src/rules/ilp_qubo.rs @@ -39,13 +39,12 @@ impl ReductionResult for ReductionILPToQUBO { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).is_valid() { - return Err(crate::rules::ExtractionError::invalid( - "target QUBO configuration does not certify a feasible ILP assignment", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).is_valid(), + "target QUBO configuration does not certify a feasible ILP assignment", + )?; Ok(target_solution[..self.num_original_vars] .iter() diff --git a/src/rules/integralflowbundles_ilp.rs b/src/rules/integralflowbundles_ilp.rs index b915db678..e9d4ee032 100644 --- a/src/rules/integralflowbundles_ilp.rs +++ b/src/rules/integralflowbundles_ilp.rs @@ -27,29 +27,19 @@ impl ReductionResult for ReductionIFBToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(target_solution) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionIFBToILP { - type Source = IntegralFlowBundles; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionIFBToILP {} #[reduction( transform = exact { diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 9aa8fcadb..502e80405 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -26,29 +26,19 @@ impl ReductionResult for ReductionIFHAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(target_solution) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionIFHAToILP { - type Source = IntegralFlowHomologousArcs; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionIFHAToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/integralflowwithmultipliers_ilp.rs b/src/rules/integralflowwithmultipliers_ilp.rs index 220bf1229..780e28dfb 100644 --- a/src/rules/integralflowwithmultipliers_ilp.rs +++ b/src/rules/integralflowwithmultipliers_ilp.rs @@ -26,29 +26,19 @@ impl ReductionResult for ReductionIFWMToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(target_solution) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionIFWMToILP { - type Source = IntegralFlowWithMultipliers; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionIFWMToILP {} #[reduction( transform = exact { diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index 726950e25..b86657b1f 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -28,29 +28,19 @@ impl ReductionResult for ReductionISTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionISTToILP { - type Source = IsomorphicSpanningTree; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionISTToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/rules/kclique_balancedcompletebipartitesubgraph.rs index 61a36b76a..af52a2233 100644 --- a/src/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -38,13 +38,12 @@ impl ReductionResult for ReductionKCliqueToBCBS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok({ (0..self.num_original_vertices) @@ -54,17 +53,8 @@ impl ReductionResult for ReductionKCliqueToBCBS { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKCliqueToBCBS { - type Source = KClique; - type Target = BalancedCompleteBipartiteSubgraph; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToBCBS {} #[reduction( transform = upper_bound { diff --git a/src/rules/kclique_conjunctivebooleanquery.rs b/src/rules/kclique_conjunctivebooleanquery.rs index b3bac6f04..8513daf61 100644 --- a/src/rules/kclique_conjunctivebooleanquery.rs +++ b/src/rules/kclique_conjunctivebooleanquery.rs @@ -38,13 +38,12 @@ impl ReductionResult for ReductionKCliqueToCBQ { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(KClique::::config_from_vertices( self.num_vertices, @@ -53,17 +52,8 @@ impl ReductionResult for ReductionKCliqueToCBQ { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKCliqueToCBQ { - type Source = KClique; - type Target = ConjunctiveBooleanQuery; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToCBQ {} #[reduction( transform = exact { diff --git a/src/rules/kclique_ilp.rs b/src/rules/kclique_ilp.rs index 058fadbda..4ce5c4cf7 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -43,29 +43,19 @@ impl ReductionResult for ReductionKCliqueToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution.iter().map(|&value| value == 1).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKCliqueToILP { - type Source = KClique; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/kclique_subgraphisomorphism.rs b/src/rules/kclique_subgraphisomorphism.rs index 4b7b3835e..a34b08bd9 100644 --- a/src/rules/kclique_subgraphisomorphism.rs +++ b/src/rules/kclique_subgraphisomorphism.rs @@ -38,13 +38,12 @@ impl ReductionResult for ReductionKCliqueToSubIso { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(KClique::::config_from_vertices( self.num_source_vertices, @@ -53,17 +52,8 @@ impl ReductionResult for ReductionKCliqueToSubIso { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKCliqueToSubIso { - type Source = KClique; - type Target = SubgraphIsomorphism; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToSubIso {} #[reduction( transform = exact { diff --git a/src/rules/kcoloring_bicliquecover.rs b/src/rules/kcoloring_bicliquecover.rs index 9c9a27606..054be2d7e 100644 --- a/src/rules/kcoloring_bicliquecover.rs +++ b/src/rules/kcoloring_bicliquecover.rs @@ -72,13 +72,12 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.0.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not a biclique cover", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0.is_some(), + "target configuration is not a biclique cover", + )?; Ok({ let n = self.num_vertices; diff --git a/src/rules/kcoloring_clustering.rs b/src/rules/kcoloring_clustering.rs index 84fb89954..bbe67ad67 100644 --- a/src/rules/kcoloring_clustering.rs +++ b/src/rules/kcoloring_clustering.rs @@ -32,13 +32,12 @@ impl ReductionResult for ReductionKColoringToClustering { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution[..self.source_num_vertices].to_vec()) } @@ -58,17 +57,8 @@ fn build_distances(graph: &SimpleGraph) -> Vec> { distances } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKColoringToClustering { - type Source = KColoring; - type Target = Clustering; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToClustering {} #[reduction( transform = upper_bound { diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs index ac8dd670c..4ced2f8f5 100644 --- a/src/rules/kcoloring_partitionintocliques.rs +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -30,29 +30,19 @@ impl ReductionResult for ReductionKColoringToPartitionIntoCliques { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target witness is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution[..self.source_num_vertices].to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKColoringToPartitionIntoCliques { - type Source = KColoring; - type Target = PartitionIntoCliques; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToPartitionIntoCliques {} #[reduction( transform = upper_bound { diff --git a/src/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs index 3ae7884ec..4f15cee3c 100644 --- a/src/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -45,13 +45,12 @@ impl ReductionResult for ReductionKColoringToTDCS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target grouping is not a consecutive-set partition", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target grouping is not a consecutive-set partition", + )?; Ok({ // The target solution is config[symbol] = group_index. @@ -78,17 +77,8 @@ impl ReductionResult for ReductionKColoringToTDCS { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKColoringToTDCS { - type Source = KColoring; - type Target = TwoDimensionalConsecutiveSets; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToTDCS {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_acyclicpartition.rs b/src/rules/ksatisfiability_acyclicpartition.rs index 8aee3c217..d88ce5861 100644 --- a/src/rules/ksatisfiability_acyclicpartition.rs +++ b/src/rules/ksatisfiability_acyclicpartition.rs @@ -33,13 +33,12 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target partition does not satisfy the acyclic partition constraints", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target partition does not satisfy the acyclic partition constraints", + )?; let source_label = target_solution[self.source_vertex]; let selected = target_solution[..self.sat_to_clique.target_problem().num_vertices()] .iter() @@ -49,19 +48,8 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToAcyclicPartition { - type Source = KSatisfiability; - type Target = AcyclicPartition; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToAcyclicPartition {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_bicliquecover.rs b/src/rules/ksatisfiability_bicliquecover.rs index 504eb2853..dec116ab8 100644 --- a/src/rules/ksatisfiability_bicliquecover.rs +++ b/src/rules/ksatisfiability_bicliquecover.rs @@ -94,13 +94,12 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.0.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not a biclique cover", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0.is_some(), + "target configuration is not a biclique cover", + )?; // Variables absent from every clause may be assigned false. // This also defines the inverse map for the empty-formula YES target. let mut source_assignment = vec![false; self.source_num_vars]; diff --git a/src/rules/ksatisfiability_cyclicordering.rs b/src/rules/ksatisfiability_cyclicordering.rs index f7f810c34..313728a7e 100644 --- a/src/rules/ksatisfiability_cyclicordering.rs +++ b/src/rules/ksatisfiability_cyclicordering.rs @@ -43,13 +43,12 @@ impl ReductionResult for Reduction3SATToCyclicOrdering { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not a feasible cyclic ordering", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target configuration is not a feasible cyclic ordering", + )?; let mut assignment = vec![false; self.source_num_vars]; for (compact, &original) in self.source_variables.iter().enumerate() { let (alpha, beta, gamma) = variable_triple(compact); @@ -162,19 +161,8 @@ fn normalize( }) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToCyclicOrdering { - type Source = KSatisfiability; - type Target = CyclicOrdering; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToCyclicOrdering {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/rules/ksatisfiability_decisionminimumvertexcover.rs index 5d353b511..9e355797a 100644 --- a/src/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -46,13 +46,12 @@ impl ReductionResult for Reduction3SATToDecisionMVC { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not certify a YES answer for the source", + )?; Ok({ (0..self.source_num_vars) @@ -65,19 +64,8 @@ impl ReductionResult for Reduction3SATToDecisionMVC { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToDecisionMVC { - type Source = KSatisfiability; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToDecisionMVC {} #[reduction( transform = exact { diff --git a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs index 206183ecd..655396bd3 100644 --- a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -170,13 +170,12 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ self.variable_paths @@ -187,19 +186,8 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { - type Source = KSatisfiability; - type Target = DirectedTwoCommodityIntegralFlow; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow {} #[reduction( transform = exact { diff --git a/src/rules/ksatisfiability_feasibleregisterassignment.rs b/src/rules/ksatisfiability_feasibleregisterassignment.rs index 837dbc233..0d2e15fa4 100644 --- a/src/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/rules/ksatisfiability_feasibleregisterassignment.rs @@ -78,13 +78,12 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not a feasible register assignment realization", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target configuration is not a feasible register assignment realization", + )?; let mut assignment = vec![false; self.num_vars]; let compact_vars = self.source_variables.len(); for (compact, &original) in self.source_variables.iter().enumerate() { @@ -95,19 +94,8 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToFeasibleRegisterAssignment { - type Source = KSatisfiability; - type Target = FeasibleRegisterAssignment; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToFeasibleRegisterAssignment {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_kclique.rs b/src/rules/ksatisfiability_kclique.rs index 7b63b5a2a..79dc24313 100644 --- a/src/rules/ksatisfiability_kclique.rs +++ b/src/rules/ksatisfiability_kclique.rs @@ -33,13 +33,12 @@ impl ReductionResult for Reduction3SATToKClique { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "target selection is not a clique meeting the threshold", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target selection is not a clique meeting the threshold", + )?; // Variables absent from the selected literals are free; choose false. let mut assignment = vec![false; self.source_num_vars]; for (&selected, &(variable, positive)) in target_solution[..self.literal_assignments.len()] @@ -54,19 +53,8 @@ impl ReductionResult for Reduction3SATToKClique { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToKClique { - type Source = KSatisfiability; - type Target = KClique; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToKClique {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_kernel.rs b/src/rules/ksatisfiability_kernel.rs index 01a844bb0..6ecd8958e 100644 --- a/src/rules/ksatisfiability_kernel.rs +++ b/src/rules/ksatisfiability_kernel.rs @@ -33,13 +33,12 @@ impl ReductionResult for Reduction3SatToKernel { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target vertex selection is not a kernel", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target vertex selection is not a kernel", + )?; let mut assignment = vec![false; self.source_num_vars]; for (compact, &original) in self.source_variables.iter().enumerate() { assignment[original] = target_solution[2 * compact]; @@ -48,19 +47,8 @@ impl ReductionResult for Reduction3SatToKernel { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SatToKernel { - type Source = KSatisfiability; - type Target = Kernel; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SatToKernel {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_monochromatictriangle.rs b/src/rules/ksatisfiability_monochromatictriangle.rs index d0f8fa8db..d1c16cfcd 100644 --- a/src/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/rules/ksatisfiability_monochromatictriangle.rs @@ -55,13 +55,12 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; let nae_solution = (0..self.nae_reduction.target_problem().num_vars()) .map(|index| target_solution[2 * index]) .collect(); @@ -71,19 +70,8 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToMonochromaticTriangle { - type Source = KSatisfiability; - type Target = MonochromaticTriangle; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToMonochromaticTriangle {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_oneinthreesatisfiability.rs b/src/rules/ksatisfiability_oneinthreesatisfiability.rs index 3fd9243b7..711f2f42c 100644 --- a/src/rules/ksatisfiability_oneinthreesatisfiability.rs +++ b/src/rules/ksatisfiability_oneinthreesatisfiability.rs @@ -31,13 +31,12 @@ impl ReductionResult for Reduction3SATToOneInThreeSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment does not satisfy every one-in-three clause", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment does not satisfy every one-in-three clause", + )?; let mut assignment = vec![false; self.source_num_vars]; for (compact, &original) in self.source_variables.iter().enumerate() { assignment[original] = target_solution[compact]; @@ -46,19 +45,8 @@ impl ReductionResult for Reduction3SATToOneInThreeSAT { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToOneInThreeSAT { - type Source = KSatisfiability; - type Target = OneInThreeSatisfiability; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToOneInThreeSAT {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index f958636c0..2d651e5f1 100644 --- a/src/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/rules/ksatisfiability_preemptivescheduling.rs @@ -336,13 +336,12 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target schedule does not meet the satisfiability threshold", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target schedule does not meet the satisfiability threshold", + )?; Ok(self .positive_start_jobs .iter() diff --git a/src/rules/ksatisfiability_quadraticcongruences.rs b/src/rules/ksatisfiability_quadraticcongruences.rs index f3c5aa650..74680d9b8 100644 --- a/src/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/rules/ksatisfiability_quadraticcongruences.rs @@ -40,13 +40,12 @@ impl ReductionResult for Reduction3SATToQuadraticCongruences { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target integer does not satisfy the bounded quadratic congruence", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target integer does not satisfy the bounded quadratic congruence", + )?; // Validation gives 0 < x <= H. Each prime power divides exactly one // of H-x and H+x. The coordinate zero sign chooses x or -x so that // the odd linear target, rather than its negative, is recovered. @@ -315,19 +314,8 @@ fn witness_config_for_assignment( Some(witness_value_from_alphas(&alphas, &construction.thetas)) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticCongruences { - type Source = KSatisfiability; - type Target = QuadraticCongruences; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticCongruences {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/rules/ksatisfiability_quadraticdiophantineequations.rs index 3a21db24f..91dfdf19e 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -32,13 +32,12 @@ impl ReductionResult for Reduction3SATToQuadraticDiophantineEquations { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ self.congruence_reduction @@ -68,19 +67,8 @@ fn translate_congruence(source: &QuadraticCongruences) -> QuadraticDiophantineEq QuadraticDiophantineEquations::new(BigUint::one(), source.b().clone(), c) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticDiophantineEquations { - type Source = KSatisfiability; - type Target = QuadraticDiophantineEquations; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticDiophantineEquations {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index 8d0a76d76..6ebb91c9f 100644 --- a/src/rules/ksatisfiability_qubo.rs +++ b/src/rules/ksatisfiability_qubo.rs @@ -37,13 +37,12 @@ impl ReductionResult for ReductionKSatToQUBO { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "QUBO energy does not meet the SAT zero-penalty threshold", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "QUBO energy does not meet the SAT zero-penalty threshold", + )?; Ok(target_solution[..self.source_num_vars].to_vec()) } } @@ -67,13 +66,12 @@ impl ReductionResult for Reduction3SATToQUBO { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "QUBO energy does not meet the SAT zero-penalty threshold", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "QUBO energy does not meet the SAT zero-penalty threshold", + )?; Ok(target_solution[..self.source_num_vars].to_vec()) } } @@ -325,29 +323,11 @@ fn build_qubo_matrix( Ok((matrix, constant)) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO { - type Source = KSatisfiability; - type Target = Decision>; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO {} -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO { - type Source = KSatisfiability; - type Target = Decision>; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO {} #[reduction( transform = exact { diff --git a/src/rules/ksatisfiability_registersufficiency.rs b/src/rules/ksatisfiability_registersufficiency.rs index 763660ae6..c4acc61c9 100644 --- a/src/rules/ksatisfiability_registersufficiency.rs +++ b/src/rules/ksatisfiability_registersufficiency.rs @@ -296,13 +296,12 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target ordering does not satisfy the register bound and dependencies", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target ordering does not satisfy the register bound and dependencies", + )?; let mut assignment = vec![false; self.source_num_vars]; let Some(layout) = &self.layout else { // Only the empty-conjunction target has a feasible witness here. @@ -323,17 +322,8 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToRegisterSufficiency { - type Source = KSatisfiability; - type Target = RegisterSufficiency; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToRegisterSufficiency {} #[reduction( transform = upper_bound { diff --git a/src/rules/ksatisfiability_simultaneousincongruences.rs b/src/rules/ksatisfiability_simultaneousincongruences.rs index 24e55a166..7fa0c5dc5 100644 --- a/src/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/rules/ksatisfiability_simultaneousincongruences.rs @@ -30,13 +30,12 @@ impl ReductionResult for Reduction3SATToSimultaneousIncongruences { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ let x = u64::try_from(*target_solution).map_err(|_| { @@ -178,19 +177,8 @@ fn ensure_prime_product_fits_target( Ok(()) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToSimultaneousIncongruences { - type Source = KSatisfiability; - type Target = SimultaneousIncongruences; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToSimultaneousIncongruences {} #[reduction( transform = unavailable { diff --git a/src/rules/ksatisfiability_subsetsum.rs b/src/rules/ksatisfiability_subsetsum.rs index c915bacf0..7079a3db8 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -39,13 +39,12 @@ impl ReductionResult for Reduction3SATToSubsetSum { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ // Variable integers are the first 2n elements in 0-based indexing: @@ -70,19 +69,8 @@ fn digits_to_integer(digits: &[u8]) -> BigUint { value } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToSubsetSum { - type Source = KSatisfiability; - type Target = SubsetSum; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToSubsetSum {} #[reduction( transform = upper_bound { num_elements = "2 * num_vars + 2 * num_clauses" } diff --git a/src/rules/ksatisfiability_timetabledesign.rs b/src/rules/ksatisfiability_timetabledesign.rs index 6cfdf3427..b79da5e9e 100644 --- a/src/rules/ksatisfiability_timetabledesign.rs +++ b/src/rules/ksatisfiability_timetabledesign.rs @@ -748,13 +748,12 @@ impl ReductionResult for Reduction3SATToTimetableDesign { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target timetable is not feasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target timetable is not feasible", + )?; Ok({ let num_periods = self.target.num_periods(); @@ -792,17 +791,8 @@ impl ReductionResult for Reduction3SATToTimetableDesign { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for Reduction3SATToTimetableDesign { - type Source = KSatisfiability; - type Target = TimetableDesign; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToTimetableDesign {} #[reduction( transform = upper_bound { diff --git a/src/rules/longestcircuit_ilp.rs b/src/rules/longestcircuit_ilp.rs index 02e3f9bb5..b122df15a 100644 --- a/src/rules/longestcircuit_ilp.rs +++ b/src/rules/longestcircuit_ilp.rs @@ -196,30 +196,18 @@ impl ReductionResult for ReductionDecisionLongestCircuitToILP { } fn extract_solution(&self, solution: &Vec) -> crate::rules::ExtractionResult> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "ILP assignment does not satisfy the bounded circuit constraints", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + solution, + |value| value.value.is_some(), + "ILP assignment does not satisfy the bounded circuit constraints", + )?; Ok(self.inner.decode_edges(solution)) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionDecisionLongestCircuitToILP { - type Source = Decision>; - type Target = ILP; - - fn target_problem(&self) -> &Self::Target { - self.inner.target_problem() - } - - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDecisionLongestCircuitToILP {} #[reduction( transform = exact { diff --git a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index bac943d4d..7b2187524 100644 --- a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -85,16 +85,14 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToMinimumIntersectionG &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.is_valid(), + "target configuration is not a valid intersection graph basis", + )?; Ok({ - if !value.is_valid() { - return Err(crate::rules::ExtractionError::invalid( - "target configuration is not a valid intersection graph basis", - )); - } - extract_edge_clique_cover(self.target.graph(), target_solution).ok_or_else(|| { crate::rules::ExtractionError::invalid( "target basis does not assign a shared label to every source edge", diff --git a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs index 1e336e56b..af82df0c9 100644 --- a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs +++ b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs @@ -34,13 +34,12 @@ impl ReductionResult for ReductionFVSToCodeGen { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.0.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target order must be a permutation respecting expression dependencies", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0.is_some(), + "target order must be a permutation respecting expression dependencies", + )?; Ok(self .chain_start .iter() diff --git a/src/rules/minimumvertexcover_comparativecontainment.rs b/src/rules/minimumvertexcover_comparativecontainment.rs index 46aa8c080..a3d5ebffe 100644 --- a/src/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/rules/minimumvertexcover_comparativecontainment.rs @@ -34,28 +34,18 @@ impl ReductionResult for ReductionDecisionMVCToComparativeContainment { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .0 - { - return Err(crate::rules::ExtractionError::invalid( - "containment inequality is not satisfied", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "containment inequality is not satisfied", + )?; Ok(target_solution.clone()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionDecisionMVCToComparativeContainment { - type Source = Decision>; - type Target = ComparativeContainment; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionDecisionMVCToComparativeContainment {} #[reduction( transform = upper_bound { diff --git a/src/rules/monochromatictriangle_ilp.rs b/src/rules/monochromatictriangle_ilp.rs index d4ce22411..137f5d4b3 100644 --- a/src/rules/monochromatictriangle_ilp.rs +++ b/src/rules/monochromatictriangle_ilp.rs @@ -29,29 +29,19 @@ impl ReductionResult for ReductionMonochromaticTriangleToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution.iter().map(|&value| value == 1).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionMonochromaticTriangleToILP { - type Source = MonochromaticTriangle; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionMonochromaticTriangleToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/multiplechoicebranching_ilp.rs b/src/rules/multiplechoicebranching_ilp.rs index edf398170..e341e994c 100644 --- a/src/rules/multiplechoicebranching_ilp.rs +++ b/src/rules/multiplechoicebranching_ilp.rs @@ -23,13 +23,12 @@ impl ReductionResult for ReductionMultipleChoiceBranchingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution[..self.num_arcs] .iter() .map(|&selected| selected == 1) @@ -37,17 +36,8 @@ impl ReductionResult for ReductionMultipleChoiceBranchingToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionMultipleChoiceBranchingToILP { - type Source = MultipleChoiceBranching; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionMultipleChoiceBranchingToILP {} #[reduction( transform = exact { diff --git a/src/rules/multiprocessorscheduling_ilp.rs b/src/rules/multiprocessorscheduling_ilp.rs index 79484d7db..520ee4674 100644 --- a/src/rules/multiprocessorscheduling_ilp.rs +++ b/src/rules/multiprocessorscheduling_ilp.rs @@ -37,13 +37,12 @@ impl ReductionResult for ReductionMSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -54,17 +53,8 @@ impl ReductionResult for ReductionMSToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionMSToILP { - type Source = MultiprocessorScheduling; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionMSToILP {} #[reduction( transform = exact { diff --git a/src/rules/naesatisfiability_ilp.rs b/src/rules/naesatisfiability_ilp.rs index 2cd33986c..8affee9e1 100644 --- a/src/rules/naesatisfiability_ilp.rs +++ b/src/rules/naesatisfiability_ilp.rs @@ -30,29 +30,19 @@ impl ReductionResult for ReductionNAESATToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution.iter().map(|&value| value == 1).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionNAESATToILP { - type Source = NAESatisfiability; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToILP {} #[reduction( transform = exact { diff --git a/src/rules/naesatisfiability_maxcut.rs b/src/rules/naesatisfiability_maxcut.rs index 464798131..b5fec3dc9 100644 --- a/src/rules/naesatisfiability_maxcut.rs +++ b/src/rules/naesatisfiability_maxcut.rs @@ -42,13 +42,12 @@ impl ReductionResult for ReductionNAESATToMaxCut { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target cut does not certify a satisfying NAE assignment", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target cut does not certify a satisfying NAE assignment", + )?; Ok({ (0..self.source_num_vars) @@ -58,19 +57,8 @@ impl ReductionResult for ReductionNAESATToMaxCut { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionNAESATToMaxCut { - type Source = NAESatisfiability; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToMaxCut {} /// Dimensions, variable-edge weight, and certificate for legal clause lengths. fn nae_maxcut_parameters( diff --git a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs index 094fab7c1..5155cb773 100644 --- a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -73,13 +73,12 @@ impl ReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target partition is not a partition into perfect matchings", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target partition is not a partition into perfect matchings", + )?; Ok({ self.layout @@ -346,19 +345,8 @@ fn build_layout( }) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { - type Source = NAESatisfiability; - type Target = PartitionIntoPerfectMatchings; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings {} #[reduction( transform = upper_bound { diff --git a/src/rules/naesatisfiability_setsplitting.rs b/src/rules/naesatisfiability_setsplitting.rs index 8ee68267f..30ae3e3a5 100644 --- a/src/rules/naesatisfiability_setsplitting.rs +++ b/src/rules/naesatisfiability_setsplitting.rs @@ -29,13 +29,12 @@ impl ReductionResult for ReductionNAESATToSetSplitting { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok(target_solution[..self.num_source_variables].to_vec()) } @@ -50,17 +49,8 @@ fn literal_element_index(lit: i64, num_vars: usize) -> usize { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionNAESATToSetSplitting { - type Source = NAESatisfiability; - type Target = SetSplitting; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToSetSplitting {} #[reduction( transform = exact { diff --git a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 45f717b3e..014c937f5 100644 --- a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -30,13 +30,12 @@ impl ReductionResult for ReductionN3DMToNMTS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ let mut x_indices_by_pair_sum: BTreeMap> = BTreeMap::new(); @@ -83,17 +82,8 @@ fn checked_target_sum(bound: i64, w_size: i64) -> Result { .ok_or("computing a derived target sum overflowed") } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionN3DMToNMTS { - type Source = Numerical3DimensionalMatching; - type Target = NumericalMatchingWithTargetSums; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionN3DMToNMTS {} #[reduction( transform = exact { diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index a53646abc..617233292 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -48,13 +48,12 @@ impl ReductionResult for ReductionNMTSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let mut assignment = vec![0usize; self.m]; @@ -68,17 +67,8 @@ impl ReductionResult for ReductionNMTSToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionNMTSToILP { - type Source = NumericalMatchingWithTargetSums; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionNMTSToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/openshopscheduling_ilp.rs b/src/rules/openshopscheduling_ilp.rs index d2d1f2bcb..5a7d01091 100644 --- a/src/rules/openshopscheduling_ilp.rs +++ b/src/rules/openshopscheduling_ilp.rs @@ -292,30 +292,18 @@ impl ReductionResult for ReductionDecisionOpenShopSchedulingToILP { } fn extract_solution(&self, solution: &Vec) -> crate::rules::ExtractionResult> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "ILP assignment does not satisfy the bounded scheduling constraints", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + solution, + |value| value.value.is_some(), + "ILP assignment does not satisfy the bounded scheduling constraints", + )?; self.inner.decode_schedule(solution) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionDecisionOpenShopSchedulingToILP { - type Source = Decision; - type Target = ILP; - - fn target_problem(&self) -> &Self::Target { - self.inner.target_problem() - } - - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDecisionOpenShopSchedulingToILP {} #[reduction( transform = exact { diff --git a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index f6c9f00f5..bc10fe770 100644 --- a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -30,13 +30,12 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target column order is not a satisfying augmentation certificate", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target column order is not a satisfying augmentation certificate", + )?; // Validation establishes a permutation within the augmentation budget. // The NO sentinel has no such certificate; all remaining columns are // source vertices, including the empty permutation for an empty graph. @@ -48,18 +47,10 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatrixAugmentation { - type Source = Decision>; - type Target = ConsecutiveOnesMatrixAugmentation; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/partition_binpacking.rs b/src/rules/partition_binpacking.rs index 37bdfc7fc..a5e2872aa 100644 --- a/src/rules/partition_binpacking.rs +++ b/src/rules/partition_binpacking.rs @@ -35,13 +35,12 @@ impl ReductionResult for ReductionPartitionToBinPacking { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; Ok({ // BinPacking may use any bin indices (0..n-1). Remap the two distinct diff --git a/src/rules/partition_cosineproductintegration.rs b/src/rules/partition_cosineproductintegration.rs index 6e10f85d0..75a121051 100644 --- a/src/rules/partition_cosineproductintegration.rs +++ b/src/rules/partition_cosineproductintegration.rs @@ -32,29 +32,19 @@ impl ReductionResult for ReductionPartitionToCPI { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPartitionToCPI { - type Source = Partition; - type Target = CosineProductIntegration; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToCPI {} #[reduction( transform = exact { diff --git a/src/rules/partition_integralflowwithmultipliers.rs b/src/rules/partition_integralflowwithmultipliers.rs index 319d61bb9..f72063df2 100644 --- a/src/rules/partition_integralflowwithmultipliers.rs +++ b/src/rules/partition_integralflowwithmultipliers.rs @@ -36,15 +36,12 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { "the fixed infeasible target instance has no extractable witness", ) })?; - let value = crate::rules::traits::validate_target_solution( + crate::rules::traits::validate_target_witness( self.target_problem(), target_solution, + |value| value.0, + "target witness does not satisfy the target problem", )?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } target_solution[..item_arc_count] .iter() @@ -54,17 +51,8 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { - type Source = Partition; - type Target = IntegralFlowWithMultipliers; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToIntegralFlowWithMultipliers {} #[reduction( transform = upper_bound { diff --git a/src/rules/partition_knapsack.rs b/src/rules/partition_knapsack.rs index 083107149..bb0584ff8 100644 --- a/src/rules/partition_knapsack.rs +++ b/src/rules/partition_knapsack.rs @@ -23,13 +23,12 @@ impl ReductionResult for ReductionPartitionToKnapsack { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; Ok(target_solution.to_vec()) } diff --git a/src/rules/partition_multiprocessorscheduling.rs b/src/rules/partition_multiprocessorscheduling.rs index d99497925..4aa12595b 100644 --- a/src/rules/partition_multiprocessorscheduling.rs +++ b/src/rules/partition_multiprocessorscheduling.rs @@ -36,13 +36,12 @@ impl ReductionResult for ReductionPartitionToMPS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok(target_solution .iter() @@ -51,17 +50,8 @@ impl ReductionResult for ReductionPartitionToMPS { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPartitionToMPS { - type Source = Partition; - type Target = MultiprocessorScheduling; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToMPS {} #[reduction( transform = exact { diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index 112030f5b..7fb5d0ac2 100644 --- a/src/rules/partition_openshopscheduling.rs +++ b/src/rules/partition_openshopscheduling.rs @@ -22,13 +22,12 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target schedule does not certify a balanced partition", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target schedule does not certify a balanced partition", + )?; Ok({ let num_elements = self.target.inner().num_jobs() - 1; @@ -78,19 +77,8 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopScheduling { - type Source = Partition; - type Target = Decision; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopScheduling {} #[reduction( transform = exact { diff --git a/src/rules/partition_productionplanning.rs b/src/rules/partition_productionplanning.rs index 4bd918c52..137313d78 100644 --- a/src/rules/partition_productionplanning.rs +++ b/src/rules/partition_productionplanning.rs @@ -21,13 +21,12 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok(target_solution[..self.target.num_periods() - 1] .iter() @@ -36,17 +35,8 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPartitionToProductionPlanning { - type Source = Partition; - type Target = ProductionPlanning; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToProductionPlanning {} #[reduction( transform = exact { diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index c7c032867..0fca17064 100644 --- a/src/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/rules/partition_sequencingtominimizetardytaskweight.rs @@ -23,13 +23,12 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target schedule does not certify a balanced partition", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target schedule does not certify a balanced partition", + )?; Ok({ let mut source_config = vec![true; self.target.inner().num_tasks()]; @@ -53,20 +52,10 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { - type Source = Partition; - type Target = Decision; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs index 1432ab05e..ceaca59ee 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -30,13 +30,12 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; if target_solution.len() != self.source_n { return Err(crate::rules::ExtractionError::invalid(format!( @@ -49,17 +48,8 @@ impl ReductionResult for ReductionPartitionToSubsetSum { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPartitionToSubsetSum { - type Source = Partition; - type Target = SubsetSum; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToSubsetSum {} #[reduction( transform = upper_bound { diff --git a/src/rules/partition_sumofsquarespartition.rs b/src/rules/partition_sumofsquarespartition.rs index 18f3d8432..691f3cb28 100644 --- a/src/rules/partition_sumofsquarespartition.rs +++ b/src/rules/partition_sumofsquarespartition.rs @@ -47,13 +47,12 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; if target_solution.len() != self.target.num_elements() { return Err(crate::rules::ExtractionError::invalid(format!( "expected {} target group assignments, got {}", diff --git a/src/rules/partitionintocliques_ilp.rs b/src/rules/partitionintocliques_ilp.rs index 3810581a7..f15013c49 100644 --- a/src/rules/partitionintocliques_ilp.rs +++ b/src/rules/partitionintocliques_ilp.rs @@ -25,13 +25,12 @@ impl ReductionResult for ReductionPartitionIntoCliquesToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; (0..self.num_vertices) .map(|vertex| { @@ -47,17 +46,8 @@ impl ReductionResult for ReductionPartitionIntoCliquesToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToILP { - type Source = PartitionIntoCliques; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/rules/partitionintocliques_minimumcoveringbycliques.rs index 85e36e54b..1c4639752 100644 --- a/src/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -155,13 +155,12 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target cover does not certify the source clique bound", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target cover does not certify the source clique bound", + )?; Ok({ let n = self.num_source_vertices; @@ -202,20 +201,10 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques { - type Source = PartitionIntoCliques; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index 6e6f7507c..e393cd3c1 100644 --- a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -37,29 +37,19 @@ impl ReductionResult for ReductionPPL2ToBCSF { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPPL2ToBCSF { - type Source = PartitionIntoPathsOfLength2; - type Target = BoundedComponentSpanningForest; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPPL2ToBCSF {} #[reduction( transform = upper_bound { diff --git a/src/rules/partitionintopathsoflength2_ilp.rs b/src/rules/partitionintopathsoflength2_ilp.rs index 280d9de85..d08b94113 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -48,13 +48,12 @@ impl ReductionResult for ReductionPIPL2ToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -65,17 +64,8 @@ impl ReductionResult for ReductionPIPL2ToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPIPL2ToILP { - type Source = PartitionIntoPathsOfLength2; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPIPL2ToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/partitionintotriangles_ilp.rs b/src/rules/partitionintotriangles_ilp.rs index bfcea4491..644eb9458 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -41,13 +41,12 @@ impl ReductionResult for ReductionPITToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -58,17 +57,8 @@ impl ReductionResult for ReductionPITToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPITToILP { - type Source = PartitionIntoTriangles; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPITToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/pathconstrainednetworkflow_ilp.rs b/src/rules/pathconstrainednetworkflow_ilp.rs index a2b11b7b2..bf9b50ebd 100644 --- a/src/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/rules/pathconstrainednetworkflow_ilp.rs @@ -26,29 +26,19 @@ impl ReductionResult for ReductionPCNFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(target_solution) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPCNFToILP { - type Source = PathConstrainedNetworkFlow; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPCNFToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/precedenceconstrainedscheduling_ilp.rs b/src/rules/precedenceconstrainedscheduling_ilp.rs index fb6c172f8..4de993d6f 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -42,13 +42,12 @@ impl ReductionResult for ReductionPCSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -59,17 +58,8 @@ impl ReductionResult for ReductionPCSToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionPCSToILP { - type Source = PrecedenceConstrainedScheduling; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPCSToILP {} #[reduction( transform = exact { diff --git a/src/rules/prizecollectingsteinerforest_steinertree.rs b/src/rules/prizecollectingsteinerforest_steinertree.rs index 1f282e2eb..c79151290 100644 --- a/src/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/rules/prizecollectingsteinerforest_steinertree.rs @@ -75,13 +75,12 @@ impl ReductionResult for ReductionPCSFToSteinerTree { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if !crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .is_valid() - { - return Err(crate::rules::ExtractionError::invalid( - "target edges do not form a Steiner tree", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.is_valid(), + "target edges do not form a Steiner tree", + )?; Ok({ let n = self.num_source_vertices; diff --git a/src/rules/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index 4ccaf3f49..78334ddf4 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -25,29 +25,19 @@ impl ReductionResult for ReductionRPCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution.iter().map(|&value| value == 1).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionRPCToILP { - type Source = RectilinearPictureCompression; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRPCToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/registersufficiency_ilp.rs b/src/rules/registersufficiency_ilp.rs index 68f2430de..447c376c8 100644 --- a/src/rules/registersufficiency_ilp.rs +++ b/src/rules/registersufficiency_ilp.rs @@ -30,29 +30,19 @@ impl ReductionResult for ReductionRegisterSufficiencyToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(&target_solution[..self.num_vertices]) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionRegisterSufficiencyToILP { - type Source = RegisterSufficiency; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRegisterSufficiencyToILP {} #[reduction( transform = exact { diff --git a/src/rules/resourceconstrainedscheduling_ilp.rs b/src/rules/resourceconstrainedscheduling_ilp.rs index 72deeb980..99b8510ad 100644 --- a/src/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/rules/resourceconstrainedscheduling_ilp.rs @@ -33,13 +33,12 @@ impl ReductionResult for ReductionRCSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -50,17 +49,8 @@ impl ReductionResult for ReductionRCSToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionRCSToILP { - type Source = ResourceConstrainedScheduling; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRCSToILP {} #[reduction( transform = exact { diff --git a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs index 0d511b96f..a27f69c94 100644 --- a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -40,13 +40,12 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ let n = self.num_vertices; @@ -60,20 +59,10 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign } } -#[crate::aggregate_reduction] +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssignment { - type Source = RootedTreeArrangement; - type Target = RootedTreeStorageAssignment; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } } #[reduction( diff --git a/src/rules/rootedtreestorageassignment_ilp.rs b/src/rules/rootedtreestorageassignment_ilp.rs index 1426f960e..0ca97f3a7 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -76,29 +76,19 @@ impl ReductionResult for ReductionRTSAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode_rows(target_solution, self.n, self.n, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionRTSAToILP { - type Source = RootedTreeStorageAssignment; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRTSAToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/sat_circuitsat.rs b/src/rules/sat_circuitsat.rs index 503c16860..3f79a7a1e 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -30,13 +30,12 @@ impl ReductionResult for ReductionSATToCircuit { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ self.source_var_indices @@ -47,17 +46,8 @@ impl ReductionResult for ReductionSATToCircuit { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSATToCircuit { - type Source = Satisfiability; - type Target = CircuitSAT; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToCircuit {} #[reduction( transform = upper_bound { diff --git a/src/rules/sat_coloring.rs b/src/rules/sat_coloring.rs index 50d724d16..2c824337f 100644 --- a/src/rules/sat_coloring.rs +++ b/src/rules/sat_coloring.rs @@ -238,13 +238,12 @@ impl ReductionResult for ReductionSATToColoring { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target coloring is not valid", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target coloring is not valid", + )?; Ok(self .pos_vertices .iter() @@ -270,17 +269,8 @@ impl ReductionSATToColoring { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSATToColoring { - type Source = Satisfiability; - type Target = KColoring; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToColoring {} #[reduction( transform = upper_bound { diff --git a/src/rules/sat_ksat.rs b/src/rules/sat_ksat.rs index 3420b8a01..b7aef7264 100644 --- a/src/rules/sat_ksat.rs +++ b/src/rules/sat_ksat.rs @@ -36,13 +36,12 @@ impl ReductionResult for ReductionSATToKSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment is not satisfying", + )?; Ok({ // Only return the original variables, discarding ancillas @@ -53,16 +52,8 @@ impl ReductionResult for ReductionSATToKSAT { crate::register_aggregate_reduction!(ReductionSATToKSAT); -impl crate::rules::AggregateReductionResult for ReductionSATToKSAT { - type Source = Satisfiability; - type Target = KSatisfiability; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToKSAT {} /// Add a clause to the K-SAT formula, splitting or padding as necessary. /// @@ -205,13 +196,12 @@ impl ReductionResult for ReductionKSATToSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment is not satisfying", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment is not satisfying", + )?; Ok({ // Direct mapping - no transformation needed @@ -222,16 +212,8 @@ impl ReductionResult for ReductionKSATToSAT { crate::register_aggregate_reduction!(ReductionKSATToSAT); -impl crate::rules::AggregateReductionResult for ReductionKSATToSAT { - type Source = KSatisfiability; - type Target = Satisfiability; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKSATToSAT {} /// Helper function for KSAT -> SAT reduction logic (generic over K). fn reduce_ksat_to_sat(ksat: &KSatisfiability) -> ReductionKSATToSAT { diff --git a/src/rules/sat_maximumindependentset.rs b/src/rules/sat_maximumindependentset.rs index da7fc262d..79642a363 100644 --- a/src/rules/sat_maximumindependentset.rs +++ b/src/rules/sat_maximumindependentset.rs @@ -81,13 +81,12 @@ impl ReductionResult for ReductionSATToIS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target independent set does not certify satisfiability", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target independent set does not certify satisfiability", + )?; let mut assignment = vec![false; self.num_source_variables]; for (literal, &selected) in self.literals.iter().zip(target_solution) { @@ -99,19 +98,8 @@ impl ReductionResult for ReductionSATToIS { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSATToIS { - type Source = Satisfiability; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToIS {} impl ReductionSATToIS { /// Get the number of clauses in the source SAT problem. diff --git a/src/rules/sat_minimumdominatingset.rs b/src/rules/sat_minimumdominatingset.rs index 8083a1514..9dea994c8 100644 --- a/src/rules/sat_minimumdominatingset.rs +++ b/src/rules/sat_minimumdominatingset.rs @@ -60,13 +60,12 @@ impl ReductionResult for ReductionSATToDS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target dominating set does not certify satisfiability", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target dominating set does not certify satisfiability", + )?; let mut assignment = vec![false; self.num_literals]; for (&variable, &gadget) in &self.variables { @@ -77,19 +76,8 @@ impl ReductionResult for ReductionSATToDS { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSATToDS { - type Source = Satisfiability; - type Target = Decision>; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToDS {} impl ReductionSATToDS { /// Compute the graph dimensions and exact certificate before allocation. diff --git a/src/rules/satisfiability_integralflowhomologousarcs.rs b/src/rules/satisfiability_integralflowhomologousarcs.rs index daef6a649..17b963efc 100644 --- a/src/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/rules/satisfiability_integralflowhomologousarcs.rs @@ -106,13 +106,12 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target flow is not feasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target flow is not feasible", + )?; Ok({ self.variable_paths @@ -123,17 +122,8 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSATToIntegralFlowHomologousArcs { - type Source = Satisfiability; - type Target = IntegralFlowHomologousArcs; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToIntegralFlowHomologousArcs {} #[reduction( transform = upper_bound { diff --git a/src/rules/satisfiability_maximum2satisfiability.rs b/src/rules/satisfiability_maximum2satisfiability.rs index f11f2557c..c2d97a009 100644 --- a/src/rules/satisfiability_maximum2satisfiability.rs +++ b/src/rules/satisfiability_maximum2satisfiability.rs @@ -25,31 +25,19 @@ impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment does not certify satisfiability", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment does not certify satisfiability", + )?; Ok(target_solution[..self.source_num_vars].to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { - type Source = Satisfiability; - type Target = Decision; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability {} fn add_normalized_clause( clause: &CNFClause, diff --git a/src/rules/satisfiability_naesatisfiability.rs b/src/rules/satisfiability_naesatisfiability.rs index cbbe8d44c..e0efdf532 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -33,13 +33,12 @@ impl ReductionResult for ReductionSATToNAESAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment does not satisfy NAE clauses", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment does not satisfy NAE clauses", + )?; let n = self.source_num_vars; let sentinel = target_solution[n]; @@ -50,17 +49,8 @@ impl ReductionResult for ReductionSATToNAESAT { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSATToNAESAT { - type Source = Satisfiability; - type Target = NAESatisfiability; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToNAESAT {} #[reduction( transform = exact { diff --git a/src/rules/satisfiability_nontautology.rs b/src/rules/satisfiability_nontautology.rs index 615a0c3b4..6196139c3 100644 --- a/src/rules/satisfiability_nontautology.rs +++ b/src/rules/satisfiability_nontautology.rs @@ -25,29 +25,19 @@ impl ReductionResult for ReductionSATToNonTautology { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSATToNonTautology { - type Source = Satisfiability; - type Target = NonTautology; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToNonTautology {} #[reduction( transform = exact { diff --git a/src/rules/schedulingwithindividualdeadlines_ilp.rs b/src/rules/schedulingwithindividualdeadlines_ilp.rs index 8c9394146..c073ba6c1 100644 --- a/src/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/rules/schedulingwithindividualdeadlines_ilp.rs @@ -43,29 +43,19 @@ impl ReductionResult for ReductionSWIDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode_rows(target_solution, self.num_tasks, self.max_deadline, 0) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSWIDToILP { - type Source = SchedulingWithIndividualDeadlines; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWIDToILP {} #[reduction( transform = exact { diff --git a/src/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/rules/sequencingtominimizetardytaskweight_ilp.rs index 5f41493d2..4bf8c68f0 100644 --- a/src/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -29,13 +29,12 @@ impl ReductionResult for ReductionSTMTTWToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.is_valid() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.is_valid(), + "target ILP assignment is infeasible", + )?; Ok({ let n = self.num_tasks; diff --git a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs index 408e95c23..feb90dd53 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -37,13 +37,12 @@ impl ReductionResult for ReductionSTMWTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let n = self.num_tasks; @@ -55,17 +54,8 @@ impl ReductionResult for ReductionSTMWTToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSTMWTToILP { - type Source = SequencingToMinimizeWeightedTardiness; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSTMWTToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index f709d6160..85dc2be3e 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -40,13 +40,12 @@ impl ReductionResult for ReductionSWDSTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let n = self.num_tasks; @@ -56,17 +55,8 @@ impl ReductionResult for ReductionSWDSTToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSWDSTToILP { - type Source = SequencingWithDeadlinesAndSetUpTimes; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWDSTToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/sequencingwithinintervals_ilp.rs b/src/rules/sequencingwithinintervals_ilp.rs index b3de77917..663628d5f 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -47,13 +47,12 @@ impl ReductionResult for ReductionSWIToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; self.task_layout .iter() @@ -74,17 +73,8 @@ impl ReductionResult for ReductionSWIToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSWIToILP { - type Source = SequencingWithinIntervals; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWIToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 66273db9a..91c5d8109 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -33,13 +33,12 @@ impl ReductionResult for ReductionSWRTDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let n = self.num_tasks; @@ -56,17 +55,8 @@ impl ReductionResult for ReductionSWRTDToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSWRTDToILP { - type Source = SequencingWithReleaseTimesAndDeadlines; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWRTDToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/setsplitting_betweenness.rs b/src/rules/setsplitting_betweenness.rs index a77a678da..547982fa4 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -33,13 +33,12 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; let pole_position = target_solution[self.pole]; Ok(target_solution[..self.source_universe_size] @@ -49,17 +48,8 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSetSplittingToBetweenness { - type Source = SetSplitting; - type Target = Betweenness; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSetSplittingToBetweenness {} #[reduction( transform = unavailable { diff --git a/src/rules/setsplitting_ilp.rs b/src/rules/setsplitting_ilp.rs index 7c21800e2..ecccd257b 100644 --- a/src/rules/setsplitting_ilp.rs +++ b/src/rules/setsplitting_ilp.rs @@ -32,29 +32,19 @@ impl ReductionResult for ReductionSetSplittingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution.iter().map(|&value| value == 1).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSetSplittingToILP { - type Source = SetSplitting; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSetSplittingToILP {} #[reduction( transform = exact { diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index e6ab9f498..b03552ee3 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -26,13 +26,12 @@ impl ReductionResult for ReductionSMCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, @@ -43,17 +42,8 @@ impl ReductionResult for ReductionSMCToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSMCToILP { - type Source = SparseMatrixCompression; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSMCToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/steinertree_ilp.rs b/src/rules/steinertree_ilp.rs index 3294b91e4..0bfa3f98d 100644 --- a/src/rules/steinertree_ilp.rs +++ b/src/rules/steinertree_ilp.rs @@ -30,14 +30,12 @@ impl ReductionResult for ReductionSteinerTreeToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - if crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)? - .value - .is_none() - { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution[..self.num_edges] .iter() .map(|&value| value == 1) diff --git a/src/rules/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index 94b0ac79c..aa4e22978 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -58,13 +58,12 @@ impl ReductionResult for ReductionSTSCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let n = self.n; @@ -113,17 +112,8 @@ impl ReductionResult for ReductionSTSCToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSTSCToILP { - type Source = StringToStringCorrection; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSTSCToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/strongconnectivityaugmentation_ilp.rs b/src/rules/strongconnectivityaugmentation_ilp.rs index 4a6da2d4e..00fbacf36 100644 --- a/src/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/rules/strongconnectivityaugmentation_ilp.rs @@ -27,13 +27,12 @@ impl ReductionResult for ReductionSCAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution[..self.num_candidates] .iter() @@ -42,17 +41,8 @@ impl ReductionResult for ReductionSCAToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSCAToILP { - type Source = StrongConnectivityAugmentation; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSCAToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/subgraphisomorphism_ilp.rs b/src/rules/subgraphisomorphism_ilp.rs index 9bdfa1770..7237dab2b 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -38,13 +38,12 @@ impl ReductionResult for ReductionSubIsoToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; one_hot_decode_rows( target_solution, @@ -55,17 +54,8 @@ impl ReductionResult for ReductionSubIsoToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSubIsoToILP { - type Source = SubgraphIsomorphism; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSubIsoToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/subsetsum_closestvectorproblem.rs b/src/rules/subsetsum_closestvectorproblem.rs index 79f13cd1c..8dbf54908 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -25,13 +25,12 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target lattice vector does not certify a subset sum", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target lattice vector does not certify a subset sum", + )?; Ok(target_solution[..self.num_elements] .iter() .map(|&value| value == 1) @@ -39,19 +38,8 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSubsetSumToClosestVectorProblem { - type Source = SubsetSum; - type Target = Decision; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToClosestVectorProblem {} impl ReductionSubsetSumToClosestVectorProblem { /// Check the dense representation before allocating its columns. diff --git a/src/rules/subsetsum_integerexpressionmembership.rs b/src/rules/subsetsum_integerexpressionmembership.rs index 92b487702..b56afe8a2 100644 --- a/src/rules/subsetsum_integerexpressionmembership.rs +++ b/src/rules/subsetsum_integerexpressionmembership.rs @@ -21,13 +21,12 @@ impl ReductionResult for ReductionSubsetSumToIntegerExpressionMembership { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ // Union choice 0 = left = Atom(1) = exclude, choice 1 = right = Atom(s_i+1) = include. @@ -67,17 +66,8 @@ fn build_expression(sizes: &[i64]) -> Result { Ok(expr) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSubsetSumToIntegerExpressionMembership { - type Source = SubsetSum; - type Target = IntegerExpressionMembership; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToIntegerExpressionMembership {} #[reduction( transform = exact { diff --git a/src/rules/subsetsum_partition.rs b/src/rules/subsetsum_partition.rs index 0f7df4b83..ae35fb792 100644 --- a/src/rules/subsetsum_partition.rs +++ b/src/rules/subsetsum_partition.rs @@ -34,13 +34,12 @@ impl ReductionResult for ReductionSubsetSumToPartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ let source_bits = &target_solution[..self.source_len]; @@ -66,17 +65,8 @@ impl ReductionResult for ReductionSubsetSumToPartition { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionSubsetSumToPartition { - type Source = SubsetSum; - type Target = Partition; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToPartition {} #[reduction( transform = upper_bound { diff --git a/src/rules/threedimensionalmatching_ilp.rs b/src/rules/threedimensionalmatching_ilp.rs index baa174195..b9a430bde 100644 --- a/src/rules/threedimensionalmatching_ilp.rs +++ b/src/rules/threedimensionalmatching_ilp.rs @@ -22,29 +22,19 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok(target_solution.iter().map(|&value| value == 1).collect()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToILP { - type Source = ThreeDimensionalMatching; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToILP {} #[reduction( transform = exact { diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index facd476d8..20d9c722e 100644 --- a/src/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -51,13 +51,12 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !crate::rules::AggregateReductionResult::extract_value(self, value).0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not certify a YES answer for the source", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| crate::rules::AggregateReductionResult::extract_value(self, value).0, + "target witness does not certify a YES answer for the source", + )?; if target_solution.len() != self.target.num_cols() { return Err(crate::rules::ExtractionError::invalid(format!( "expected {} target codeword bits, got {}", diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index de65db8df..68262ca45 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -266,13 +266,12 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreePartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target assignment is not a feasible 3-partition", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target assignment is not a feasible 3-partition", + )?; if self.num_source_triples == 0 { return Ok(Vec::new()); @@ -341,17 +340,8 @@ fn enumerate_pair_keys(num_regulars: usize) -> Option> { Some(pairs) } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToThreePartition { - type Source = ThreeDimensionalMatching; - type Target = ThreePartition; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToThreePartition {} #[reduction( transform = upper_bound { diff --git a/src/rules/threepartition_resourceconstrainedscheduling.rs b/src/rules/threepartition_resourceconstrainedscheduling.rs index 01c1727a7..48536d66c 100644 --- a/src/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/rules/threepartition_resourceconstrainedscheduling.rs @@ -42,29 +42,19 @@ impl ReductionResult for ReductionThreePartitionToRCS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok(target_solution.to_vec()) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionThreePartitionToRCS { - type Source = ThreePartition; - type Target = ResourceConstrainedScheduling; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionThreePartitionToRCS {} #[reduction( transform = exact { diff --git a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index 075c8feda..a77aea096 100644 --- a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -51,13 +51,12 @@ impl ReductionResult for ReductionThreePartitionToSRTD { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !value.0 { - return Err(crate::rules::ExtractionError::invalid( - "target witness does not satisfy the target problem", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; Ok({ // Simulate the schedule to find start times @@ -92,17 +91,8 @@ impl ReductionResult for ReductionThreePartitionToSRTD { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionThreePartitionToSRTD { - type Source = ThreePartition; - type Target = SequencingWithReleaseTimesAndDeadlines; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Or) -> crate::types::Or { - value - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionThreePartitionToSRTD {} #[reduction( transform = exact { diff --git a/src/rules/timetabledesign_ilp.rs b/src/rules/timetabledesign_ilp.rs index 9f9af22c1..4ddfe908f 100644 --- a/src/rules/timetabledesign_ilp.rs +++ b/src/rules/timetabledesign_ilp.rs @@ -35,13 +35,12 @@ impl ReductionResult for ReductionTDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok((0..self.num_craftsmen) .map(|craftsman| { @@ -62,17 +61,8 @@ impl ReductionResult for ReductionTDToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionTDToILP { - type Source = TimetableDesign; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionTDToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/traits.rs b/src/rules/traits.rs index 28b79d2c8..2507bb4ed 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -168,6 +168,22 @@ pub(crate) fn validate_target_solution( Ok(target.evaluate(solution)?) } +/// Validate once, then require the evaluated target to certify a source witness. +/// The rule supplies its feasibility predicate or value-map threshold and rejection reason. +/// A rejected candidate is an extraction error, not a completed infeasibility result. +pub(crate) fn validate_target_witness( + target: &P, + solution: &P::Solution, + certifies_source: impl FnOnce(P::Value) -> bool, + message: &str, +) -> ExtractionResult<()> { + let value = validate_target_solution(target, solution)?; + if !certifies_source(value) { + return Err(ExtractionError::invalid(message)); + } + Ok(()) +} + /// Result of reducing a source problem to a target problem. /// /// This trait encapsulates the target problem and provides methods diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index a91f8da20..6a02a8e2b 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -41,16 +41,16 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if crate::rules::AggregateReductionResult::extract_value(self, value) - .0 - .is_none() - { - return Err(crate::rules::ExtractionError::invalid( - "target energy does not encode a feasible tour", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| { + crate::rules::AggregateReductionResult::extract_value(self, value) + .0 + .is_some() + }, + "target energy does not encode a feasible tour", + )?; if self.num_vertices < 3 { return Ok(self .small_optimum diff --git a/src/rules/undirectedflowlowerbounds_ilp.rs b/src/rules/undirectedflowlowerbounds_ilp.rs index 5f5f98280..d3310c430 100644 --- a/src/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/rules/undirectedflowlowerbounds_ilp.rs @@ -58,13 +58,12 @@ impl ReductionResult for ReductionUFLBToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; Ok({ let e = self.num_edges; @@ -76,17 +75,8 @@ impl ReductionResult for ReductionUFLBToILP { } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionUFLBToILP { - type Source = UndirectedFlowLowerBounds; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionUFLBToILP {} #[reduction( transform = upper_bound { diff --git a/src/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/rules/undirectedtwocommodityintegralflow_ilp.rs index 171145bb5..7cba64e21 100644 --- a/src/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -55,29 +55,19 @@ impl ReductionResult for ReductionU2CIFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if value.value.is_none() { - return Err(crate::rules::ExtractionError::invalid( - "target ILP assignment is infeasible", - )); - } + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.value.is_some(), + "target ILP assignment is infeasible", + )?; crate::rules::ilp_helpers::decode_usize_values(&target_solution[..4 * self.num_edges]) } } -#[crate::aggregate_reduction] -impl crate::rules::AggregateReductionResult for ReductionU2CIFToILP { - type Source = UndirectedTwoCommodityIntegralFlow; - type Target = ILP; - fn target_problem(&self) -> &Self::Target { - &self.target - } - fn extract_value(&self, value: crate::types::Extremum) -> crate::types::Or { - crate::types::Or(value.value.is_some()) - } -} +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionU2CIFToILP {} #[reduction( transform = exact { diff --git a/src/unit_tests/rules/traits.rs b/src/unit_tests/rules/traits.rs index dde9e5df0..1f792a394 100644 --- a/src/unit_tests/rules/traits.rs +++ b/src/unit_tests/rules/traits.rs @@ -508,3 +508,58 @@ fn universal_reduction_preserves_true_and_false_aggregates_without_witnesses() { assert!(reduction.extract_value_dyn(json!("not a Boolean")).is_err()); } } + +#[derive(Clone)] +struct CountedTarget(std::cell::Cell); + +impl Problem for CountedTarget { + const NAME: &'static str = "CountedTarget"; + type Solution = Vec; + type Value = i64; + fn parameter_names() -> &'static [&'static str] { + &[] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![]) + } + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } + + fn evaluate(&self, solution: &Self::Solution) -> Result { + self.0.set(self.0.get() + 1); + TargetProblem.evaluate(solution) + } +} + +#[test] +fn target_witness_validation_evaluates_once_and_preserves_rejection() { + use crate::rules::{traits::validate_target_witness, ExtractionError}; + let target = CountedTarget(std::cell::Cell::new(0)); + validate_target_witness( + &target, + &vec![1, 1], + |value| value == 2, + "threshold not met", + ) + .unwrap(); + assert_eq!(target.0.get(), 1); + let error = validate_target_witness( + &target, + &vec![1, 0], + |value| value == 2, + "threshold not met", + ) + .unwrap_err(); + assert_eq!(error, ExtractionError::invalid("threshold not met")); + assert_eq!(target.0.get(), 2); + let error = validate_target_witness( + &target, + &vec![2, 0], + |_| panic!("invalid input must not reach the predicate"), + "threshold not met", + ) + .unwrap_err(); + assert!(matches!(error, ExtractionError::Evaluation(_))); + assert_eq!(target.0.get(), 3); +} From d45318aea436c0f2a483a84f39d501b6119daff7 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 14:46:52 +0800 Subject: [PATCH 43/44] refactor: deserialize models through validating conversions Co-Authored-By: Codex --- src/models/algebraic/minimum_matrix_cover.rs | 21 +++++----- src/models/algebraic/qubo.rs | 38 ++++++++++++++----- .../graph/biconnectivity_augmentation.rs | 19 ++++++---- .../bounded_component_spanning_forest.rs | 20 ++++++---- .../graph/bounded_diameter_spanning_tree.rs | 19 ++++++---- .../graph/degree_constrained_spanning_tree.rs | 15 +++++--- src/models/graph/disjoint_connecting_paths.rs | 15 +++++--- src/models/graph/generalized_hex.rs | 15 +++++--- .../hamiltonian_path_between_two_vertices.rs | 14 ++++--- src/models/graph/kclique.rs | 15 +++++--- .../graph/length_bounded_disjoint_paths.rs | 22 ++++++----- src/models/graph/longest_circuit.rs | 17 +++++---- src/models/graph/longest_path.rs | 16 ++++---- src/models/graph/max_cut.rs | 17 +++++---- src/models/graph/maximal_is.rs | 17 +++++---- src/models/graph/maximum_clique.rs | 17 +++++---- src/models/graph/maximum_co_k_plex.rs | 19 ++++++---- src/models/graph/maximum_independent_set.rs | 17 +++++---- .../graph/maximum_leaf_spanning_tree.rs | 15 +++++--- src/models/graph/maximum_matching.rs | 17 +++++---- src/models/graph/min_max_multicenter.rs | 16 ++++---- .../minimum_capacitated_spanning_tree.rs | 20 ++++++---- .../graph/minimum_cut_into_bounded_sets.rs | 16 ++++---- src/models/graph/minimum_dominating_set.rs | 17 +++++---- src/models/graph/minimum_feedback_arc_set.rs | 15 +++++--- .../graph/minimum_feedback_vertex_set.rs | 15 +++++--- src/models/graph/minimum_multiway_cut.rs | 16 ++++---- src/models/graph/minimum_sum_multicenter.rs | 16 ++++---- src/models/graph/minimum_vertex_cover.rs | 17 +++++---- src/models/graph/monochromatic_triangle.rs | 13 ++++--- src/models/graph/partition_into_cliques.rs | 15 +++++--- src/models/graph/partition_into_forests.rs | 15 +++++--- .../graph/partition_into_paths_of_length_2.rs | 15 +++++--- .../graph/partition_into_perfect_matchings.rs | 15 +++++--- src/models/graph/partition_into_triangles.rs | 15 +++++--- src/models/graph/rural_postman.rs | 16 ++++---- .../graph/shortest_weight_constrained_path.rs | 19 ++++++---- src/models/graph/traveling_salesman.rs | 17 +++++---- .../misc/minimum_tardiness_sequencing.rs | 22 ++++++----- src/models/set/minimum_set_covering.rs | 15 +++++--- .../models/algebraic/minimum_matrix_cover.rs | 17 +++++++++ 41 files changed, 430 insertions(+), 277 deletions(-) diff --git a/src/models/algebraic/minimum_matrix_cover.rs b/src/models/algebraic/minimum_matrix_cover.rs index c23a9b642..6bb4e8e08 100644 --- a/src/models/algebraic/minimum_matrix_cover.rs +++ b/src/models/algebraic/minimum_matrix_cover.rs @@ -50,20 +50,23 @@ inventory::submit! { /// let witness = solver.solve(&problem).unwrap(); /// assert!(witness.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "Data")] pub struct MinimumMatrixCover { /// The n×n nonnegative integer matrix. matrix: Vec>, } -impl<'de> Deserialize<'de> for MinimumMatrixCover { - fn deserialize>(deserializer: D) -> Result { - #[derive(Deserialize)] - struct Data { - matrix: Vec>, - } - let data = Data::deserialize(deserializer)?; - Self::try_new(data.matrix).map_err(serde::de::Error::custom) +#[derive(Deserialize)] +struct Data { + matrix: Vec>, +} + +impl TryFrom for MinimumMatrixCover { + type Error = crate::registry::ConstructionError; + + fn try_from(data: Data) -> Result { + Self::try_new(data.matrix) } } diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 2aa46039d..0231bbd48 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -55,7 +55,9 @@ inventory::submit! { /// // Optimal is x = [0, 1] with value -2 /// assert!(solutions.contains(&vec![false, true])); /// ``` -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize)] +#[serde(try_from = "QuboInputData")] +#[serde(bound(deserialize = "W: WeightElement + Deserialize<'de>"))] pub struct QUBO { /// Number of variables. num_vars: usize, @@ -71,14 +73,32 @@ struct QuboData { entries: Vec<(usize, usize, W)>, } -impl<'de, W: WeightElement + Deserialize<'de>> Deserialize<'de> for QUBO { - fn deserialize>(deserializer: D) -> Result { - let data = QuboData::deserialize(deserializer).map_err(|error| { - serde::de::Error::custom(format!( - "{error}; expected QUBO format: num_vars and sparse entries [row, col, value] with row <= col" - )) - })?; - Self::try_from(data).map_err(serde::de::Error::custom) +// Keep parse-error format guidance separate from constructor validation errors. +#[derive(Deserialize)] +#[serde(transparent)] +struct QuboInputData { + #[serde( + deserialize_with = "deserialize_qubo_data", + bound(deserialize = "W: Deserialize<'de>") + )] + data: QuboData, +} + +fn deserialize_qubo_data<'de, W: Deserialize<'de>, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + QuboData::deserialize(deserializer).map_err(|error| { + serde::de::Error::custom(format!( + "{error}; expected QUBO format: num_vars and sparse entries [row, col, value] with row <= col" + )) + }) +} + +impl TryFrom> for QUBO { + type Error = ConstructionError; + + fn try_from(input: QuboInputData) -> Result { + Self::try_from(input.data) } } diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index e5a158601..ce58f773f 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -36,8 +36,12 @@ inventory::submit! { /// determine whether there exists a subset of potential edges `E'` such that: /// - `sum_{e in E'} w(e) <= B` /// - `(V, E union E')` is biconnected -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(bound(serialize = "G: serde::Serialize, W: serde::Serialize, W::Sum: serde::Serialize"))] +#[serde(try_from = "BiconnectivityAugmentationData")] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] pub struct BiconnectivityAugmentation where W: WeightElement, @@ -60,16 +64,15 @@ struct BiconnectivityAugmentationData { budget: W::Sum, } -impl<'de, G, W> Deserialize<'de> for BiconnectivityAugmentation +impl TryFrom> for BiconnectivityAugmentation where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, - W::Sum: Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = BiconnectivityAugmentationData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: BiconnectivityAugmentationData) -> Result { Self::try_new(data.graph, data.potential_weights, data.budget) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 8da209d50..4b0f3a30b 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -34,7 +34,11 @@ inventory::submit! { /// integer `K`, and a bound `B`, determine whether the vertices can be /// partitioned into at most `K` non-empty sets such that every set induces a /// connected subgraph and the total weight of each set is at most `B`. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "BoundedComponentSpanningForestData")] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] pub struct BoundedComponentSpanningForest { /// The underlying graph. graph: G, @@ -57,21 +61,21 @@ struct BoundedComponentSpanningForestData { max_weight: W::Sum, } -impl<'de, G, W> Deserialize<'de> for BoundedComponentSpanningForest +impl TryFrom> + for BoundedComponentSpanningForest where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, - W::Sum: Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = BoundedComponentSpanningForestData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: BoundedComponentSpanningForestData) -> Result { Self::try_new( data.graph, data.weights, data.max_components, data.max_weight, ) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index 243789d13..0c931954e 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -59,7 +59,11 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "BoundedDiameterSpanningTreeData")] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] pub struct BoundedDiameterSpanningTree { /// The underlying graph. graph: G, @@ -84,21 +88,20 @@ struct BoundedDiameterSpanningTreeData { diameter_bound: usize, } -impl<'de, G, W> Deserialize<'de> for BoundedDiameterSpanningTree +impl TryFrom> for BoundedDiameterSpanningTree where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, - W::Sum: Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = BoundedDiameterSpanningTreeData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: BoundedDiameterSpanningTreeData) -> Result { Self::try_new( data.graph, data.edge_weights, data.weight_bound, data.diameter_bound, ) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/degree_constrained_spanning_tree.rs b/src/models/graph/degree_constrained_spanning_tree.rs index 0978dd16d..80a7d3cce 100644 --- a/src/models/graph/degree_constrained_spanning_tree.rs +++ b/src/models/graph/degree_constrained_spanning_tree.rs @@ -55,7 +55,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "DegreeConstrainedSpanningTreeData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct DegreeConstrainedSpanningTree { /// The underlying graph. graph: G, @@ -72,13 +74,14 @@ struct DegreeConstrainedSpanningTreeData { max_degree: usize, } -impl<'de, G> Deserialize<'de> for DegreeConstrainedSpanningTree +impl TryFrom> for DegreeConstrainedSpanningTree where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = DegreeConstrainedSpanningTreeData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.max_degree).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: DegreeConstrainedSpanningTreeData) -> Result { + Self::try_new(data.graph, data.max_degree) } } diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index 6dc5ed7fc..d70509337 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -30,7 +30,9 @@ inventory::submit! { /// A configuration uses one binary variable per edge in the graph's canonical /// sorted edge list. A valid solution selects exactly the edges of one simple /// path for each terminal pair, with all such paths pairwise vertex-disjoint. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "DisjointConnectingPathsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct DisjointConnectingPaths { graph: G, terminal_pairs: Vec<(usize, usize)>, @@ -43,13 +45,14 @@ struct DisjointConnectingPathsData { terminal_pairs: Vec<(usize, usize)>, } -impl<'de, G> Deserialize<'de> for DisjointConnectingPaths +impl TryFrom> for DisjointConnectingPaths where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = DisjointConnectingPathsData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.terminal_pairs).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: DisjointConnectingPathsData) -> Result { + Self::try_new(data.graph, data.terminal_pairs) } } diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index 6fd800d0d..8223b1bf3 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -32,7 +32,9 @@ inventory::submit! { /// The problem is represented as a zero-variable decision problem: the graph /// instance fully determines the question, so `evaluate([])` runs a memoized /// game-tree search from the initial empty board. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "GeneralizedHexData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct GeneralizedHex { graph: G, source: usize, @@ -47,13 +49,14 @@ struct GeneralizedHexData { target: usize, } -impl<'de, G> Deserialize<'de> for GeneralizedHex +impl TryFrom> for GeneralizedHex where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = GeneralizedHexData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.source, data.target).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: GeneralizedHexData) -> Result { + Self::try_new(data.graph, data.source, data.target) } } diff --git a/src/models/graph/hamiltonian_path_between_two_vertices.rs b/src/models/graph/hamiltonian_path_between_two_vertices.rs index de5a26c1c..aff4b79cf 100644 --- a/src/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/models/graph/hamiltonian_path_between_two_vertices.rs @@ -68,7 +68,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "HamiltonianPathBetweenTwoVerticesData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct HamiltonianPathBetweenTwoVertices { graph: G, source_vertex: usize, @@ -83,14 +85,14 @@ struct HamiltonianPathBetweenTwoVerticesData { target_vertex: usize, } -impl<'de, G> Deserialize<'de> for HamiltonianPathBetweenTwoVertices +impl TryFrom> for HamiltonianPathBetweenTwoVertices where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = HamiltonianPathBetweenTwoVerticesData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: HamiltonianPathBetweenTwoVerticesData) -> Result { Self::try_new(data.graph, data.source_vertex, data.target_vertex) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index 89d751272..59b222298 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -26,7 +26,9 @@ inventory::submit! { /// Given a graph `G = (V, E)` and a positive integer `k`, determine whether /// there exists a subset `K ⊆ V` of size at least `k` such that every pair of /// distinct vertices in `K` is adjacent. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "KCliqueData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct KClique { graph: G, k: usize, @@ -39,13 +41,14 @@ struct KCliqueData { k: usize, } -impl<'de, G> Deserialize<'de> for KClique +impl TryFrom> for KClique where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = KCliqueData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.k).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: KCliqueData) -> Result { + Self::try_new(data.graph, data.k) } } diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index 4e51c6693..c13ecaa35 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -33,8 +33,9 @@ inventory::submit! { /// vertices of different slots must be disjoint. Empty slots (all zeros) are /// unused and do not count toward the objective. The objective is to maximize /// the number of non-empty valid path slots. -#[derive(Debug, Clone, Serialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "LengthBoundedDisjointPathsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct LengthBoundedDisjointPaths { graph: G, source: usize, @@ -53,20 +54,21 @@ struct LengthBoundedDisjointPathsData { max_length: usize, } -impl<'de, G> Deserialize<'de> for LengthBoundedDisjointPaths +impl TryFrom> for LengthBoundedDisjointPaths where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = LengthBoundedDisjointPathsData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: LengthBoundedDisjointPathsData) -> Result { let max_paths = data.max_paths; - let instance = Self::try_new(data.graph, data.source, data.sink, data.max_length) - .map_err(serde::de::Error::custom)?; + let instance = Self::try_new(data.graph, data.source, data.sink, data.max_length)?; if max_paths != instance.max_paths { - return Err(serde::de::Error::custom(format!( + return Err(format!( "max_paths must equal min(deg(source), deg(sink)): expected {}, got {max_paths}", instance.max_paths - ))); + ) + .into()); } Ok(instance) } diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index 4ac915a1e..e98895bb7 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -40,7 +40,9 @@ inventory::submit! { /// /// A valid configuration must select edges that form exactly one connected /// simple circuit using only edges from `graph`. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "LongestCircuitData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] pub struct LongestCircuit { graph: G, edge_lengths: Vec, @@ -53,14 +55,15 @@ struct LongestCircuitData { edge_lengths: Vec, } -impl<'de, G, W> Deserialize<'de> for LongestCircuit +impl TryFrom> for LongestCircuit where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = LongestCircuitData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.edge_lengths).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: LongestCircuitData) -> Result { + Self::try_new(data.graph, data.edge_lengths) } } diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index a40483fa0..50f3a9303 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -40,7 +40,9 @@ inventory::submit! { /// /// A valid configuration must select exactly the edges of one simple /// undirected path from `source_vertex` to `target_vertex`. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "LongestPathData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] pub struct LongestPath { graph: G, edge_lengths: Vec, @@ -57,20 +59,20 @@ struct LongestPathData { target_vertex: usize, } -impl<'de, G, W> Deserialize<'de> for LongestPath +impl TryFrom> for LongestPath where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = LongestPathData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: LongestPathData) -> Result { Self::try_new( data.graph, data.edge_lengths, data.source_vertex, data.target_vertex, ) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index ed736b430..607a40f65 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -67,7 +67,9 @@ inventory::submit! { /// assert_eq!(size, Max(Some(2))); /// } /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaxCutData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MaxCut { /// The underlying graph structure. graph: G, @@ -81,14 +83,15 @@ struct MaxCutData { edge_weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MaxCut +impl TryFrom> for MaxCut where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MaxCutData::deserialize(deserializer)?; - Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaxCutData) -> Result { + Self::try_new(data.graph, data.edge_weights) } } diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index cb12e3190..3f73f7cfa 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -53,7 +53,9 @@ inventory::submit! { /// assert!(problem.evaluate(sol).unwrap().is_valid()); /// } /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximalISData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MaximalIS { /// The underlying graph. graph: G, @@ -67,14 +69,15 @@ struct MaximalISData { weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MaximalIS +impl TryFrom> for MaximalIS where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MaximalISData::deserialize(deserializer)?; - Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximalISData) -> Result { + Self::try_new(data.graph, data.weights) } } diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 9d98c1717..a39f3c40d 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -56,7 +56,9 @@ inventory::submit! { /// // Maximum clique in a triangle (K3) is size 3 /// assert!(solutions.iter().all(|s| s.iter().filter(|&&selected| selected).count() == 3)); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximumCliqueData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MaximumClique { /// The underlying graph. graph: G, @@ -70,14 +72,15 @@ struct MaximumCliqueData { weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MaximumClique +impl TryFrom> for MaximumClique where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MaximumCliqueData::deserialize(deserializer)?; - Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumCliqueData) -> Result { + Self::try_new(data.graph, data.weights) } } diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs index 473980f6e..9b482cd05 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -63,7 +63,11 @@ inventory::submit! { /// MaximumCoKPlex::<_, One, KN>::with_k(graph, vec![One; 5], 2); /// assert_eq!(problem.bound_k(), 2); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximumCoKPlexData")] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>, K: KValue" +))] pub struct MaximumCoKPlex { /// The underlying graph. graph: G, @@ -87,15 +91,16 @@ struct MaximumCoKPlexData { bound_k: usize, } -impl<'de, G, W, K> Deserialize<'de> for MaximumCoKPlex +impl TryFrom> for MaximumCoKPlex where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, K: KValue, { - fn deserialize>(deserializer: D) -> Result { - let data = MaximumCoKPlexData::deserialize(deserializer)?; - Self::try_with_k(data.graph, data.weights, data.bound_k).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumCoKPlexData) -> Result { + Self::try_with_k(data.graph, data.weights, data.bound_k) } } diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 27b0152a1..724f9016c 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -58,7 +58,9 @@ inventory::submit! { /// // Maximum independent set in a triangle has size 1 /// assert!(solutions.iter().all(|s| s.iter().filter(|&&selected| selected).count() == 1)); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximumIndependentSetData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MaximumIndependentSet { /// The underlying graph. graph: G, @@ -72,14 +74,15 @@ struct MaximumIndependentSetData { weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MaximumIndependentSet +impl TryFrom> for MaximumIndependentSet where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MaximumIndependentSetData::deserialize(deserializer)?; - Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumIndependentSetData) -> Result { + Self::try_new(data.graph, data.weights) } } diff --git a/src/models/graph/maximum_leaf_spanning_tree.rs b/src/models/graph/maximum_leaf_spanning_tree.rs index 8dbc9ae0e..c7fb0d973 100644 --- a/src/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/models/graph/maximum_leaf_spanning_tree.rs @@ -43,7 +43,9 @@ inventory::submit! { /// # Type Parameters /// /// * `G` - The graph type (e.g., `SimpleGraph`) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximumLeafSpanningTreeData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct MaximumLeafSpanningTree { /// The underlying graph. graph: G, @@ -55,13 +57,14 @@ struct MaximumLeafSpanningTreeData { graph: G, } -impl<'de, G> Deserialize<'de> for MaximumLeafSpanningTree +impl TryFrom> for MaximumLeafSpanningTree where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = MaximumLeafSpanningTreeData::::deserialize(deserializer)?; - Self::try_new(data.graph).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumLeafSpanningTreeData) -> Result { + Self::try_new(data.graph) } } diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index 00393c3fb..5c7b9b08c 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -56,7 +56,9 @@ inventory::submit! { /// assert_eq!(sol.iter().filter(|&&selected| selected).count(), 1); /// } /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximumMatchingData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MaximumMatching { /// The underlying graph. graph: G, @@ -70,14 +72,15 @@ struct MaximumMatchingData { edge_weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MaximumMatching +impl TryFrom> for MaximumMatching where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MaximumMatchingData::deserialize(deserializer)?; - Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumMatchingData) -> Result { + Self::try_new(data.graph, data.edge_weights) } } diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index 165aee0d8..661fa6c51 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -53,7 +53,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinMaxMulticenterData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] pub struct MinMaxMulticenter { /// The underlying graph. graph: G, @@ -74,15 +76,15 @@ struct MinMaxMulticenterData { k: usize, } -impl<'de, G, W> Deserialize<'de> for MinMaxMulticenter +impl TryFrom> for MinMaxMulticenter where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = MinMaxMulticenterData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinMaxMulticenterData) -> Result { Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index eb9fec5c0..8ce48e7d5 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -48,7 +48,11 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `W` - The weight type for edges and requirements (e.g., `i64`) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumCapacitatedSpanningTreeData")] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] pub struct MinimumCapacitatedSpanningTree { /// The underlying graph. graph: G, @@ -74,14 +78,15 @@ struct MinimumCapacitatedSpanningTreeData { capacity: W::Sum, } -impl<'de, G, W> Deserialize<'de> for MinimumCapacitatedSpanningTree +impl TryFrom> + for MinimumCapacitatedSpanningTree where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, - W::Sum: Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumCapacitatedSpanningTreeData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumCapacitatedSpanningTreeData) -> Result { Self::try_new( data.graph, data.weights, @@ -89,7 +94,6 @@ where data.requirements, data.capacity, ) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index b18db9deb..8d03f2846 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -56,7 +56,9 @@ inventory::submit! { /// let val = problem.evaluate(&vec![false, false, true, true]).unwrap(); /// assert_eq!(val, problemreductions::types::Min(Some(1))); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumCutIntoBoundedSetsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] pub struct MinimumCutIntoBoundedSets { /// The underlying graph structure. graph: G, @@ -80,13 +82,14 @@ struct MinimumCutIntoBoundedSetsData { size_bound: usize, } -impl<'de, G, W> Deserialize<'de> for MinimumCutIntoBoundedSets +impl TryFrom> for MinimumCutIntoBoundedSets where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumCutIntoBoundedSetsData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumCutIntoBoundedSetsData) -> Result { Self::try_new( data.graph, data.edge_weights, @@ -94,7 +97,6 @@ where data.sink, data.size_bound, ) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index bcf4d598b..c21a23dc0 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -52,7 +52,9 @@ inventory::submit! { /// // Minimum dominating set is just the center vertex /// assert!(solutions.contains(&vec![true, false, false, false])); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumDominatingSetData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MinimumDominatingSet { /// The underlying graph. graph: G, @@ -66,14 +68,15 @@ struct MinimumDominatingSetData { weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MinimumDominatingSet +impl TryFrom> for MinimumDominatingSet where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumDominatingSetData::deserialize(deserializer)?; - Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumDominatingSetData) -> Result { + Self::try_new(data.graph, data.weights) } } diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index f4ebfee82..7352d492d 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -55,7 +55,9 @@ inventory::submit! { /// // Minimum FAS has size 1 (remove any single arc to break the cycle) /// assert_eq!(solution.iter().filter(|&&selected| selected).count(), 1); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumFeedbackArcSetData")] +#[serde(bound(deserialize = "W: Clone + Default + Deserialize<'de>"))] pub struct MinimumFeedbackArcSet { /// The directed graph. graph: DirectedGraph, @@ -69,13 +71,14 @@ struct MinimumFeedbackArcSetData { weights: Vec, } -impl<'de, W> Deserialize<'de> for MinimumFeedbackArcSet +impl TryFrom> for MinimumFeedbackArcSet where - W: Clone + Default + Deserialize<'de>, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumFeedbackArcSetData::deserialize(deserializer)?; - Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumFeedbackArcSetData) -> Result { + Self::try_new(data.graph, data.weights) } } diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index ef796bb35..113b04f84 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -49,7 +49,9 @@ inventory::submit! { /// // Any single vertex breaks the cycle /// assert_eq!(solutions.len(), 3); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumFeedbackVertexSetData")] +#[serde(bound(deserialize = "W: Clone + Default + Deserialize<'de>"))] pub struct MinimumFeedbackVertexSet { /// The underlying directed graph. graph: DirectedGraph, @@ -63,13 +65,14 @@ struct MinimumFeedbackVertexSetData { weights: Vec, } -impl<'de, W> Deserialize<'de> for MinimumFeedbackVertexSet +impl TryFrom> for MinimumFeedbackVertexSet where - W: Clone + Default + Deserialize<'de>, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumFeedbackVertexSetData::deserialize(deserializer)?; - Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumFeedbackVertexSetData) -> Result { + Self::try_new(data.graph, data.weights) } } diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 944509185..8be80045e 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -42,7 +42,9 @@ inventory::submit! { /// /// A configuration is feasible if removing the cut edges disconnects all /// terminal pairs. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumMultiwayCutData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MinimumMultiwayCut { graph: G, terminals: Vec, @@ -56,15 +58,15 @@ struct MinimumMultiwayCutData { edge_weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MinimumMultiwayCut +impl TryFrom> for MinimumMultiwayCut where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumMultiwayCutData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumMultiwayCutData) -> Result { Self::try_new(data.graph, data.terminals, data.edge_weights) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index cf0f2bcfd..e202b2dd2 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -54,7 +54,9 @@ inventory::submit! { /// // Center at vertex 1 gives total distance 0+1+1 = 2 (optimal) /// assert_eq!(solution, vec![false, true, false]); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumSumMulticenterData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MinimumSumMulticenter { /// The underlying graph. graph: G, @@ -75,15 +77,15 @@ struct MinimumSumMulticenterData { k: usize, } -impl<'de, G, W> Deserialize<'de> for MinimumSumMulticenter +impl TryFrom> for MinimumSumMulticenter where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumSumMulticenterData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumSumMulticenterData) -> Result { Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index be7910b79..c3195f3a4 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -52,7 +52,9 @@ inventory::submit! { /// // Minimum vertex cover is just vertex 1 /// assert!(solutions.contains(&vec![false, true, false])); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumVertexCoverData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct MinimumVertexCover { /// The underlying graph. graph: G, @@ -66,14 +68,15 @@ struct MinimumVertexCoverData { weights: Vec, } -impl<'de, G, W> Deserialize<'de> for MinimumVertexCover +impl TryFrom> for MinimumVertexCover where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumVertexCoverData::deserialize(deserializer)?; - Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumVertexCoverData) -> Result { + Self::try_new(data.graph, data.weights) } } diff --git a/src/models/graph/monochromatic_triangle.rs b/src/models/graph/monochromatic_triangle.rs index f454fb936..b4ef79649 100644 --- a/src/models/graph/monochromatic_triangle.rs +++ b/src/models/graph/monochromatic_triangle.rs @@ -57,7 +57,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MonochromaticTriangleData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct MonochromaticTriangle { /// The underlying graph. graph: G, @@ -74,12 +76,13 @@ struct MonochromaticTriangleData { graph: G, } -impl<'de, G> Deserialize<'de> for MonochromaticTriangle +impl TryFrom> for MonochromaticTriangle where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = MonochromaticTriangleData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: MonochromaticTriangleData) -> Result { Ok(Self::new(data.graph)) } } diff --git a/src/models/graph/partition_into_cliques.rs b/src/models/graph/partition_into_cliques.rs index 661e13c10..a98ccc8f6 100644 --- a/src/models/graph/partition_into_cliques.rs +++ b/src/models/graph/partition_into_cliques.rs @@ -53,7 +53,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PartitionIntoCliquesData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoCliques { /// The underlying graph. graph: G, @@ -68,13 +70,14 @@ struct PartitionIntoCliquesData { num_cliques: usize, } -impl<'de, G> Deserialize<'de> for PartitionIntoCliques +impl TryFrom> for PartitionIntoCliques where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = PartitionIntoCliquesData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.num_cliques).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoCliquesData) -> Result { + Self::try_new(data.graph, data.num_cliques) } } diff --git a/src/models/graph/partition_into_forests.rs b/src/models/graph/partition_into_forests.rs index 4fe68f062..1d5c02893 100644 --- a/src/models/graph/partition_into_forests.rs +++ b/src/models/graph/partition_into_forests.rs @@ -54,7 +54,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PartitionIntoForestsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoForests { /// The underlying graph. graph: G, @@ -69,13 +71,14 @@ struct PartitionIntoForestsData { num_forests: usize, } -impl<'de, G> Deserialize<'de> for PartitionIntoForests +impl TryFrom> for PartitionIntoForests where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = PartitionIntoForestsData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.num_forests).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoForestsData) -> Result { + Self::try_new(data.graph, data.num_forests) } } diff --git a/src/models/graph/partition_into_paths_of_length_2.rs b/src/models/graph/partition_into_paths_of_length_2.rs index 65bb37f01..01bd8569a 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -58,7 +58,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PartitionIntoPathsOfLength2Data")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoPathsOfLength2 { /// The underlying graph. graph: G, @@ -70,13 +72,14 @@ struct PartitionIntoPathsOfLength2Data { graph: G, } -impl<'de, G> Deserialize<'de> for PartitionIntoPathsOfLength2 +impl TryFrom> for PartitionIntoPathsOfLength2 where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = PartitionIntoPathsOfLength2Data::::deserialize(deserializer)?; - Self::try_new(data.graph).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoPathsOfLength2Data) -> Result { + Self::try_new(data.graph) } } diff --git a/src/models/graph/partition_into_perfect_matchings.rs b/src/models/graph/partition_into_perfect_matchings.rs index 4b6b2ced4..25aceba7a 100644 --- a/src/models/graph/partition_into_perfect_matchings.rs +++ b/src/models/graph/partition_into_perfect_matchings.rs @@ -55,7 +55,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PartitionIntoPerfectMatchingsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoPerfectMatchings { /// The underlying graph. graph: G, @@ -70,13 +72,14 @@ struct PartitionIntoPerfectMatchingsData { num_matchings: usize, } -impl<'de, G> Deserialize<'de> for PartitionIntoPerfectMatchings +impl TryFrom> for PartitionIntoPerfectMatchings where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = PartitionIntoPerfectMatchingsData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.num_matchings).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoPerfectMatchingsData) -> Result { + Self::try_new(data.graph, data.num_matchings) } } diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs index ce4f0c8a8..772f82471 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -50,7 +50,9 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PartitionIntoTrianglesData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoTriangles { /// The underlying graph. graph: G, @@ -62,13 +64,14 @@ struct PartitionIntoTrianglesData { graph: G, } -impl<'de, G> Deserialize<'de> for PartitionIntoTriangles +impl TryFrom> for PartitionIntoTriangles where - G: Graph + Deserialize<'de>, + G: Graph, { - fn deserialize>(deserializer: D) -> Result { - let data = PartitionIntoTrianglesData::::deserialize(deserializer)?; - Self::try_new(data.graph).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoTrianglesData) -> Result { + Self::try_new(data.graph) } } diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 66e041f7a..da7737b8a 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -52,7 +52,9 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `W` - The weight type for edge lengths (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "RuralPostmanData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] pub struct RuralPostman { /// The underlying graph. graph: G, @@ -70,15 +72,15 @@ struct RuralPostmanData { required_edges: Vec, } -impl<'de, G, W> Deserialize<'de> for RuralPostman +impl TryFrom> for RuralPostman where - G: Graph + Deserialize<'de>, - W: WeightElement + Deserialize<'de>, + G: Graph, + W: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = RuralPostmanData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: RuralPostmanData) -> Result { Self::try_new(data.graph, data.edge_lengths, data.required_edges) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index 3fcee5ce2..b95ce34d8 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -51,7 +51,11 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `N` - The edge length / weight type (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ShortestWeightConstrainedPathData")] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, N: WeightElement + Deserialize<'de>, N::Sum: Deserialize<'de>" +))] pub struct ShortestWeightConstrainedPath { /// The underlying graph. graph: G, @@ -80,14 +84,14 @@ struct ShortestWeightConstrainedPathData { weight_bound: N::Sum, } -impl<'de, G, N> Deserialize<'de> for ShortestWeightConstrainedPath +impl TryFrom> for ShortestWeightConstrainedPath where - G: Graph + Deserialize<'de>, - N: WeightElement + Deserialize<'de>, - N::Sum: Deserialize<'de>, + G: Graph, + N: WeightElement, { - fn deserialize>(deserializer: D) -> Result { - let data = ShortestWeightConstrainedPathData::::deserialize(deserializer)?; + type Error = crate::registry::ConstructionError; + + fn try_from(data: ShortestWeightConstrainedPathData) -> Result { Self::try_new( data.graph, data.edge_lengths, @@ -96,7 +100,6 @@ where data.target_vertex, data.weight_bound, ) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index 155ec4b87..554453069 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -47,7 +47,9 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`, `KingsSubgraph`) /// * `W` - The weight type for edges (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "TravelingSalesmanData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct TravelingSalesman { /// The underlying graph. graph: G, @@ -61,14 +63,15 @@ struct TravelingSalesmanData { edge_weights: Vec, } -impl<'de, G, W> Deserialize<'de> for TravelingSalesman +impl TryFrom> for TravelingSalesman where - G: Graph + Deserialize<'de>, - W: Clone + Default + Deserialize<'de>, + G: Graph, + W: Clone + Default, { - fn deserialize>(deserializer: D) -> Result { - let data = TravelingSalesmanData::::deserialize(deserializer)?; - Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + type Error = crate::registry::ConstructionError; + + fn try_from(data: TravelingSalesmanData) -> Result { + Self::try_new(data.graph, data.edge_weights) } } diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index 1f7835b05..a17af7951 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -54,7 +54,11 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumTardinessSequencingData")] +#[serde(bound( + deserialize = "W: Deserialize<'de>, Self: TryFrom>, >>::Error: std::fmt::Display" +))] pub struct MinimumTardinessSequencing { lengths: Vec, deadlines: Vec, @@ -68,19 +72,19 @@ struct MinimumTardinessSequencingData { precedences: Vec<(usize, usize)>, } -impl<'de> Deserialize<'de> for MinimumTardinessSequencing { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumTardinessSequencingData::::deserialize(deserializer)?; +impl TryFrom> for MinimumTardinessSequencing { + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumTardinessSequencingData) -> Result { Self::try_new(data.lengths.len(), data.deadlines, data.precedences) - .map_err(serde::de::Error::custom) } } -impl<'de> Deserialize<'de> for MinimumTardinessSequencing { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumTardinessSequencingData::::deserialize(deserializer)?; +impl TryFrom> for MinimumTardinessSequencing { + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumTardinessSequencingData) -> Result { Self::try_with_lengths(data.lengths, data.deadlines, data.precedences) - .map_err(serde::de::Error::custom) } } diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 14d550990..0a7f0db09 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -55,7 +55,9 @@ inventory::submit! { /// assert!(problem.evaluate(&sol).unwrap().is_valid()); /// } /// ``` -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumSetCoveringData")] +#[serde(bound(deserialize = "W: Clone + Default + Deserialize<'de>"))] pub struct MinimumSetCovering { /// Size of the universe (elements are 0..universe_size). universe_size: usize, @@ -72,11 +74,14 @@ struct MinimumSetCoveringData { weights: Vec, } -impl<'de, W: Clone + Default + Deserialize<'de>> Deserialize<'de> for MinimumSetCovering { - fn deserialize>(deserializer: D) -> Result { - let data = MinimumSetCoveringData::deserialize(deserializer)?; +impl TryFrom> for MinimumSetCovering +where + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumSetCoveringData) -> Result { Self::try_with_weights(data.universe_size, data.sets, data.weights) - .map_err(serde::de::Error::custom) } } diff --git a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs index b91c293db..2e6dc7ec6 100644 --- a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs +++ b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs @@ -190,3 +190,20 @@ fn test_minimum_matrix_cover_rejects_negative_entries() { ); assert!(std::panic::catch_unwind(|| MinimumMatrixCover::new(vec![vec![-1]])).is_err()); } + +#[test] +fn test_deserialization_preserves_data_shape_errors() { + for (input, expected) in [ + ( + serde_json::Value::Null, + "invalid type: null, expected struct Data", + ), + ( + serde_json::json!([]), + "invalid length 0, expected struct Data with 1 element", + ), + ] { + let error = serde_json::from_value::(input).unwrap_err(); + assert_eq!(error.to_string(), expected); + } +} From be05d567341aa33686f023ed772dc6e8a5ac1565 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 25 Sep 2026 15:14:58 +0800 Subject: [PATCH 44/44] Store QUBO coefficients sparsely instead of capping num_vars QUBO keeps sorted upper-triangular nonzero entries, so memory follows the input instead of num_vars^2. from_entries is the shared validating constructor for the persisted format and the i64->f64 cast; evaluate, QUBO->ILP and QUBO->SpinGlass iterate entries in the same order as the old dense scan. matrix() now returns an owned dense copy. Removes the hardcoded 8192-variable limit. Co-Authored-By: Claude Opus 5.5 --- src/models/algebraic/qubo.rs | 169 +++++++++++++----------- src/rules/qubo_casts.rs | 13 +- src/rules/qubo_ilp.rs | 28 ++-- src/rules/spinglass_qubo.rs | 36 +++-- src/unit_tests/models/algebraic/qubo.rs | 39 ++++-- 5 files changed, 151 insertions(+), 134 deletions(-) diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 0231bbd48..02b6a7153 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -61,9 +61,11 @@ inventory::submit! { pub struct QUBO { /// Number of variables. num_vars: usize, - /// Q matrix stored as upper triangular (row-major). - /// `Q[i][j]` for i <= j represents the coefficient of x_i * x_j - matrix: Vec>, + /// Nonzero upper-triangular coefficients `(i, j, Q[i][j])` with `i <= j`, + /// sorted by `(i, j)`; absent entries are zero. + entries: Vec<(usize, usize, W)>, + /// Zero coefficient returned by [`QUBO::get`] for absent entries. + zero: W, } #[derive(Serialize, Deserialize)] @@ -104,73 +106,23 @@ impl TryFrom> for QUBO { impl Serialize for QUBO { fn serialize(&self, serializer: S) -> Result { - let entries = self - .matrix - .iter() - .enumerate() - .flat_map(|(row, values)| { - values - .iter() - .enumerate() - .skip(row) - .filter_map(move |(column, value)| { - (!value.to_sum().is_zero()).then_some((row, column, value)) - }) - }) - .collect(); QuboData { num_vars: self.num_vars, - entries, + entries: self + .entries + .iter() + .map(|(i, j, value)| (*i, *j, value)) + .collect(), } .serialize(serializer) } } -/// Largest `num_vars` accepted from the sparse persisted format. -const MAX_PERSISTED_QUBO_VARS: usize = 8192; - impl TryFrom> for QUBO { type Error = ConstructionError; - fn try_from(mut data: QuboData) -> Result { - for &(row, column, _) in &data.entries { - if row >= data.num_vars || column >= data.num_vars { - return Err(ConstructionError::Conversion(format!( - "QUBO index ({row}, {column}) is outside 0..{}", - data.num_vars - ))); - } - } - for &(row, column, _) in &data.entries { - if row > column { - return Err(ConstructionError::Conversion(format!( - "QUBO index ({row}, {column}) is below the diagonal; use ({column}, {row}) instead" - ))); - } - } - data.entries.sort_by_key(|&(row, column, _)| (row, column)); - for pair in data.entries.windows(2) { - if (pair[0].0, pair[0].1) == (pair[1].0, pair[1].1) { - return Err(ConstructionError::Conversion(format!( - "duplicate QUBO index ({}, {})", - pair[0].0, pair[0].1 - ))); - } - } - // ponytail: the sparse format still loads into a dense matrix, so cap - // num_vars (8192^2 cells) to keep a tiny file from demanding n^2 memory. - // Store the matrix sparsely if larger persisted QUBOs are needed. - if data.num_vars > MAX_PERSISTED_QUBO_VARS { - return Err(ConstructionError::Conversion(format!( - "QUBO with {} variables is too large to load (at most {MAX_PERSISTED_QUBO_VARS})", - data.num_vars - ))); - } - let mut matrix = vec![vec![W::default(); data.num_vars]; data.num_vars]; - for (row, column, value) in data.entries { - matrix[row][column] = value; - } - Self::from_matrix(matrix) + fn try_from(data: QuboData) -> Result { + Self::from_entries(data.num_vars, data.entries) } } @@ -210,7 +162,59 @@ impl QUBO { value.validate_element(&format!("QUBO coefficient at ({row}, {column})"))?; } } - Ok(Self { num_vars, matrix }) + let entries = matrix + .into_iter() + .enumerate() + .flat_map(|(row, values)| { + values + .into_iter() + .enumerate() + .skip(row) + .map(move |(column, value)| (row, column, value)) + }) + .collect(); + Self::from_entries(num_vars, entries) + } + + /// Create a QUBO from sparse coefficients `(i, j, Q[i][j])`. + /// + /// Indices must be in `0..num_vars` with `i <= j`, and each pair may + /// appear at most once. Zero coefficients are dropped. + pub fn from_entries( + num_vars: usize, + mut entries: Vec<(usize, usize, W)>, + ) -> Result { + for (row, column, value) in &entries { + if *row >= num_vars || *column >= num_vars { + return Err(ConstructionError::Conversion(format!( + "QUBO index ({row}, {column}) is outside 0..{num_vars}" + ))); + } + if row > column { + return Err(ConstructionError::Conversion(format!( + "QUBO index ({row}, {column}) is below the diagonal; use ({column}, {row}) instead" + ))); + } + value.validate_element(&format!("QUBO coefficient at ({row}, {column})"))?; + } + entries.sort_by_key(|&(row, column, _)| (row, column)); + if let Some(pair) = entries + .windows(2) + .find(|pair| (pair[0].0, pair[0].1) == (pair[1].0, pair[1].1)) + { + return Err(ConstructionError::Conversion(format!( + "duplicate QUBO index ({}, {})", + pair[0].0, pair[0].1 + ))); + } + Ok(Self { + num_vars, + entries: entries + .into_iter() + .filter(|(_, _, value)| !value.to_sum().is_zero()) + .collect(), + zero: W::default(), + }) } /// Create a QUBO from linear and quadratic terms. @@ -248,20 +252,36 @@ impl QUBO { } } -impl QUBO { +impl QUBO { /// Get the number of variables. pub fn num_vars(&self) -> usize { self.num_vars } - /// Get the Q matrix. - pub fn matrix(&self) -> &[Vec] { - &self.matrix + /// Nonzero upper-triangular coefficients `(i, j, Q[i][j])`, sorted by `(i, j)`. + pub fn entries(&self) -> &[(usize, usize, W)] { + &self.entries } - /// Get a specific matrix element `Q[i][j]`. + /// Dense upper-triangular copy of Q. Allocates `num_vars^2` elements. + pub fn matrix(&self) -> Vec> { + let mut matrix = vec![vec![self.zero.clone(); self.num_vars]; self.num_vars]; + for (i, j, value) in &self.entries { + matrix[*i][*j] = value.clone(); + } + matrix + } + + /// Get a specific matrix element `Q[i][j]`; entries below the diagonal are zero. pub fn get(&self, i: usize, j: usize) -> Option<&W> { - self.matrix.get(i).and_then(|row| row.get(j)) + if i >= self.num_vars || j >= self.num_vars { + return None; + } + Some( + self.entries + .binary_search_by_key(&(i, j), |&(row, column, _)| (row, column)) + .map_or(&self.zero, |index| &self.entries[index].2), + ) } } @@ -289,20 +309,11 @@ where )); } let mut value = W::Sum::zero(); - - for i in 0..self.num_vars { - if !solution[i] { - continue; - } - - for (j, &selected) in solution.iter().enumerate().skip(i) { - if !selected { - continue; - } - + for (i, j, coefficient) in &self.entries { + if solution[*i] && solution[*j] { value = W::checked_add_to_sum( value, - self.matrix[i][j].to_sum(), + coefficient.to_sum(), "summing selected QUBO coefficients", )?; } diff --git a/src/rules/qubo_casts.rs b/src/rules/qubo_casts.rs index 841c432cc..bef444980 100644 --- a/src/rules/qubo_casts.rs +++ b/src/rules/qubo_casts.rs @@ -10,20 +10,15 @@ impl_variant_reduction!( => , fields: [num_vars], |src| { - let matrix = src - .matrix() + let entries = src + .entries() .iter() - .map(|row| { - row.iter() - .copied() - .map(i64_to_exact_f64) - .collect::, _>>() - }) + .map(|&(i, j, value)| i64_to_exact_f64(value).map(|value| (i, j, value))) .collect::, _>>() .map_err(|error| { ReductionError::inexact_float_conversion::, QUBO>(error) })?; - QUBO::from_matrix(matrix) + QUBO::from_entries(src.num_vars(), entries) .map_err(ReductionError::construction::, QUBO>)? } ); diff --git a/src/rules/qubo_ilp.rs b/src/rules/qubo_ilp.rs index 2dd22909c..e08a10368 100644 --- a/src/rules/qubo_ilp.rs +++ b/src/rules/qubo_ilp.rs @@ -55,29 +55,21 @@ where C: ILPCoefficient + crate::variant::VariantParam + From, { let n = source.num_vars(); - let matrix = source.matrix(); - - // Collect non-zero off-diagonal entries (i < j) - let mut off_diag: Vec<(usize, usize, C)> = Vec::new(); - for (i, row) in matrix.iter().enumerate() { - for (j, &q_ij) in row.iter().enumerate().skip(i + 1) { - if q_ij != C::zero() { - off_diag.push((i, j, q_ij)); - } - } - } + let entries = source.entries(); + + // Non-zero off-diagonal entries (i < j), one auxiliary product variable each + let off_diag: Vec<(usize, usize, C)> = + entries.iter().copied().filter(|&(i, j, _)| i < j).collect(); let m = off_diag.len(); let total_vars = n + m; // Objective: minimize Σ Q_ii · x_i + Σ Q_ij · y_k - let mut objective: Vec<(usize, C)> = Vec::new(); - for (i, row) in matrix.iter().enumerate() { - let q_ii = row[i]; - if q_ii != C::zero() { - objective.push((i, q_ii)); - } - } + let mut objective: Vec<(usize, C)> = entries + .iter() + .filter(|&&(i, j, _)| i == j) + .map(|&(i, _, q_ii)| (i, q_ii)) + .collect(); for (k, &(_, _, q_ij)) in off_diag.iter().enumerate() { objective.push((n + k, q_ij)); } diff --git a/src/rules/spinglass_qubo.rs b/src/rules/spinglass_qubo.rs index 1a6a900ad..31d1c46d3 100644 --- a/src/rules/spinglass_qubo.rs +++ b/src/rules/spinglass_qubo.rs @@ -47,7 +47,6 @@ impl ReduceTo> for QUBO { fn reduce_to(&self) -> Result { let n = self.num_vars(); - let matrix = self.matrix(); // Convert Q matrix to J interactions and h fields // Using substitution s = 2x - 1: @@ -63,27 +62,24 @@ impl ReduceTo> for QUBO { let mut interactions = Vec::new(); let mut onsite = vec![0.0; n]; - for i in 0..n { - for j in i..n { - let q = matrix[i][j]; - if q.abs() < 1e-10 { - continue; - } + for &(i, j, q) in self.entries() { + if q.abs() < 1e-10 { + continue; + } - if i == j { - // Diagonal: Q_ii * x_i = Q_ii/2 * s_i + Q_ii/2 (constant) - onsite[i] += q / 2.0; - } else { - // Off-diagonal: Q_ij * x_i * x_j - // J_ij contribution - let j_ij = q / 4.0; - if j_ij.abs() > 1e-10 { - interactions.push(((i, j), j_ij)); - } - // h_i and h_j contributions - onsite[i] += q / 4.0; - onsite[j] += q / 4.0; + if i == j { + // Diagonal: Q_ii * x_i = Q_ii/2 * s_i + Q_ii/2 (constant) + onsite[i] += q / 2.0; + } else { + // Off-diagonal: Q_ij * x_i * x_j + // J_ij contribution + let j_ij = q / 4.0; + if j_ij.abs() > 1e-10 { + interactions.push(((i, j), j_ij)); } + // h_i and h_j contributions + onsite[i] += q / 4.0; + onsite[j] += q / 4.0; } } diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 215c9a3ed..2c9539116 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -271,14 +271,37 @@ fn test_integer_qubo_reports_objective_overflow() { } #[test] -fn test_qubo_entries_reject_oversized_num_vars() { - for num_vars in [MAX_PERSISTED_QUBO_VARS + 1, 20_000, usize::MAX] { - let error = QUBO::::try_from(QuboData { - num_vars, - entries: vec![], - }) - .unwrap_err(); - assert!(error.to_string().contains("too large"), "{error}"); +fn test_qubo_entries_load_huge_sparse_instance() { + // Storage is proportional to the entries, not num_vars^2. + let data = + serde_json::json!({"num_vars": 10_000_000_000u64, "entries": [[0, 9_999_999_999u64, 5]]}); + let problem: QUBO = serde_json::from_value(data.clone()).unwrap(); + assert_eq!(problem.num_vars(), 10_000_000_000); + assert_eq!(problem.get(0, 9_999_999_999), Some(&5)); + assert_eq!(problem.get(1, 2), Some(&0)); + assert_eq!(problem.get(10_000_000_000, 0), None); + assert_eq!(serde_json::to_value(&problem).unwrap(), data); +} + +#[test] +fn test_qubo_from_entries() { + let problem = QUBO::from_entries(3, vec![(1, 2, 4), (0, 0, -1), (1, 1, 0)]).unwrap(); + assert_eq!(problem.entries(), &[(0, 0, -1), (1, 2, 4)]); + assert_eq!( + problem.matrix(), + vec![vec![-1, 0, 0], vec![0, 0, 4], vec![0, 0, 0]] + ); + assert_eq!( + problem.evaluate(&vec![true, true, true]).unwrap(), + Min(Some(3)) + ); + for (entries, message) in [ + (vec![(0, 3, 1)], "outside 0..3"), + (vec![(2, 1, 1)], "below the diagonal"), + (vec![(0, 1, 1), (0, 1, 2)], "duplicate QUBO index"), + ] { + let error = QUBO::from_entries(3, entries).unwrap_err(); + assert!(error.to_string().contains(message), "{error}"); } }