diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 438584ded..44934dcf8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -165,8 +165,11 @@ 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 -- Every direct `extract_solution()` must call `validate_target_solution()` once before decoding; composed extractors delegate validation to the first direct decoder. +- `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. 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)`) - Graph types: SimpleGraph, PlanarGraph, BipartiteGraph, UnitDiskGraph, KingsSubgraph, TriangularSubgraph @@ -210,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` 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 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/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/paper/reductions.typ b/docs/paper/reductions.typ index 4aa97f617..781280a3a 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)], @@ -788,7 +803,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 @@ -1402,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) @@ -3279,11 +3298,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 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 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 @@ -4939,7 +4960,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) @@ -5437,14 +5461,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), ) @@ -11525,12 +11549,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"), @@ -11544,7 +11568,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 ], @@ -11555,17 +11579,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 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( "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"), @@ -11579,7 +11603,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 ], @@ -11596,7 +11620,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") @@ -11969,7 +11993,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))) @@ -12026,9 +12053,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: [ @@ -12078,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")[ @@ -12097,7 +12124,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 @@ -12106,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") @@ -12216,17 +12243,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: [ @@ -12243,7 +12270,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. ], @@ -12256,11 +12283,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. ] ] } @@ -12506,18 +12533,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") @@ -12555,9 +12584,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)) $ @@ -12566,12 +12595,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")[ @@ -12693,9 +12722,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: [ @@ -12706,7 +12735,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 ], )[ @@ -12753,9 +12782,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: [ @@ -12766,7 +12795,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 ], )[ @@ -12868,9 +12897,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: [ @@ -12894,7 +12923,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. ], @@ -13008,9 +13037,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: [ @@ -13021,7 +13050,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 ], )[ @@ -13999,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.\ @@ -14010,15 +14039,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 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. ] #let lcs_mis = load-example("LongestCommonSubsequence", "MaximumIndependentSet") @@ -15265,6 +15296,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. ][ @@ -15550,14 +15604,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: [ @@ -15579,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")[ @@ -15606,6 +15660,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. ][ @@ -16604,15 +16681,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`. @@ -16680,22 +16748,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"), @@ -17238,53 +17306,13 @@ 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") -#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) @@ -17535,13 +17563,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") @@ -17656,7 +17684,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$. @@ -17667,14 +17695,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") @@ -17822,13 +17852,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: [ @@ -17841,7 +17871,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\ @@ -17861,10 +17891,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: [ @@ -17877,9 +17907,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. ], @@ -17922,9 +17952,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: [ @@ -17937,7 +17967,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 @@ -18400,9 +18430,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: [ @@ -18415,7 +18445,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. @@ -18611,17 +18641,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) @@ -18820,9 +18852,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: [ @@ -18834,9 +18866,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() [ @@ -18877,11 +18909,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", @@ -18892,7 +18924,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() [ @@ -18928,12 +18960,12 @@ 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", "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: [ @@ -18948,12 +18980,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. ] @@ -19423,9 +19455,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: [ @@ -19529,36 +19561,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/docs/src/cli-commands.md b/docs/src/cli-commands.md index 97eb52217..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,13 +90,38 @@ 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 '[1,0,1,0]' +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. `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` calls the reduction rules' existing mappings. Supply exactly one input: + +- `--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. + +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 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. + +Aggregate-only paths can be constructed with `pred reduce --aggregate` and +recovered with `pred extract --value` through `AggregateReductionChain::extract_value`. ## Solve diff --git a/docs/src/design.md b/docs/src/design.md index cff593427..4caa126b6 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: @@ -264,10 +281,16 @@ 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. -2. Validate any structure required by the inverse mapping, such as exactly-one - blocks, permutations, paths, flows, or schedules. +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. 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. @@ -303,9 +326,81 @@ 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 -`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. +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 | +| --- | --- | --- | +| 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`. + +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 +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 +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 @@ -409,7 +504,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, 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 @@ -435,6 +530,13 @@ 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 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 See [Call for Contributions](index.html#open-questions) for the recommended issue-based workflow (no coding required). 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) 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 dd1518a1b..b841d6a1f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -209,20 +209,18 @@ 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 configuration or value through the reduction rules #[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 - 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`. - -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 --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 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 #[cfg(feature = "mcp")] @@ -331,15 +329,21 @@ pub struct ReduceArgs { /// Explicit reduction route selected from a path-set entry. #[arg(long, required = true)] pub via: PathBuf, + /// Construct an aggregate-value path for recovery with pred extract --value. + #[arg(long)] + pub aggregate: bool, } #[derive(clap::Args)] 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]) + /// 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 config: String, + pub value: Option, } #[derive(clap::Args)] @@ -560,4 +564,28 @@ mod tests { assert_eq!(create.get_subcommands().count(), 0); assert!(create.is_allow_external_subcommands_set()); } + + #[test] + 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()); + assert!(Cli::try_parse_from(["pred", "extract", "bundle.json", "--value", "2"]).is_ok()); + assert!(Cli::try_parse_from([ + "pred", + "extract", + "bundle.json", + "--config", + "[true,false]", + "--value", + "2" + ]) + .is_err()); + } } 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/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index e6babc485..c4794de1a 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"); @@ -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([("coefficient".into(), "i64".into())]); + assert!(create_schema_driven(&args, "ClosestVectorProblem", &variant).is_err()); } #[test] @@ -1444,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] @@ -1986,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/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index 79736a984..55289543e 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -1,61 +1,59 @@ -use crate::dispatch::{read_input, BundleReplay, ReductionBundle}; +use crate::cli::ExtractArgs; +use crate::dispatch::{extract_bundle_value, 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)?; +/// 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!( "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`?" ); } - let bundle: ReductionBundle = serde_json::from_value(json).context("Failed to parse reduction bundle")?; - - 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)?; - - out.emit( - || { - format!( - "Problem: {}\nSolver: external (via {})\nSolution: {:?}\nEvaluation: {}", - replay.source_name, replay.target_name, source_config, source_eval, - ) - }, - || { - // 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, - }, - })) - }, - ) + 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})), + ); + } + 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/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs index 0ff6f61c4..6f9bb2c04 100644 --- a/problemreductions-cli/src/commands/reduce.rs +++ b/problemreductions-cli/src/commands/reduce.rs @@ -61,10 +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, -) -> Result { +fn load_route_source( + problem_json: &ProblemJson, + reduction_path: &ReductionPath, +) -> Result { let source = load_problem( &problem_json.problem_type, &problem_json.variant, @@ -86,25 +86,19 @@ 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(), - )?; + 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(), @@ -127,13 +121,49 @@ pub(crate) fn execute_route( }) } -pub fn reduce(input: &Path, via: &Path, out: &OutputConfig) -> Result<()> { +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 bundle = execute_route(problem_json, reduction_path)?; + let bundle = if aggregate { + execute_aggregate_route(problem_json, reduction_path)? + } else { + execute_route(problem_json, reduction_path)? + }; out.emit( || { diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index ce3c471de..be960dfa3 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, @@ -87,29 +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(); }); - 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) => { - 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/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/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index c48f99f52..883ebc1ca 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 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, @@ -197,7 +205,92 @@ pub struct BundleReplay { pub(crate) source_name: String, pub(crate) target: LoadedProblem, pub(crate) target_name: String, - pub(crate) chain: problemreductions::rules::ReductionChain, + chain: problemreductions::rules::ReductionChain, +} + +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 { @@ -213,81 +306,17 @@ impl BundleReplay { /// /// Returns an error (not a panic) for malformed bundles or paths without witness extraction. pub fn prepare(bundle: &ReductionBundle) -> 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(), - }; - + let (source, target, reduction_path) = load_bundle_endpoints(bundle)?; 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." - ))?; - - // 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())?; - 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." - ); - } - + .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, - source_name, target, - target_name, chain, }) } @@ -307,30 +336,20 @@ impl BundleReplay { Ok((source_config, source_eval)) } + /// 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 { + Ok(self.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, - }, - ) - } - SolveOutcome::Infeasible => (SolveOutcome::Infeasible, SolveOutcome::Infeasible), - }; + let target_outcome = target_result.outcome; + let source_outcome = self.extract_result(&target_outcome)?; Ok(BundleSolveResult { source_name: self.source_name.clone(), @@ -420,6 +439,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; @@ -427,8 +477,297 @@ 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::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 }, + ) + .unwrap(); + 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.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.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}; + 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) + ); + } + } + 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::< + problemreductions::models::decision::Decision>, + >(), + 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 bundle_rejects_infeasible_extracted_witness() { + 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 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::< + problemreductions::models::decision::Decision< + MinimumVertexCover, + >, + >(), + problem_step::>( + ), + ], + ); + 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::{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_aggregate_route(source, path).unwrap(); + assert_eq!(executed.target.data, serde_json::json!({"base":14})); + assert_eq!( + extract_bundle_value(&executed, serde_json::json!(12)).unwrap(), + serde_json::json!(12) + ); + assert!(extract_bundle_value(&executed, serde_json::json!(true)).is_err()); + assert!(BundleReplay::prepare(&executed).is_err()); + } + + #[test] + 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), @@ -450,23 +789,48 @@ mod tests { let route = crate::commands::reduce::parse_path_json( r#"{"path":[{ "from":{"name":"KSatisfiability","variant":{"k":"K3"}}, - "to":{"name":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}} + "to":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"One"}} + },{ + "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(); let replay = BundleReplay::prepare(&bundle).unwrap(); - let result = replay.solve(SolverRequest::BruteForce); + + { + 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_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!( + extract_bundle_value(&bundle, 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-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/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 65a7f15ee..b2fa3993b 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -381,11 +381,14 @@ fn test_reduce_rejects_discontinuous_explicit_route() { fn test_solve() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - let result = server.solve_inner(&problem_json, Some("brute-force"), None); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert!(json["solution"].is_array()); - assert_eq!(json["solver"]["kind"], "brute-force"); + for timeout in [None, Some(5)] { + let result = server + .solve_inner(&problem_json, Some("brute-force"), timeout) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert!(json["solution"].is_array()); + assert_eq!(json["solver"]["kind"], "brute-force"); + } } #[test] @@ -487,11 +490,14 @@ fn test_solve_bundle() { ), ) .unwrap(); - let result = server.solve_inner(&bundle_json, Some("brute-force"), None); - assert!(result.is_ok()); - let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert!(json["solution"].is_array()); - assert_eq!(json["problem"], "MaximumIndependentSet"); + for timeout in [None, Some(5)] { + let result = server + .solve_inner(&bundle_json, Some("brute-force"), timeout) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert!(json["solution"].is_array()); + assert_eq!(json["problem"], "MaximumIndependentSet"); + } } #[test] diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 0c3da5a08..7a32f99e0 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -447,35 +447,25 @@ impl McpServer { && json.get("target").is_some() && json.get("path").is_some(); + let run = move || -> 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(); }); - match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) { - Ok(result) => result, - Err(_) => anyhow::bail!("Solve timed out after {} seconds", timeout_secs), - } - } else if is_bundle { - let bundle: ReductionBundle = serde_json::from_value(json)?; - solve_bundle_inner(bundle, request) + rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) + .map_err(|error| crate::dispatch::solve_worker_error(error, timeout_secs))? } else { - let pj: ProblemJson = serde_json::from_value(json)?; - solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, request) + run() } } } diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index ba3d7e15d..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) } @@ -316,6 +319,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 +368,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 +408,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 +449,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..4f9cd1949 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1,9 +1,46 @@ 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 has length 0, expected 1"), + "{stderr}" + ); + assert!(!stderr.contains("panicked"), "{stderr}"); +} + fn pred() -> Command { Command::new(env!("CARGO_BIN_EXE_pred")) } +fn extract_target_config( + bundle: &std::path::Path, + config: serde_json::Value, +) -> std::process::Output { + pred() + .args([ + "extract", + bundle.to_str().unwrap(), + "--config", + &config.to_string(), + "--json", + ]) + .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"]) @@ -116,7 +153,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")); @@ -971,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")); } @@ -1139,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")); } @@ -1373,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")); } @@ -3067,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}" ); } @@ -3125,6 +3162,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(); } @@ -3585,7 +3628,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"); @@ -3624,8 +3667,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(); } @@ -3810,7 +3855,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] @@ -8632,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 has 7 entries, expected 8"), + stderr.contains("edge lengths has length 7, expected 8"), "stderr: {stderr}" ); } @@ -8678,7 +8726,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!( @@ -8766,7 +8814,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}" ); } @@ -9557,6 +9605,143 @@ fn extract_test_solve_bundle(bundle_file: &std::path::Path) -> (String, String) (target_solution, source_eval) } +#[test] +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 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 source = Decision::new( + MinimumVertexCover::new(SimpleGraph::cycle(3), vec![1i64; 3]), + bound, + ); + 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(), + "--value", + "2", + "--json", + ]) + .output() + .unwrap(); + assert!( + mapped.status.success(), + "{}", + String::from_utf8_lossy(&mapped.stderr) + ); + 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([ + "extract", + bundle.to_str().unwrap(), + "--value", + "true", + "--json", + ]) + .output() + .unwrap(); + 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([ + "solve", + bundle.to_str().unwrap(), + "--solver", + solver, + "--json", + ]) + .output() + .unwrap(); + assert!( + solved.status.success(), + "{}", + 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" } + ); + } + } + std::fs::remove_file(bundle).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. + 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(); +} + #[test] fn test_extract_roundtrip_mis_to_qubo() { let problem_file = std::env::temp_dir().join("pred_test_extract_in.json"); @@ -9598,16 +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 = pred() - .args([ - "--json", - "extract", - bundle_file.to_str().unwrap(), - "--config", - &target_cfg, - ]) - .output() - .unwrap(); + let extract_out = + extract_target_config(&bundle_file, serde_json::from_str(&target_cfg).unwrap()); assert!( extract_out.status.success(), "extract stderr: {}", @@ -9617,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"); @@ -9684,19 +9861,14 @@ 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_config( + &bundle_file, + 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(); 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}" ); @@ -9721,15 +9893,7 @@ 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_config(&problem_file, serde_json::json!([false, true, false])); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -9770,15 +9934,7 @@ 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_config(&bundle_file, serde_json::json!([false, true])); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( @@ -9825,17 +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 = pred() - .args([ - "extract", - bundle_file.to_str().unwrap(), - "--config", - &bad_cfg, - ]) - .output() - .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!( @@ -9887,15 +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 = pred() - .args([ - "extract", - tampered_file.to_str().unwrap(), - "--config", - "[false,true,false]", - ]) - .output() - .unwrap(); + 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: {}", @@ -9950,22 +10089,15 @@ 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(); // 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_config(&tampered_file, serde_json::from_str(&target_cfg).unwrap()); assert!( !extract_out.status.success(), "expected failure on tampered target.data; stdout: {}", @@ -10058,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(); diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 4bb5e88c0..8d9154c9f 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,131 @@ 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 { + let mapping = if attr.is_empty() { + None + } else { + Some(parse_macro_input!(attr as syn::Ident)) + }; + let implementation = parse_macro_input!(item as ItemImpl); + 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(), + } +} + +/// 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_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() + .is_some_and(|segment| segment.ident == "AggregateReductionResult") + }) { + return Err(syn::Error::new_spanned( + implementation, + "expected impl AggregateReductionResult", + )); + } + Ok(()) +} + +fn generate_aggregate_impl(implementation: &ItemImpl) -> syn::Result { + validate_aggregate_trait(implementation)?; + 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 +362,6 @@ struct ReductionAttrs { relation: Option, fields: Option>, unavailable: Option>, - aggregate: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -255,7 +377,6 @@ impl syn::parse::Parse for ReductionAttrs { relation: None, fields: None, unavailable: None, - aggregate: false, }; while !input.is_empty() { @@ -305,16 +426,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(), @@ -401,6 +512,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, } } @@ -517,20 +630,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 } - }; - // Collect generic parameter info from the impl block let type_generics = collect_type_generic_names(&impl_block.generics); @@ -576,10 +675,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, + reduce_aggregate_fn: None, + aggregate_view_fn: None, turing: false, } } @@ -940,6 +1040,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! { @@ -1021,6 +1122,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! { @@ -1274,30 +1390,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! { @@ -1366,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/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/lib.rs b/src/lib.rs index ae4edab0c..93dfbd4b7 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/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index d166b32fb..bce15f48f 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; +#[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 i64 { - const NAME: &'static str = "i64"; +impl TryFrom for ClosestVectorProblem { + type Error = ConstructionError; - 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())) + fn try_from(spec: ClosestVectorProblemCreateSpec) -> Result { + Self::new(spec.basis, spec.target) } } -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" - ))) - } - } - - fn to_f64(&self) -> Result { - Ok(*self) - } -} - -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("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", - 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)) + Some(row_indices) } -fn rank_overflow() -> ConstructionError { - ConstructionError::IntegerOverflow("checking closest-vector basis rank".into()) -} - -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![("coefficient", "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,10 +213,52 @@ 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: "Accept objective values <= this 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/minimum_matrix_cover.rs b/src/models/algebraic/minimum_matrix_cover.rs index 8df124c06..6bb4e8e08 100644 --- a/src/models/algebraic/minimum_matrix_cover.rs +++ b/src/models/algebraic/minimum_matrix_cover.rs @@ -51,28 +51,50 @@ inventory::submit! { /// assert!(witness.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "Data")] pub struct MinimumMatrixCover { /// The n×n nonnegative integer matrix. matrix: Vec>, } +#[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) + } +} + impl MinimumMatrixCover { /// Create a new MinimumMatrixCover instance. /// /// # 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}")) + } + + 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()); + } + if row.iter().any(|&entry| entry < 0) { + return Err(format!("matrix row {i} contains a negative entry").into()); + } } - Self { matrix } + Ok(Self { matrix }) } /// Returns the number of rows (= columns) of the matrix. diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index 328e6c21e..9568a0a8e 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -40,7 +40,7 @@ pub(crate) mod sparse_matrix_compression; pub use algebraic_equations_over_gf2::AlgebraicEquationsOverGF2; pub use bmf::BMF; -pub use closest_vector_problem::{ClosestVectorProblem, ClosestVectorTarget}; +pub use closest_vector_problem::ClosestVectorProblem; pub use consecutive_block_minimization::ConsecutiveBlockMinimization; pub use consecutive_ones_matrix_augmentation::ConsecutiveOnesMatrixAugmentation; pub use consecutive_ones_submatrix::ConsecutiveOnesSubmatrix; diff --git a/src/models/algebraic/quadratic_assignment.rs b/src/models/algebraic/quadratic_assignment.rs index f4a9d08df..8afd85804 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: "Accept objective values <= this 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..02b6a7153 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -55,13 +55,75 @@ 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 = "QuboInputData")] +#[serde(bound(deserialize = "W: WeightElement + Deserialize<'de>"))] 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)] +#[serde(deny_unknown_fields)] +struct QuboData { + num_vars: usize, + entries: Vec<(usize, usize, W)>, +} + +// 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) + } +} + +impl Serialize for QUBO { + fn serialize(&self, serializer: S) -> Result { + QuboData { + num_vars: self.num_vars, + entries: self + .entries + .iter() + .map(|(i, j, value)| (*i, *j, value)) + .collect(), + } + .serialize(serializer) + } +} + +impl TryFrom> for QUBO { + type Error = ConstructionError; + + fn try_from(data: QuboData) -> Result { + Self::from_entries(data.num_vars, data.entries) + } } #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -100,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. @@ -138,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), + ) } } @@ -179,24 +309,13 @@ 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; - } - - 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", - )?; - } + for (i, j, coefficient) in &self.entries { + if solution[*i] && solution[*j] { + value = W::checked_add_to_sum( + value, + coefficient.to_sum(), + "summing selected QUBO coefficients", + )?; } } @@ -242,3 +361,38 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionQUBO"); +crate::register_decision_variant!( + QUBO, "DecisionQUBO", "2^num_vars", &[], + "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: "Accept objective values <= this 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/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/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/models/formula/maximum_2_satisfiability.rs b/src/models/formula/maximum_2_satisfiability.rs index 5085b21ff..67954de0a 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= 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: "Accept objective values >= this 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/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 05ed0ff6c..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(()) } @@ -318,9 +326,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 +357,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/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index 994a13a06..ce58f773f 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -37,9 +37,10 @@ inventory::submit! { /// - `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"))] +#[serde(try_from = "BiconnectivityAugmentationData")] #[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>" + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" ))] pub struct BiconnectivityAugmentation where @@ -53,6 +54,28 @@ 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 TryFrom> for BiconnectivityAugmentation +where + G: Graph, + W: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: BiconnectivityAugmentationData) -> Result { + Self::try_new(data.graph, data.potential_weights, data.budget) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BiconnectivityAugmentationCreateSpec { #[create(codec = "edge-list")] @@ -88,27 +111,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) } } @@ -120,37 +123,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/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 155532cbc..4b0f3a30b 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -35,6 +35,10 @@ inventory::submit! { /// 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)] +#[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, @@ -46,6 +50,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 TryFrom> + for BoundedComponentSpanningForest +where + G: Graph, + W: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: BoundedComponentSpanningForestData) -> Result { + Self::try_new( + data.graph, + data.weights, + data.max_components, + data.max_weight, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BoundedComponentSpanningForestCreateSpec { /// The underlying graph G=(V,E). @@ -64,49 +97,48 @@ 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()); - } - 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); + } + 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..0c931954e 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -60,8 +60,9 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "BoundedDiameterSpanningTreeData")] #[serde(bound( - deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>, W::Sum: serde::Deserialize<'de>" + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" ))] pub struct BoundedDiameterSpanningTree { /// The underlying graph. @@ -76,6 +77,34 @@ 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 TryFrom> for BoundedDiameterSpanningTree +where + G: Graph, + W: WeightElement, +{ + 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, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BoundedDiameterSpanningTreeCreateSpec { #[create(codec = "edge-list")] @@ -97,29 +126,7 @@ 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()); - } - 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 +170,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 +210,27 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); + } + 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..80a7d3cce 100644 --- a/src/models/graph/degree_constrained_spanning_tree.rs +++ b/src/models/graph/degree_constrained_spanning_tree.rs @@ -56,7 +56,8 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "DegreeConstrainedSpanningTreeData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct DegreeConstrainedSpanningTree { /// The underlying graph. graph: G, @@ -66,19 +67,43 @@ 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 TryFrom> for DegreeConstrainedSpanningTree +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: DegreeConstrainedSpanningTreeData) -> Result { + Self::try_new(data.graph, data.max_degree) + } +} + 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/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/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index 551e97e0f..d70509337 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -31,12 +31,31 @@ inventory::submit! { /// 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>"))] +#[serde(try_from = "DisjointConnectingPathsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] 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 TryFrom> for DisjointConnectingPaths +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: DisjointConnectingPathsData) -> Result { + Self::try_new(data.graph, data.terminal_pairs) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct DisjointConnectingPathsCreateSpec { #[create(codec = "edge-list")] @@ -69,27 +88,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) } } @@ -101,33 +100,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..8223b1bf3 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -33,13 +33,33 @@ inventory::submit! { /// 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>"))] +#[serde(try_from = "GeneralizedHexData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] 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 TryFrom> for GeneralizedHex +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: GeneralizedHexData) -> Result { + Self::try_new(data.graph, data.source, data.target) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct GeneralizedHexCreateSpec { /// The underlying graph G=(V,E). @@ -54,25 +74,7 @@ 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()); - } - Ok(Self::new(spec.graph, spec.source, spec.sink)) + Self::try_new(spec.graph, spec.source, spec.sink) } } @@ -86,15 +88,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..aff4b79cf 100644 --- a/src/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/models/graph/hamiltonian_path_between_two_vertices.rs @@ -69,13 +69,33 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "HamiltonianPathBetweenTwoVerticesData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] 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 TryFrom> for HamiltonianPathBetweenTwoVertices +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: HamiltonianPathBetweenTwoVerticesData) -> Result { + Self::try_new(data.graph, data.source_vertex, data.target_vertex) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct HamiltonianPathBetweenTwoVerticesRandomSpec { /// Number of graph vertices. @@ -97,24 +117,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/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/kclique.rs b/src/models/graph/kclique.rs index 07bca75e7..59b222298 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -27,11 +27,31 @@ inventory::submit! { /// 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)] +#[serde(try_from = "KCliqueData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct KClique { graph: G, k: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct KCliqueData { + graph: G, + k: usize, +} + +impl TryFrom> for KClique +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: KCliqueData) -> Result { + Self::try_new(data.graph, data.k) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct KCliqueCreateSpec { #[create(codec = "edge-list")] @@ -63,25 +83,24 @@ 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) } } 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/kcoloring.rs b/src/models/graph/kcoloring.rs index 9f1662123..4fca4b03a 100644 --- a/src/models/graph/kcoloring.rs +++ b/src/models/graph/kcoloring.rs @@ -6,7 +6,7 @@ use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::variant::{KValue, VariantParam, K1, K2, K3, K4, K5, KN}; +use crate::variant::{KValue, VariantParam, K2, K3, KN}; use serde::{Deserialize, Serialize}; inventory::submit! { @@ -16,7 +16,7 @@ inventory::submit! { aliases: &[], dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("k", "KN", &["KN", "K1", "K2", "K3", "K4", "K5"]), + VariantDimension::new("k", "KN", &["KN", "K2", "K3"]), ], category: crate::registry::ProblemCategory::Graph, module_path: module_path!(), @@ -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| { @@ -333,32 +327,17 @@ crate::impl_random_generate!(KColoring, 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/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/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index a12a08172..c13ecaa35 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -34,7 +34,8 @@ inventory::submit! { /// 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)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "LengthBoundedDisjointPathsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct LengthBoundedDisjointPaths { graph: G, source: usize, @@ -43,6 +44,36 @@ 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 TryFrom> for LengthBoundedDisjointPaths +where + G: Graph, +{ + 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)?; + if max_paths != instance.max_paths { + return Err(format!( + "max_paths must equal min(deg(source), deg(sink)): expected {}, got {max_paths}", + instance.max_paths + ) + .into()); + } + Ok(instance) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct LengthBoundedDisjointPathsCreateSpec { /// Undirected graph edges. @@ -102,30 +133,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 +149,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/longest_circuit.rs b/src/models/graph/longest_circuit.rs index f60e24bff..e98895bb7 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -41,11 +41,32 @@ 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)] +#[serde(try_from = "LongestCircuitData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] 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 TryFrom> for LongestCircuit +where + G: Graph, + W: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: LongestCircuitData) -> Result { + Self::try_new(data.graph, data.edge_lengths) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct LongestCircuitCreateSpec { #[create(codec = "edge-list")] @@ -63,18 +84,7 @@ 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()); - } - Ok(Self::new(graph, edge_lengths)) + Self::try_new(graph, edge_lengths) } } @@ -117,22 +127,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 +150,27 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); + } + 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); @@ -358,3 +367,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 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: "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) +); + +#[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 2423f24fb..50f3a9303 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -41,6 +41,8 @@ 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)] +#[serde(try_from = "LongestPathData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] pub struct LongestPath { graph: G, edge_lengths: Vec, @@ -48,6 +50,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 TryFrom> for LongestPath +where + G: Graph, + W: WeightElement, +{ + 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, + ) + } +} + macro_rules! longest_path_create_spec { (@lengths $spec:ident, $lengths:ident) => { $spec.$lengths }; (@lengths $spec:ident) => { vec![One; $spec.graph.len()] }; @@ -86,21 +114,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, + ) } } }; @@ -109,42 +128,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 +177,27 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); + } + 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 @@ -303,3 +333,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 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: "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) +); + +#[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 0d414cfac..607a40f65 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -68,6 +68,8 @@ inventory::submit! { /// } /// ``` #[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, @@ -75,6 +77,24 @@ pub struct MaxCut { edge_weights: Vec, } +#[derive(Deserialize)] +struct MaxCutData { + graph: G, + edge_weights: Vec, +} + +impl TryFrom> for MaxCut +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaxCutData) -> Result { + Self::try_new(data.graph, data.edge_weights) + } +} + macro_rules! max_cut_create_spec { ($name:ident, $weight:ty, $one:expr $(, $edge_weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -94,15 +114,7 @@ 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()); - } - Ok(Self::new(graph, edge_weights)) + Self::try_new(graph, edge_weights) } } }; @@ -146,15 +158,21 @@ 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(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); + } + Ok(Self { graph, edge_weights, - } + }) } /// Create a MaxCut problem with unit weights. @@ -337,3 +355,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 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: "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) +); + +#[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/maximal_is.rs b/src/models/graph/maximal_is.rs index 079e0752d..3f73f7cfa 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -54,6 +54,8 @@ inventory::submit! { /// } /// ``` #[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, @@ -61,6 +63,24 @@ pub struct MaximalIS { weights: Vec, } +#[derive(Deserialize)] +struct MaximalISData { + graph: G, + weights: Vec, +} + +impl TryFrom> for MaximalIS +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximalISData) -> Result { + Self::try_new(data.graph, data.weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximalISCreateSpec { /// The underlying graph G=(V,E). @@ -72,27 +92,25 @@ 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()); - } - 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); + } + 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..a39f3c40d 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -57,6 +57,8 @@ inventory::submit! { /// assert!(solutions.iter().all(|s| s.iter().filter(|&&selected| selected).count() == 3)); /// ``` #[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, @@ -64,6 +66,24 @@ pub struct MaximumClique { weights: Vec, } +#[derive(Deserialize)] +struct MaximumCliqueData { + graph: G, + weights: Vec, +} + +impl TryFrom> for MaximumClique +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumCliqueData) -> Result { + Self::try_new(data.graph, data.weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumCliqueCreateSpec { /// The underlying graph G=(V,E). @@ -75,27 +95,25 @@ 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()); - } - Ok(Self::new(spec.graph, spec.weights)) + Self::try_new(spec.graph, spec.weights) } } impl 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -225,7 +243,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..9b482cd05 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -64,7 +64,10 @@ inventory::submit! { /// assert_eq!(problem.bound_k(), 2); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>"))] +#[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, @@ -81,6 +84,26 @@ pub struct MaximumCoKPlex { _phantom: std::marker::PhantomData, } +#[derive(Deserialize)] +struct MaximumCoKPlexData { + graph: G, + weights: Vec, + bound_k: usize, +} + +impl TryFrom> for MaximumCoKPlex +where + G: Graph, + W: Clone + Default, + K: KValue, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumCoKPlexData) -> Result { + Self::try_with_k(data.graph, data.weights, data.bound_k) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumCoKPlexCreateSpec { /// The underlying graph G=(V,E). @@ -97,18 +120,7 @@ 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()); - } - Ok(Self::with_k(spec.graph, spec.weights, spec.k)) + Self::try_with_k(spec.graph, spec.weights, spec.k) } } @@ -120,24 +132,35 @@ 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(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()); + } 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`. @@ -279,10 +302,7 @@ 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()); - } - Ok(Self::with_k(spec.graph, weights, spec.k)) + Self::try_with_k(spec.graph, weights, spec.k) } } 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 55723eb26..724f9016c 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -59,6 +59,8 @@ inventory::submit! { /// assert!(solutions.iter().all(|s| s.iter().filter(|&&selected| selected).count() == 1)); /// ``` #[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, @@ -66,6 +68,24 @@ pub struct MaximumIndependentSet { weights: Vec, } +#[derive(Deserialize)] +struct MaximumIndependentSetData { + graph: G, + weights: Vec, +} + +impl TryFrom> for MaximumIndependentSet +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumIndependentSetData) -> Result { + Self::try_new(data.graph, data.weights) + } +} + macro_rules! simple_mis_spec { ($name:ident,$weight:ty,$one:expr $(, $weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -102,13 +122,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) } } }; @@ -141,13 +155,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) } } }; @@ -189,15 +197,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) } } }; @@ -213,12 +213,18 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); + } + 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..c7fb0d973 100644 --- a/src/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/models/graph/maximum_leaf_spanning_tree.rs @@ -44,21 +44,43 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MaximumLeafSpanningTreeData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct MaximumLeafSpanningTree { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct MaximumLeafSpanningTreeData { + graph: G, +} + +impl TryFrom> for MaximumLeafSpanningTree +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumLeafSpanningTreeData) -> Result { + Self::try_new(data.graph) + } +} + 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..5c7b9b08c 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -57,6 +57,8 @@ inventory::submit! { /// } /// ``` #[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, @@ -64,6 +66,24 @@ pub struct MaximumMatching { edge_weights: Vec, } +#[derive(Deserialize)] +struct MaximumMatchingData { + graph: G, + edge_weights: Vec, +} + +impl TryFrom> for MaximumMatching +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MaximumMatchingData) -> Result { + Self::try_new(data.graph, data.edge_weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumMatchingCreateSpec { #[create(codec = "edge-list")] @@ -81,15 +101,7 @@ 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()); - } - Ok(Self::new(graph, edge_weights)) + Self::try_new(graph, edge_weights) } } @@ -131,15 +143,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 +221,24 @@ 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(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); + } + 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..661fa6c51 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -54,6 +54,8 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[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, @@ -65,6 +67,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 TryFrom> for MinMaxMulticenter +where + G: Graph, + W: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinMaxMulticenterData) -> Result { + Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) + } +} + macro_rules! min_max_multicenter_create_spec { ($name:ident, $weight:ty, $one:expr $(, $weights:ident, $edge_weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -89,40 +112,8 @@ 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()); - } - Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + Self::try_new(graph, vertex_weights, edge_lengths, spec.k) } } }; @@ -174,37 +165,55 @@ 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(crate::registry::ConstructionError::length_mismatch( + "vertex_weights", + vertex_weights.len(), + graph.num_vertices(), + )); + } + if edge_lengths.len() != graph.num_edges() { + return Err(crate::registry::ConstructionError::length_mismatch( + "edge_lengths", + edge_lengths.len(), + graph.num_edges(), + )); + } 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. @@ -417,3 +426,54 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMinMaxMulticenter"); +crate::register_decision_variant!( + MinMaxMulticenter, "DecisionMinMaxMulticenter", "1.4969^num_vertices", &[], + "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: "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) +); + +#[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_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 631a960b6..8ce48e7d5 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -49,6 +49,10 @@ 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)] +#[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, @@ -62,6 +66,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 TryFrom> + for MinimumCapacitatedSpanningTree +where + G: Graph, + W: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumCapacitatedSpanningTreeData) -> Result { + Self::try_new( + data.graph, + data.weights, + data.root, + data.requirements, + data.capacity, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumCapacitatedSpanningTreeCreateSpec { /// The underlying graph. @@ -82,30 +117,13 @@ 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()); - } - Ok(Self::new( + Self::try_new( spec.graph, weights, spec.root, spec.requirements, spec.capacity, - )) + ) } } @@ -124,32 +142,42 @@ 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(crate::registry::ConstructionError::length_mismatch( + "requirements", + requirements.len(), + graph.num_vertices(), + )); + } + 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 +192,21 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); + } + 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_covering_by_cliques.rs b/src/models/graph/minimum_covering_by_cliques.rs index 1f59f2bae..f664ced7b 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 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: "Accept objective values <= this 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_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 2f5167a65..8d03f2846 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -57,6 +57,8 @@ inventory::submit! { /// assert_eq!(val, problemreductions::types::Min(Some(1))); /// ``` #[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, @@ -70,6 +72,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 TryFrom> for MinimumCutIntoBoundedSets +where + G: Graph, + W: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumCutIntoBoundedSetsData) -> Result { + Self::try_new( + data.graph, + data.edge_weights, + data.source, + data.sink, + data.size_bound, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumCutIntoBoundedSetsCreateSpec { /// The undirected graph. @@ -88,26 +118,13 @@ 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()); - } - Ok(Self::new( + Self::try_new( spec.graph, edge_weights, spec.source, spec.sink, spec.size_bound, - )) + ) } } @@ -131,21 +148,40 @@ 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(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()); + } + 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..c21a23dc0 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -53,6 +53,8 @@ inventory::submit! { /// assert!(solutions.contains(&vec![true, false, false, false])); /// ``` #[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, @@ -60,6 +62,24 @@ pub struct MinimumDominatingSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumDominatingSetData { + graph: G, + weights: Vec, +} + +impl TryFrom> for MinimumDominatingSet +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumDominatingSetData) -> Result { + Self::try_new(data.graph, data.weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumDominatingSetCreateSpec { /// The underlying graph G=(V,E). @@ -73,27 +93,25 @@ 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()); - } - 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -227,7 +245,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..7352d492d 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -56,6 +56,8 @@ inventory::submit! { /// assert_eq!(solution.iter().filter(|&&selected| selected).count(), 1); /// ``` #[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, @@ -63,6 +65,23 @@ pub struct MinimumFeedbackArcSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumFeedbackArcSetData { + graph: DirectedGraph, + weights: Vec, +} + +impl TryFrom> for MinimumFeedbackArcSet +where + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumFeedbackArcSetData) -> Result { + Self::try_new(data.graph, data.weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumFeedbackArcSetCreateSpec { /// The directed graph. @@ -75,22 +94,22 @@ 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()); - } - 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,24 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_arcs(), + )); + } + 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..113b04f84 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -50,6 +50,8 @@ inventory::submit! { /// assert_eq!(solutions.len(), 3); /// ``` #[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, @@ -57,6 +59,23 @@ pub struct MinimumFeedbackVertexSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumFeedbackVertexSetData { + graph: DirectedGraph, + weights: Vec, +} + +impl TryFrom> for MinimumFeedbackVertexSet +where + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumFeedbackVertexSetData) -> Result { + Self::try_new(data.graph, data.weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumFeedbackVertexSetCreateSpec { /// The directed graph. @@ -71,22 +90,22 @@ 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()); - } - 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,24 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); + } + 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 +229,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_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 6b1b66cec..8be80045e 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -43,12 +43,33 @@ inventory::submit! { /// A configuration is feasible if removing the cut edges disconnects all /// terminal pairs. #[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, edge_weights: Vec, } +#[derive(Deserialize)] +struct MinimumMultiwayCutData { + graph: G, + terminals: Vec, + edge_weights: Vec, +} + +impl TryFrom> for MinimumMultiwayCut +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumMultiwayCutData) -> Result { + Self::try_new(data.graph, data.terminals, data.edge_weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumMultiwayCutCreateSpec { /// The undirected graph G=(V,E). @@ -62,35 +83,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 +100,40 @@ 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(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()); + } 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 +178,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 +241,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/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 2ba567460..e202b2dd2 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -55,6 +55,8 @@ inventory::submit! { /// assert_eq!(solution, vec![false, true, false]); /// ``` #[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, @@ -66,6 +68,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 TryFrom> for MinimumSumMulticenter +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumSumMulticenterData) -> Result { + Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumSumMulticenterCreateSpec { #[create(codec = "edge-list")] @@ -98,29 +121,10 @@ 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 +164,42 @@ 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(crate::registry::ConstructionError::length_mismatch( + "vertex_weights", + vertex_weights.len(), + graph.num_vertices(), + )); + } + if edge_lengths.len() != graph.num_edges() { + 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()); + } + 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. @@ -409,3 +431,65 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMinimumSumMulticenter"); +crate::register_decision_variant!( + MinimumSumMulticenter, "DecisionMinimumSumMulticenter", "2^num_vertices", &[], + "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: "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) +); + +#[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/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 5f511118b..c3195f3a4 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -53,6 +53,8 @@ inventory::submit! { /// assert!(solutions.contains(&vec![false, true, false])); /// ``` #[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, @@ -60,6 +62,24 @@ pub struct MinimumVertexCover { weights: Vec, } +#[derive(Deserialize)] +struct MinimumVertexCoverData { + graph: G, + weights: Vec, +} + +impl TryFrom> for MinimumVertexCover +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumVertexCoverData) -> Result { + Self::try_new(data.graph, data.weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumVertexCoverCreateSpec { /// The underlying graph G=(V,E). @@ -76,27 +96,25 @@ 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()); - } - 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_vertices(), + )); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -217,7 +235,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) } } @@ -298,13 +316,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 ); @@ -342,35 +361,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/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/monochromatic_triangle.rs b/src/models/graph/monochromatic_triangle.rs index 272037f0d..b4ef79649 100644 --- a/src/models/graph/monochromatic_triangle.rs +++ b/src/models/graph/monochromatic_triangle.rs @@ -58,7 +58,8 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "MonochromaticTriangleData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct MonochromaticTriangle { /// The underlying graph. graph: G, @@ -68,6 +69,24 @@ 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 TryFrom> for MonochromaticTriangle +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: MonochromaticTriangleData) -> Result { + 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/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/partition_into_cliques.rs b/src/models/graph/partition_into_cliques.rs index 5bb87de80..a98ccc8f6 100644 --- a/src/models/graph/partition_into_cliques.rs +++ b/src/models/graph/partition_into_cliques.rs @@ -54,7 +54,8 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "PartitionIntoCliquesData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoCliques { /// The underlying graph. graph: G, @@ -62,18 +63,41 @@ pub struct PartitionIntoCliques { num_cliques: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoCliquesData { + graph: G, + num_cliques: usize, +} + +impl TryFrom> for PartitionIntoCliques +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoCliquesData) -> Result { + Self::try_new(data.graph, data.num_cliques) + } +} + 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..1d5c02893 100644 --- a/src/models/graph/partition_into_forests.rs +++ b/src/models/graph/partition_into_forests.rs @@ -55,7 +55,8 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "PartitionIntoForestsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoForests { /// The underlying graph. graph: G, @@ -63,14 +64,38 @@ pub struct PartitionIntoForests { num_forests: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoForestsData { + graph: G, + num_forests: usize, +} + +impl TryFrom> for PartitionIntoForests +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoForestsData) -> Result { + Self::try_new(data.graph, data.num_forests) + } +} + 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..01bd8569a 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -59,25 +59,48 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "PartitionIntoPathsOfLength2Data")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoPathsOfLength2 { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoPathsOfLength2Data { + graph: G, +} + +impl TryFrom> for PartitionIntoPathsOfLength2 +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoPathsOfLength2Data) -> Result { + Self::try_new(data.graph) + } +} + 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. @@ -118,29 +141,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) { - 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) { + if groups.iter().any(|vertices| vertices.len() != 3) { 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/partition_into_perfect_matchings.rs b/src/models/graph/partition_into_perfect_matchings.rs index e93976e53..25aceba7a 100644 --- a/src/models/graph/partition_into_perfect_matchings.rs +++ b/src/models/graph/partition_into_perfect_matchings.rs @@ -56,7 +56,8 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "PartitionIntoPerfectMatchingsData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoPerfectMatchings { /// The underlying graph. graph: G, @@ -64,21 +65,44 @@ pub struct PartitionIntoPerfectMatchings { num_matchings: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoPerfectMatchingsData { + graph: G, + num_matchings: usize, +} + +impl TryFrom> for PartitionIntoPerfectMatchings +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoPerfectMatchingsData) -> Result { + Self::try_new(data.graph, data.num_matchings) + } +} + 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..772f82471 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -51,24 +51,48 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +#[serde(try_from = "PartitionIntoTrianglesData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] pub struct PartitionIntoTriangles { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoTrianglesData { + graph: G, +} + +impl TryFrom> for PartitionIntoTriangles +where + G: Graph, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: PartitionIntoTrianglesData) -> Result { + Self::try_new(data.graph) + } +} + 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/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 1503614e1..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() { @@ -229,6 +233,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/rural_postman.rs b/src/models/graph/rural_postman.rs index 2561b04e1..da7737b8a 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -53,6 +53,8 @@ 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)] +#[serde(try_from = "RuralPostmanData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] pub struct RuralPostman { /// The underlying graph. graph: G, @@ -62,6 +64,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 TryFrom> for RuralPostman +where + G: Graph, + W: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: RuralPostmanData) -> Result { + Self::try_new(data.graph, data.edge_lengths, data.required_edges) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct RuralPostmanCreateSpec { #[create(codec = "edge-list")] @@ -81,22 +103,7 @@ 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()); - } - Ok(Self::new(graph, edge_lengths, spec.required_edges)) + Self::try_new(graph, edge_lengths, spec.required_edges) } } @@ -138,24 +145,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 +203,21 @@ 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(crate::registry::ConstructionError::length_mismatch( + "weights", + weights.len(), + graph.num_edges(), + )); + } + Ok(()) + } + /// Get the edge lengths as a Vec. pub fn weights(&self) -> Vec { self.edge_lengths.clone() @@ -400,3 +424,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 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: "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 +); + +#[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/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index 0dab498cf..b95ce34d8 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -52,6 +52,10 @@ inventory::submit! { /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `N` - The edge length / weight type (e.g., `i64`, `f64`) #[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, @@ -67,6 +71,38 @@ 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 TryFrom> for ShortestWeightConstrainedPath +where + G: Graph, + N: WeightElement, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: ShortestWeightConstrainedPathData) -> Result { + Self::try_new( + data.graph, + data.edge_lengths, + data.edge_weights, + data.source_vertex, + data.target_vertex, + data.weight_bound, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct ShortestWeightConstrainedPathCreateSpec { /// The underlying graph G=(V,E). @@ -88,68 +124,34 @@ 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()); - } - 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(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()); + } + Ok(()) } /// Create a new ShortestWeightConstrainedPath instance. @@ -166,39 +168,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 +235,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/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index b99974051..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() { @@ -444,3 +448,56 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionSpinGlass"); +crate::register_decision_variant!( + SpinGlass, "DecisionSpinGlass", "2^num_spins", &[], + "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: "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: "Accept objective values <= this 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/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index 6f819ecae..585174952 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`) @@ -96,17 +98,25 @@ 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.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() { @@ -114,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, @@ -222,7 +230,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 @@ -341,12 +349,12 @@ 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) } } 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/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index b658067da..554453069 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -48,6 +48,8 @@ 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)] +#[serde(try_from = "TravelingSalesmanData")] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: Clone + Default + Deserialize<'de>"))] pub struct TravelingSalesman { /// The underlying graph. graph: G, @@ -55,6 +57,24 @@ pub struct TravelingSalesman { edge_weights: Vec, } +#[derive(Deserialize)] +struct TravelingSalesmanData { + graph: G, + edge_weights: Vec, +} + +impl TryFrom> for TravelingSalesman +where + G: Graph, + W: Clone + Default, +{ + type Error = crate::registry::ConstructionError; + + fn try_from(data: TravelingSalesmanData) -> Result { + Self::try_new(data.graph, data.edge_weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct TravelingSalesmanCreateSpec { #[create(codec = "edge-list")] @@ -72,15 +92,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(crate::registry::ConstructionError::length_mismatch( + "edge_weights", + edge_weights.len(), + graph.num_edges(), + )); + } + Ok(Self { graph, edge_weights, - } + }) } /// Create a TravelingSalesman problem with unit weights. @@ -233,13 +251,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/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 684fa4892..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(); @@ -291,6 +295,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/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/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/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 82eff4f6b..8cc6f33f2 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -167,6 +167,28 @@ fn validate_cdft_create( ); } } + 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)) + .ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing the total domain size".into(), + ) + })?; + tables + .iter() + .flat_map(|table| table.counts()) + .try_fold(0usize, |sum, row| sum.checked_add(row.len())) + .ok_or_else(|| { + crate::registry::ConstructionError::IntegerOverflow( + "representing the number of frequency-table cells".into(), + ) + })?; let mut pairs = BTreeSet::new(); for table in tables { let a = table.attribute_a(); @@ -228,6 +250,7 @@ fn validate_cdft_create( impl ConsistencyOfDatabaseFrequencyTables { /// Create a new consistency-of-database-frequency-tables instance. + /// Input parameter counts and the table-assignment witness length must fit in `usize`. pub fn new( num_objects: usize, attribute_domains: Vec, @@ -289,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. @@ -314,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 @@ -327,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 } @@ -346,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), @@ -424,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/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/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_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/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/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/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index c8865a5e2..a17af7951 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -55,45 +55,37 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[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, 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 TryFrom> for MinimumTardinessSequencing { + type Error = crate::registry::ConstructionError; - 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) - } - } - }; + fn try_from(data: MinimumTardinessSequencingData) -> Result { + Self::try_new(data.lengths.len(), data.deadlines, data.precedences) + } +} + +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) + } } #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -106,30 +98,26 @@ 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()); - } - 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 +127,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 +156,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/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/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/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 30680b8cc..a08efa1a7 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: "Accept objective values <= this 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/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/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs index c60474b40..cae8f9a6d 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: "Accept objective values <= this 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..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 { @@ -423,3 +427,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: "Accept objective values <= this 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/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/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 81748fc00..0a7f0db09 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -56,6 +56,8 @@ inventory::submit! { /// } /// ``` #[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, @@ -65,6 +67,24 @@ pub struct MinimumSetCovering { weights: Vec, } +#[derive(Deserialize)] +struct MinimumSetCoveringData { + universe_size: usize, + sets: Vec>, + weights: Vec, +} + +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) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumSetCoveringCreateSpec { /// Size of the universe U. @@ -79,28 +99,7 @@ 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()); - } - } - Ok(Self::with_weights( - spec.universe_size, - spec.subsets, - spec.weights, - )) + Self::try_with_weights(spec.universe_size, spec.subsets, spec.weights) } } @@ -110,23 +109,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/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/registry/dyn_problem.rs b/src/registry/dyn_problem.rs index e5938bdc4..b50fa1e16 100644 --- a/src/registry/dyn_problem.rs +++ b/src/registry/dyn_problem.rs @@ -21,6 +21,11 @@ where /// /// Implemented for serializable problems whose values support solution witnesses. pub trait DynProblem: Any { + /// 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 +53,15 @@ where T::Solution: serde::de::DeserializeOwned, T::Value: SolutionAggregate + fmt::Display + Serialize, { + 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 +73,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/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/registry/variant.rs b/src/registry/variant.rs index 08f464b00..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) @@ -254,6 +260,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/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index df8b781e2..9baca7955 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -29,16 +29,24 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionAcyclicPartitionToILP {} + #[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 +67,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 +81,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..5857dfc7e 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -28,7 +28,12 @@ impl ReductionResult for ReductionBCBSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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() @@ -37,6 +42,9 @@ impl ReductionResult for ReductionBCBSToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionBCBSToILP {} + #[reduction( transform = upper_bound { num_vars = "num_vertices", diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index 6fc7dd3bc..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,6 +72,9 @@ impl ReductionResult for ReductionBiconnAugToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionBiconnAugToILP {} + #[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/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 96c32f915..3a8242872 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -31,12 +31,20 @@ impl ReductionResult for ReductionBCSFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionBCSFToILP {} + #[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..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,6 +191,9 @@ impl ILPBuilder { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCircuitToILP {} + #[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..4030b23ca 100644 --- a/src/rules/circuit_sat.rs +++ b/src/rules/circuit_sat.rs @@ -293,12 +293,20 @@ impl ReductionResult for ReductionCircuitSATToSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionCircuitSATToSAT {} + #[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..3acda5b81 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 @@ -230,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 !crate::rules::AggregateReductionResult::extract_value(self, 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 @@ -246,18 +244,8 @@ impl ReductionResult for ReductionCircuitToSG { } } -impl crate::rules::AggregateReductionResult for ReductionCircuitToSG { - type Source = CircuitSAT; - type Target = SpinGlass; - - 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)) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionCircuitToSG {} /// Builder for constructing the combined SpinGlass from circuit gadgets. struct SpinGlassBuilder { @@ -491,13 +479,12 @@ 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", } )] -impl ReduceTo> for CircuitSAT { +impl ReduceTo>> for CircuitSAT { type Result = ReductionCircuitToSG; fn reduce_to(&self) -> Result { @@ -508,21 +495,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 +550,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/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/clustering_ilp.rs b/src/rules/clustering_ilp.rs index bb3149ae0..00b2e91ee 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -30,7 +30,12 @@ impl ReductionResult for ReductionClusteringToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -41,6 +46,9 @@ impl ReductionResult for ReductionClusteringToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionClusteringToILP {} + #[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 37c6f5944..6dfcf29cc 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. /// @@ -48,7 +48,12 @@ where &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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) } @@ -99,6 +104,14 @@ fn reduce_kcoloring_to_ilp( }) } +crate::register_aggregate_reduction!(ReductionKColoringToILP); + +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult + for ReductionKColoringToILP +{ +} + // Register only the KN variant in the reduction graph #[reduction( transform = exact { @@ -128,7 +141,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/coloring_qubo.rs b/src/rules/coloring_qubo.rs index c29893620..dfb7d2a32 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 @@ -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 !crate::rules::AggregateReductionResult::extract_value(self, 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| { @@ -64,18 +63,10 @@ impl ReductionResult for ReductionKColoringToQUBO { } } -impl crate::rules::AggregateReductionResult for ReductionKColoringToQUBO { - type Source = KColoring; - type Target = QUBO; - - fn target_problem(&self) -> &Self::Target { - &self.target - } +crate::register_aggregate_reduction!(ReductionKColoringToQUBO); - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.feasible_energy)) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToQUBO {} /// Check dimensions and the omitted constant before allocating the matrix. fn coloring_qubo_parameters( @@ -83,9 +74,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) @@ -112,9 +104,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)?; @@ -169,26 +162,29 @@ 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, }) } // Register only the KN variant in the reduction graph #[reduction( - aggregate = custom, transform = exact { num_vars = "num_vertices * num_colors", } )] -impl ReduceTo> for KColoring { +impl ReduceTo>> for KColoring { type Result = ReductionKColoringToQUBO; fn reduce_to(&self) -> Result { @@ -199,7 +195,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) @@ -220,7 +216,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/consecutiveblockminimization_ilp.rs b/src/rules/consecutiveblockminimization_ilp.rs index ca25d2c2f..f61d3e4bb 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -28,12 +28,20 @@ impl ReductionResult for ReductionCBMToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCBMToILP {} + #[reduction( transform = upper_bound { num_vars = "num_cols * num_cols + num_rows * num_cols + num_rows * num_cols", @@ -84,6 +92,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..999a91899 100644 --- a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -29,12 +29,20 @@ impl ReductionResult for ReductionCOMAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCOMAToILP {} + #[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..a1affc72d 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -26,7 +26,12 @@ impl ReductionResult for ReductionCOSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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) @@ -38,6 +43,9 @@ impl ReductionResult for ReductionCOSToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCOSToILP {} + #[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..7d4d095a9 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()) @@ -95,7 +95,12 @@ impl ReductionResult for ReductionCDFTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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()); @@ -127,6 +132,9 @@ impl ReductionResult for ReductionCDFTToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionCDFTToILP {} + #[reduction( transform = exact { num_vars = "num_objects * total_domain_size + num_objects * num_frequency_cells", @@ -140,24 +148,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)); } } @@ -204,13 +240,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/rules/decisionmaximumindependentset_integralflowbundles.rs b/src/rules/decisionmaximumindependentset_integralflowbundles.rs index 7fdeab6b4..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,6 +75,9 @@ fn flow_requirement(n: usize, bound: i64) -> Result, + 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 @@ -32,38 +31,27 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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(identity)] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { - type Source = Decision>; - type Target = MinimumSumMulticenter; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, target_value: Min) -> Or { - Or(target_value.0 == Some(self.threshold)) - } } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "num_vertices + 2", num_edges = "num_edges" } )] -impl ReduceTo> +impl ReduceTo>> for Decision> { type Result = ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter; @@ -80,9 +68,8 @@ impl ReduceTo> ); Ok( ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { - target, + target: Decision::new(target, threshold), source_num_vertices: n, - threshold, }, ) } @@ -95,7 +82,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 +111,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 5bcb1bac1..ac4b58d98 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 @@ -30,40 +30,29 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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(identity)] impl crate::rules::AggregateReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { - type Source = Decision>; - type Target = MinMaxMulticenter; - - 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)) - } } #[reduction( - aggregate = custom, transform = exact { num_vertices = "num_vertices + 2", num_edges = "num_edges", } )] -impl ReduceTo> +impl ReduceTo>> for Decision> { type Result = ReductionDecisionMinimumDominatingSetToMinMaxMulticenter; @@ -79,7 +68,7 @@ impl ReduceTo> centers, ); Ok(ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { - target, + target: Decision::new(target, 1), source_num_vertices: n, }) } @@ -91,7 +80,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 +104,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( Decision::new( MinimumDominatingSet::new( diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 201a2c437..e2cea1103 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -1,14 +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::traits::Problem; +use crate::types::One; use std::collections::BTreeSet; #[derive(Debug, Clone)] @@ -20,7 +20,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 +73,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 +184,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 +211,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", @@ -234,7 +223,7 @@ impl TheoremConstruction { } } -/// Result of reducing Decision> to +/// Result of reducing Decision> to /// HamiltonianCircuit. #[derive(Debug, Clone)] pub struct ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { @@ -256,7 +245,7 @@ impl ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { } impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { - type Source = Decision>; + type Source = Decision>; type Target = HamiltonianCircuit; fn target_problem(&self) -> &Self::Target { @@ -267,26 +256,23 @@ impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 { - 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,28 +294,35 @@ fn insert_edge(edges: &mut BTreeSet<(usize, usize)>, a: usize, b: usize) { edges.insert(edge); } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult + for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit +{ +} + #[reduction( 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", } )] -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(); - let raw_bound = *self.bound(); + // A loop forces its vertex into every cover. Reduce the remaining + // loopless graph with the budget left after selecting those vertices. + let mut forced_cover = vec![false; num_source_vertices]; + let mut edges = normalize_edges(self.inner().graph().edges()); + for &(u, v) in &edges { + if u == v { + forced_cover[u] = true; + } + } + let raw_bound = i128::from(*self.bound()) + - forced_cover.iter().filter(|&&selected| selected).count() as i128; + edges.retain(|&(u, v)| !forced_cover[u] && !forced_cover[v]); if raw_bound < 0 { return Ok(ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { target: HamiltonianCircuit::new(SimpleGraph::path(3)), @@ -336,8 +330,6 @@ impl ReduceTo> 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 +355,7 @@ impl ReduceTo> for Decision> for Decision> for Decision>, + Decision>, HamiltonianCircuit, >("active source vertex has no Hamiltonian gadget path endpoints") })?; @@ -457,7 +450,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -45,10 +50,13 @@ impl ReductionResult for ReductionDirectedHamiltonianPathToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDirectedHamiltonianPathToILP {} + #[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..ff9089ec1 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -41,12 +41,20 @@ impl ReductionResult for ReductionD2CIFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionD2CIFToILP {} + #[reduction( transform = upper_bound { num_vars = "2 * num_arcs", @@ -101,7 +109,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 +135,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 +148,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..7dfdbed51 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -40,7 +40,12 @@ impl ReductionResult for ReductionDCPToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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() { @@ -85,6 +90,9 @@ impl ReductionResult for ReductionDCPToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDCPToILP {} + #[reduction( transform = exact { num_vars = "num_pairs * 2 * num_edges", @@ -122,6 +130,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..5543806d8 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -74,7 +74,12 @@ impl ReductionResult for ReductionEulerianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -139,6 +144,9 @@ fn compatible_pairs(arcs: &[(usize, usize)]) -> Vec<(usize, usize)> { pairs } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionEulerianPathToILP {} + #[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..b177223cf 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -22,13 +22,22 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution.to_vec()) } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 {} + +#[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..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,6 +115,9 @@ impl ReductionResult for ReductionX3CToBoundedDiameterSpanningTree { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionX3CToBoundedDiameterSpanningTree {} + #[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..2a7ea358d 100644 --- a/src/rules/exactcoverby3sets_ilp.rs +++ b/src/rules/exactcoverby3sets_ilp.rs @@ -25,12 +25,20 @@ impl ReductionResult for ReductionX3CToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionX3CToILP {} + #[reduction( transform = exact { num_vars = "num_subsets", diff --git a/src/rules/exactcoverby3sets_maximumsetpacking.rs b/src/rules/exactcoverby3sets_maximumsetpacking.rs index 60536abc0..8aa93e9c5 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,35 @@ impl ReductionResult for ReductionXC3SToMaximumSetPacking { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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()) } } +#[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 +83,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..023075498 100644 --- a/src/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/rules/exactcoverby3sets_minimumaxiomset.rs @@ -27,13 +27,18 @@ 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)?; + 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; @@ -44,6 +49,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..bc8ff97fd 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,43 @@ impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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![]); + } 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 +73,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 +95,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..c54084d5d 100644 --- a/src/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/rules/exactcoverby3sets_staffscheduling.rs @@ -37,12 +37,20 @@ impl ReductionResult for ReductionXC3SToStaffScheduling { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionXC3SToStaffScheduling {} + #[reduction( transform = exact { num_periods = "universe_size", diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index 4084f1a40..1e902948b 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -30,7 +30,12 @@ impl ReductionResult for ReductionX3CToSubsetProduct { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok(target_solution.to_vec()) } @@ -58,6 +63,9 @@ fn assigned_primes(universe_size: usize) -> Vec { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionX3CToSubsetProduct {} + #[reduction( transform = exact { num_elements = "num_sets", diff --git a/src/rules/factoring_circuit.rs b/src/rules/factoring_circuit.rs index 330406035..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,6 +211,9 @@ fn build_multiplier_cell( (assignments, ancillas) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionFactoringToCircuit {} + #[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..f7e6fe5fb 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -80,7 +80,12 @@ impl ReductionResult for ReductionFactoringToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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) @@ -105,7 +110,11 @@ impl ReductionResult for ReductionFactoringToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionFactoringToILP {} + +#[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..20ea224a6 100644 --- a/src/rules/feasibleregisterassignment_ilp.rs +++ b/src/rules/feasibleregisterassignment_ilp.rs @@ -33,12 +33,20 @@ impl ReductionResult for ReductionFeasibleRegisterAssignmentToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionFeasibleRegisterAssignmentToILP {} + #[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..8bbce62e5 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -34,12 +34,19 @@ 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)?; + 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; @@ -47,15 +54,25 @@ 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionFSSToILP {} + +#[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 +129,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 +147,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 f89ffbb49..a0f50f77e 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}; @@ -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, } @@ -350,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. @@ -433,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()); @@ -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, }, ); @@ -1439,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()); @@ -1528,16 +1530,150 @@ 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 extraction back to the source problem space. +/// solution and aggregate-value mappings back to the source problem space. +/// 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) + } + + /// 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, + 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) + }, + ) + } + /// Get the final target problem as a type-erased reference. pub fn target_problem_any(&self) -> &dyn Any { self.steps @@ -1611,11 +1747,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( + &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)) } } @@ -1626,10 +1765,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); }; @@ -1661,6 +1796,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 +1814,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 +1827,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/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index 07576fe0e..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,6 +116,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult + for ReductionHamiltonianCircuitToBiconnectivityAugmentation +{ +} + #[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..8d2e27df1 100644 --- a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -27,12 +27,33 @@ impl ReductionResult for ReductionHamiltonianCircuitToBottleneckTravelingSalesma &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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) } } +#[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..06bd1b027 100644 --- a/src/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -40,7 +40,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -78,6 +83,9 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToHamiltonianPath {} + #[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..03ab5871c 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 @@ -27,50 +28,45 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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.graph(), target_solution) - } -} - -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLongestCircuit { - type Source = HamiltonianCircuit; - type Target = LongestCircuit; - - 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())), + crate::rules::graph_helpers::edges_to_cycle_order( + self.target.inner().graph(), + target_solution, ) } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToLongestCircuit {} + #[reduction( - aggregate = custom, transform = exact { num_vertices = "num_vertices", 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 +78,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 4cd1e5264..37a43e35f 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 @@ -29,40 +30,28 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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()) } } -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { - type Source = HamiltonianCircuit; - type Target = QuadraticAssignment; - - 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))) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment {} #[reduction( - aggregate = custom, transform = upper_bound { num_facilities = "num_vertices + 3", num_locations = "num_vertices + 3", } )] -impl ReduceTo for HamiltonianCircuit { +impl ReduceTo> for HamiltonianCircuit { type Result = ReductionHamiltonianCircuitToQuadraticAssignment; fn reduce_to(&self) -> Result { @@ -82,7 +71,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 +85,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 e1e2d321d..1ea99e400 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 @@ -50,7 +51,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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. @@ -103,6 +109,9 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToRuralPostman {} + #[reduction( transform = exact { num_vertices = "2 * num_vertices", @@ -110,7 +119,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { num_required_edges = "num_vertices", } )] -impl ReduceTo> for HamiltonianCircuit { +impl ReduceTo>> for HamiltonianCircuit { type Result = ReductionHamiltonianCircuitToRuralPostman; fn reduce_to(&self) -> Result { @@ -145,7 +154,17 @@ impl ReduceTo> for HamiltonianCircuit>>>::exact_i64( + 2 * n, + "encoding the route bound", + )? + }, + ), n, source_edges, }) @@ -169,7 +188,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 bccffa686..b98b6f720 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 @@ -36,45 +37,28 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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()) } } -impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToStackerCrane { - type Source = HamiltonianCircuit; - type Target = StackerCrane; - - 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())), - ) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianCircuitToStackerCrane {} #[reduction( - aggregate = custom, transform = exact { num_vertices = "2 * num_vertices", num_arcs = "num_vertices", num_edges = "2 * num_edges", } )] -impl ReduceTo for HamiltonianCircuit { +impl ReduceTo> for HamiltonianCircuit { type Result = ReductionHamiltonianCircuitToStackerCrane; fn reduce_to(&self) -> Result { @@ -103,9 +87,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 +112,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 +121,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 +136,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_strongconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index e2592e77e..089159a34 100644 --- a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -31,7 +31,12 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; Ok({ let n = self.n; @@ -74,9 +79,15 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult + for ReductionHamiltonianCircuitToStrongConnectivityAugmentation +{ +} + #[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 +97,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..989b2caa2 100644 --- a/src/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -27,12 +27,35 @@ impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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) } } +#[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..26bfe460f 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -25,12 +25,23 @@ impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult + for ReductionHamiltonianPathToDegreeConstrainedSpanningTree +{ +} + #[reduction( transform = exact { num_vertices = "num_vertices", diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index 378545040..2cd1e8c0d 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,20 @@ impl ReductionResult for ReductionHamiltonianPathToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionHamiltonianPathToILP {} + #[reduction( transform = upper_bound { num_vars = "num_vertices^2 + 2 * num_edges * num_vertices", @@ -95,14 +103,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..f37750098 100644 --- a/src/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -32,12 +32,20 @@ impl ReductionResult for ReductionHPToIST { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionHPToIST {} + #[reduction( transform = exact { num_vertices = "num_vertices", diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index 0a9186ee1..fd68c11c7 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 @@ -33,16 +34,18 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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()) { + 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 + .iter() + .zip(self.target.inner().graph().edges()) + { if selected { adjacency[u].push(v); adjacency[v].push(u); @@ -52,9 +55,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() @@ -68,31 +71,17 @@ impl ReductionResult for ReductionHPBTVToLP { } } -impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP { - type Source = HamiltonianPathBetweenTwoVertices; - type Target = LongestPath; - - 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) - }), - ) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionHPBTVToLP {} #[reduction( - aggregate = custom, transform = exact { 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 +96,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 +121,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/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/ilp_qubo.rs b/src/rules/ilp_qubo.rs index fbe885057..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() @@ -54,6 +53,7 @@ impl ReductionResult for ReductionILPToQUBO { } } +#[crate::aggregate_reduction] impl crate::rules::AggregateReductionResult for ReductionILPToQUBO { type Source = ILP; type Target = QUBO; @@ -79,7 +79,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..e9d4ee032 100644 --- a/src/rules/integralflowbundles_ilp.rs +++ b/src/rules/integralflowbundles_ilp.rs @@ -27,12 +27,20 @@ impl ReductionResult for ReductionIFBToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionIFBToILP {} + #[reduction( transform = exact { num_vars = "num_arcs", diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 006ecdb56..502e80405 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -26,12 +26,20 @@ impl ReductionResult for ReductionIFHAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionIFHAToILP {} + #[reduction( transform = upper_bound { num_vars = "num_arcs", diff --git a/src/rules/integralflowwithmultipliers_ilp.rs b/src/rules/integralflowwithmultipliers_ilp.rs index 6c700a3ba..780e28dfb 100644 --- a/src/rules/integralflowwithmultipliers_ilp.rs +++ b/src/rules/integralflowwithmultipliers_ilp.rs @@ -26,12 +26,20 @@ impl ReductionResult for ReductionIFWMToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionIFWMToILP {} + #[reduction( transform = exact { num_vars = "num_arcs", diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index e2d9e2ec4..b86657b1f 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -28,12 +28,20 @@ impl ReductionResult for ReductionISTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionISTToILP {} + #[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..af52a2233 100644 --- a/src/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -38,7 +38,12 @@ impl ReductionResult for ReductionKCliqueToBCBS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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) @@ -48,8 +53,11 @@ impl ReductionResult for ReductionKCliqueToBCBS { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToBCBS {} + #[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 +72,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..8513daf61 100644 --- a/src/rules/kclique_conjunctivebooleanquery.rs +++ b/src/rules/kclique_conjunctivebooleanquery.rs @@ -38,7 +38,12 @@ impl ReductionResult for ReductionKCliqueToCBQ { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -47,6 +52,9 @@ impl ReductionResult for ReductionKCliqueToCBQ { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToCBQ {} + #[reduction( transform = exact { domain_size = "num_vertices", @@ -65,6 +73,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..4ce5c4cf7 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -43,12 +43,20 @@ impl ReductionResult for ReductionKCliqueToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToILP {} + #[reduction( transform = upper_bound { num_vars = "num_vertices", diff --git a/src/rules/kclique_subgraphisomorphism.rs b/src/rules/kclique_subgraphisomorphism.rs index f39f27561..a34b08bd9 100644 --- a/src/rules/kclique_subgraphisomorphism.rs +++ b/src/rules/kclique_subgraphisomorphism.rs @@ -38,7 +38,12 @@ impl ReductionResult for ReductionKCliqueToSubIso { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -47,6 +52,9 @@ impl ReductionResult for ReductionKCliqueToSubIso { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKCliqueToSubIso {} + #[reduction( transform = exact { num_host_vertices = "num_vertices", diff --git a/src/rules/kcoloring_bicliquecover.rs b/src/rules/kcoloring_bicliquecover.rs index 46be9a726..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; @@ -121,6 +120,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..bbe67ad67 100644 --- a/src/rules/kcoloring_clustering.rs +++ b/src/rules/kcoloring_clustering.rs @@ -32,7 +32,12 @@ impl ReductionResult for ReductionKColoringToClustering { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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()) } @@ -52,9 +57,12 @@ fn build_distances(graph: &SimpleGraph) -> Vec> { distances } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToClustering {} + #[reduction( - transform = exact { - num_elements = "num_vertices", + transform = upper_bound { + num_elements = "num_vertices + 2", num_clusters = "num_colors", } )] @@ -62,6 +70,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..4ced2f8f5 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,47 @@ impl ReductionResult for ReductionKColoringToPartitionIntoCliques { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness is not satisfying", + )?; - Ok(target_solution.to_vec()) + Ok(target_solution[..self.source_num_vertices].to_vec()) } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToPartitionIntoCliques {} + #[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 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())), + 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..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,6 +77,9 @@ impl ReductionResult for ReductionKColoringToTDCS { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKColoringToTDCS {} + #[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..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,6 +48,9 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToAcyclicPartition {} + #[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..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]; @@ -235,6 +234,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..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,6 +161,9 @@ fn normalize( }) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToCyclicOrdering {} + #[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..9e355797a 100644 --- a/src/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -1,70 +1,136 @@ -//! 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::types::One; 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, + 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 } + /// 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) + 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) + .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(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToDecisionMVC {} + #[reduction( transform = exact { num_vertices = "2 * num_vars + 3 * num_clauses", num_edges = "num_vars + 6 * num_clauses", } )] -impl ReduceTo>> for KSatisfiability { +impl ReduceTo>> for KSatisfiability { 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![One; 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, }) } } @@ -86,11 +152,19 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>, + Decision>, >( 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_directedtwocommodityintegralflow.rs b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs index a9e5d8bcb..655396bd3 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; @@ -175,7 +170,12 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 @@ -186,6 +186,9 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow {} + #[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..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,6 +94,9 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToFeasibleRegisterAssignment {} + #[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..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,6 +53,9 @@ impl ReductionResult for Reduction3SATToKClique { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToKClique {} + #[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..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,6 +47,9 @@ impl ReductionResult for Reduction3SatToKernel { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SatToKernel {} + #[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 deleted file mode 100644 index 38d133bbe..000000000 --- a/src/rules/ksatisfiability_minimumvertexcover.rs +++ /dev/null @@ -1,155 +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, -} - -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> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - 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() - }) - } -} - -#[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, &lit) in clause.literals.iter().enumerate() { - 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, - }) - } -} - -#[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_monochromatictriangle.rs b/src/rules/ksatisfiability_monochromatictriangle.rs index 61a8de78a..d1c16cfcd 100644 --- a/src/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/rules/ksatisfiability_monochromatictriangle.rs @@ -55,7 +55,12 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(); @@ -65,6 +70,9 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToMonochromaticTriangle {} + #[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..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,6 +45,9 @@ impl ReductionResult for Reduction3SATToOneInThreeSAT { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToOneInThreeSAT {} + #[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 e994b9cda..2d651e5f1 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,43 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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() + .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() - }) +#[crate::aggregate_reduction] +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( 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 +382,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/ksatisfiability_quadraticcongruences.rs b/src/rules/ksatisfiability_quadraticcongruences.rs index 15320498f..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,6 +314,9 @@ fn witness_config_for_assignment( Some(witness_value_from_alphas(&alphas, &construction.thetas)) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticCongruences {} + #[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..91dfdf19e 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -32,7 +32,12 @@ impl ReductionResult for Reduction3SATToQuadraticDiophantineEquations { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 @@ -62,6 +67,9 @@ fn translate_congruence(source: &QuadraticCongruences) -> QuadraticDiophantineEq QuadraticDiophantineEquations::new(BigUint::one(), source.b().clone(), c) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToQuadraticDiophantineEquations {} + #[reduction( transform = upper_bound { bit_length_a = "1", diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index 4a7b8b21c..6ebb91c9f 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 @@ -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 !crate::rules::AggregateReductionResult::extract_value(self, 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()) } } @@ -51,14 +50,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 @@ -68,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 !crate::rules::AggregateReductionResult::extract_value(self, 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()) } } @@ -326,83 +323,74 @@ fn build_qubo_matrix( Ok((matrix, constant)) } -impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO { - type Source = KSatisfiability; - type Target = QUBO; - 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)) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionKSatToQUBO {} -impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO { - type Source = KSatisfiability; - type Target = QUBO; - 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)) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToQUBO {} #[reduction( - aggregate = custom, transform = exact { 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, }) } } #[reduction( - aggregate = custom, transform = exact { 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 +414,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 +438,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/ksatisfiability_registersufficiency.rs b/src/rules/ksatisfiability_registersufficiency.rs index 8cde3e058..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,6 +322,9 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToRegisterSufficiency {} + #[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..7fa0c5dc5 100644 --- a/src/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/rules/ksatisfiability_simultaneousincongruences.rs @@ -30,7 +30,12 @@ impl ReductionResult for Reduction3SATToSimultaneousIncongruences { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(|_| { @@ -172,6 +177,9 @@ fn ensure_prime_product_fits_target( Ok(()) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToSimultaneousIncongruences {} + #[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..7079a3db8 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -39,7 +39,12 @@ impl ReductionResult for Reduction3SATToSubsetSum { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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: @@ -64,6 +69,9 @@ fn digits_to_integer(digits: &[u8]) -> BigUint { 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 112eabbb6..b79da5e9e 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,12 @@ impl ReductionResult for Reduction3SATToTimetableDesign { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(); @@ -786,11 +791,14 @@ impl ReductionResult for Reduction3SATToTimetableDesign { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for Reduction3SATToTimetableDesign {} + #[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/longestcircuit_ilp.rs b/src/rules/longestcircuit_ilp.rs index c67c9a2e7..b122df15a 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,87 @@ 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> { + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDecisionLongestCircuitToILP {} + +#[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/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/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/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index 892f31b03..7b2187524 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,15 +85,14 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToMinimumIntersectionG &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - 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 !self.target.evaluate(target_solution)?.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/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/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/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/minimumvertexcover_comparativecontainment.rs b/src/rules/minimumvertexcover_comparativecontainment.rs index 4e08fc68a..a3d5ebffe 100644 --- a/src/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/rules/minimumvertexcover_comparativecontainment.rs @@ -34,17 +34,19 @@ 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionDecisionMVCToComparativeContainment {} + #[reduction( transform = upper_bound { universe_size = "num_vertices", 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 Vec Vec => < $($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..137f5d4b3 100644 --- a/src/rules/monochromatictriangle_ilp.rs +++ b/src/rules/monochromatictriangle_ilp.rs @@ -29,12 +29,20 @@ impl ReductionResult for ReductionMonochromaticTriangleToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionMonochromaticTriangleToILP {} + #[reduction( transform = upper_bound { num_vars = "num_edges", diff --git a/src/rules/multiplechoicebranching_ilp.rs b/src/rules/multiplechoicebranching_ilp.rs index cba6f76cd..e341e994c 100644 --- a/src/rules/multiplechoicebranching_ilp.rs +++ b/src/rules/multiplechoicebranching_ilp.rs @@ -23,7 +23,12 @@ impl ReductionResult for ReductionMultipleChoiceBranchingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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) @@ -31,6 +36,9 @@ impl ReductionResult for ReductionMultipleChoiceBranchingToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionMultipleChoiceBranchingToILP {} + #[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..520ee4674 100644 --- a/src/rules/multiprocessorscheduling_ilp.rs +++ b/src/rules/multiprocessorscheduling_ilp.rs @@ -37,7 +37,12 @@ impl ReductionResult for ReductionMSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -48,6 +53,9 @@ impl ReductionResult for ReductionMSToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionMSToILP {} + #[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..8affee9e1 100644 --- a/src/rules/naesatisfiability_ilp.rs +++ b/src/rules/naesatisfiability_ilp.rs @@ -30,12 +30,20 @@ impl ReductionResult for ReductionNAESATToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToILP {} + #[reduction( transform = exact { num_vars = "num_vars", diff --git a/src/rules/naesatisfiability_maxcut.rs b/src/rules/naesatisfiability_maxcut.rs index 55e8e81c7..b5fec3dc9 100644 --- a/src/rules/naesatisfiability_maxcut.rs +++ b/src/rules/naesatisfiability_maxcut.rs @@ -11,6 +11,7 @@ //! Section 4, arXiv:1512.03127. The triangle construction is the classical //! NAE-3SAT to MaxCut reduction (Garey and Johnson, ND16). +use crate::models::decision::Decision; use crate::models::formula::NAESatisfiability; use crate::models::graph::MaxCut; use crate::reduction; @@ -20,14 +21,13 @@ use crate::topology::SimpleGraph; /// Result of reducing NAESatisfiability to MaxCut. #[derive(Debug, Clone)] pub struct ReductionNAESATToMaxCut { - target: MaxCut, + 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 @@ -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 !crate::rules::AggregateReductionResult::extract_value(self, 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,18 +57,8 @@ impl ReductionResult for ReductionNAESATToMaxCut { } } -impl crate::rules::AggregateReductionResult for ReductionNAESATToMaxCut { - type Source = NAESatisfiability; - type Target = MaxCut; - - 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)) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToMaxCut {} /// Dimensions, variable-edge weight, and certificate for legal clause lengths. fn nae_maxcut_parameters( @@ -77,9 +66,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() @@ -144,13 +134,12 @@ 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", } )] -impl ReduceTo> for NAESatisfiability { +impl ReduceTo>> for NAESatisfiability { type Result = ReductionNAESATToMaxCut; fn reduce_to(&self) -> Result { @@ -173,7 +162,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 +189,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 +217,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/naesatisfiability_partitionintoperfectmatchings.rs b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs index c7a09e983..5155cb773 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,18 @@ impl ReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 .variables .iter() + .take(self.layout.source_num_vars) .map(|variable| target_solution[variable.t] == 0) .collect() }) @@ -86,12 +96,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 +187,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 +330,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 +345,13 @@ fn build_layout( }) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings {} + #[reduction( - transform = exact { - num_vertices = "4 * num_vars + 16 * num_clauses", - num_edges = "3 * num_vars + 21 * num_clauses", + 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 +359,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/rules/naesatisfiability_setsplitting.rs b/src/rules/naesatisfiability_setsplitting.rs index 854e7ed69..30ae3e3a5 100644 --- a/src/rules/naesatisfiability_setsplitting.rs +++ b/src/rules/naesatisfiability_setsplitting.rs @@ -29,7 +29,12 @@ impl ReductionResult for ReductionNAESATToSetSplitting { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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()) } @@ -44,6 +49,9 @@ fn literal_element_index(lit: i64, num_vars: usize) -> usize { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionNAESATToSetSplitting {} + #[reduction( transform = exact { universe_size = "2 * num_vars", diff --git a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 30729b498..014c937f5 100644 --- a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -30,7 +30,12 @@ impl ReductionResult for ReductionN3DMToNMTS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(); @@ -77,6 +82,9 @@ fn checked_target_sum(bound: i64, w_size: i64) -> Result { .ok_or("computing a derived target sum overflowed") } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionN3DMToNMTS {} + #[reduction( transform = exact { num_pairs = "num_groups", diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index 3f658a8bf..617233292 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -48,7 +48,12 @@ impl ReductionResult for ReductionNMTSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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]; @@ -62,6 +67,9 @@ impl ReductionResult for ReductionNMTSToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionNMTSToILP {} + #[reduction( transform = upper_bound { num_vars = "num_pairs * num_pairs * num_pairs", @@ -85,7 +93,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/openshopscheduling_ilp.rs b/src/rules/openshopscheduling_ilp.rs index 41f385661..5a7d01091 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,84 @@ 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> { + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionDecisionOpenShopSchedulingToILP {} + +#[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/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 1efcd0b0b..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,6 +47,12 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult + for ReductionOptimalLinearArrangementToConsecutiveOnesMatrixAugmentation +{ +} + #[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..a5e2872aa 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,12 @@ impl ReductionResult for ReductionPartitionToBinPacking { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 @@ -46,6 +52,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 +75,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..75a121051 100644 --- a/src/rules/partition_cosineproductintegration.rs +++ b/src/rules/partition_cosineproductintegration.rs @@ -32,12 +32,20 @@ impl ReductionResult for ReductionPartitionToCPI { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToCPI {} + #[reduction( transform = exact { num_coefficients = "num_elements", diff --git a/src/rules/partition_integralflowwithmultipliers.rs b/src/rules/partition_integralflowwithmultipliers.rs index 8c1a96391..f72063df2 100644 --- a/src/rules/partition_integralflowwithmultipliers.rs +++ b/src/rules/partition_integralflowwithmultipliers.rs @@ -36,7 +36,12 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { "the fixed infeasible target instance has no extractable witness", ) })?; - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target witness does not satisfy the target problem", + )?; target_solution[..item_arc_count] .iter() @@ -46,8 +51,11 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToIntegralFlowWithMultipliers {} + #[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..bb0584ff8 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,31 @@ impl ReductionResult for ReductionPartitionToKnapsack { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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()) } } +#[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 +63,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..4aa12595b 100644 --- a/src/rules/partition_multiprocessorscheduling.rs +++ b/src/rules/partition_multiprocessorscheduling.rs @@ -36,7 +36,12 @@ impl ReductionResult for ReductionPartitionToMPS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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() @@ -45,6 +50,9 @@ impl ReductionResult for ReductionPartitionToMPS { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToMPS {} + #[reduction( transform = exact { num_tasks = "num_elements", diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index f3126f0c5..7fb5d0ac2 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 @@ -22,18 +22,17 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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.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 +49,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 +63,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") })?; @@ -78,21 +77,10 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { } } -impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopScheduling { - type Source = Partition; - type Target = OpenShopScheduling; - - 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)) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopScheduling {} #[reduction( - aggregate = custom, transform = exact { num_jobs = "num_elements + 1", num_machines = "3", @@ -101,7 +89,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 +99,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 +115,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_productionplanning.rs b/src/rules/partition_productionplanning.rs index 3dc8856fb..137313d78 100644 --- a/src/rules/partition_productionplanning.rs +++ b/src/rules/partition_productionplanning.rs @@ -21,7 +21,12 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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() @@ -30,6 +35,9 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToProductionPlanning {} + #[reduction( transform = exact { num_periods = "num_elements + 1", diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index 72827a5d0..0fca17064 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 @@ -22,27 +23,26 @@ 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 !crate::rules::AggregateReductionResult::extract_value(self, 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.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; } } @@ -52,28 +52,17 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight } } +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { - type Source = Partition; - type Target = SequencingToMinimizeTardyTaskWeight; - - 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])) - } } #[reduction( - aggregate = custom, transform = exact { num_tasks = "num_elements", })] -impl ReduceTo for Partition { +impl ReduceTo> for Partition { type Result = ReductionPartitionToSequencingToMinimizeTardyTaskWeight; fn reduce_to(&self) -> Result { @@ -83,7 +72,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 +89,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/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs index 57f6c0d14..ceaca59ee 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -30,7 +30,12 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), 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 target_solution.len() != self.source_n { return Err(crate::rules::ExtractionError::invalid(format!( @@ -43,8 +48,11 @@ impl ReductionResult for ReductionPartitionToSubsetSum { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionPartitionToSubsetSum {} + #[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..691f3cb28 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,12 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 {}", @@ -65,6 +68,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 +104,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..f15013c49 100644 --- a/src/rules/partitionintocliques_ilp.rs +++ b/src/rules/partitionintocliques_ilp.rs @@ -25,7 +25,12 @@ impl ReductionResult for ReductionPartitionIntoCliquesToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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| { @@ -41,6 +46,9 @@ impl ReductionResult for ReductionPartitionIntoCliquesToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToILP {} + #[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..1c4639752 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 @@ -158,17 +155,16 @@ 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 !Min::meets_bound(&value, &self.target_bound) { - 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; - 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 +194,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 @@ -213,29 +201,21 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques } } +#[crate::aggregate_reduction(identity)] impl crate::rules::AggregateReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques { - type Source = PartitionIntoCliques; - type Target = MinimumCoveringByCliques; - - 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)) - } } #[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", } )] -impl ReduceTo> for PartitionIntoCliques { +impl ReduceTo>> + for PartitionIntoCliques +{ type Result = ReductionPartitionIntoCliquesToMinimumCoveringByCliques; fn reduce_to(&self) -> Result { @@ -243,14 +223,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 +267,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 +332,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( source, SolutionPair { diff --git a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index 193a5d681..e393cd3c1 100644 --- a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -37,17 +37,25 @@ impl ReductionResult for ReductionPPL2ToBCSF { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionPPL2ToBCSF {} + #[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..d08b94113 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -48,7 +48,12 @@ impl ReductionResult for ReductionPIPL2ToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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,6 +64,9 @@ impl ReductionResult for ReductionPIPL2ToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPIPL2ToILP {} + #[reduction( transform = upper_bound { num_vars = "num_vertices^2 + num_edges * num_vertices", @@ -74,7 +82,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..644eb9458 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -41,7 +41,12 @@ impl ReductionResult for ReductionPITToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -52,6 +57,9 @@ impl ReductionResult for ReductionPITToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPITToILP {} + #[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..bf9b50ebd 100644 --- a/src/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/rules/pathconstrainednetworkflow_ilp.rs @@ -26,14 +26,22 @@ impl ReductionResult for ReductionPCNFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPCNFToILP {} + #[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..4de993d6f 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -42,7 +42,12 @@ impl ReductionResult for ReductionPCSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -53,6 +58,9 @@ impl ReductionResult for ReductionPCSToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionPCSToILP {} + #[reduction( transform = exact { num_vars = "num_tasks * deadline", diff --git a/src/rules/prizecollectingsteinerforest_steinertree.rs b/src/rules/prizecollectingsteinerforest_steinertree.rs index 540183f1a..c79151290 100644 --- a/src/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/rules/prizecollectingsteinerforest_steinertree.rs @@ -16,11 +16,13 @@ //! - every `v in V` is attached to `r` by an edge of cost `omega` (so each //! tree component of `F` is paid by exactly one root-attachment edge in //! `T*`), -//! - for every `v in V_p` we add `(v, t_v)` of cost `0` and `(r, t_v)` of -//! cost `beta * p(v)`, +//! - with `M = omega + 1`, every `v in V_p` gets `(v, t_v)` of cost `M` +//! and `(r, t_v)` of cost `M + beta * p(v)`, //! - the terminal set is `{r} cup {t_v : v in V_p}`. //! -//! The Steiner-tree optimum then equals the PCSF optimum. +//! The Steiner-tree optimum equals the PCSF optimum plus `M * |V_p|`. +//! Every gadget terminal is a leaf in an optimum: replacing its omit edge +//! with a root attachment when both gadget edges are selected lowers cost. //! //! References: //! - Bienstock, Goemans, Simchi-Levi, Williamson, "A note on the prize @@ -38,7 +40,7 @@ use crate::topology::{Graph, SimpleGraph}; /// Result of reducing PCSF to SteinerTree. /// -/// Stores the original PCSF source parameterss plus the mapping from the target +/// Stores the original PCSF source parameters plus the mapping from the target /// graph's edge list back to the source variables (the original edge index /// for each "original" edge, and the source vertex index for each gadget /// include-edge). Other target edges (root-attachment and gadget omit-edges) @@ -73,7 +75,12 @@ impl ReductionResult for ReductionPCSFToSteinerTree { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - 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 edges do not form a Steiner tree", + )?; Ok({ let n = self.num_source_vertices; @@ -156,18 +163,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 +225,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec => , 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/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index cf75fb3a1..78334ddf4 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -25,12 +25,20 @@ impl ReductionResult for ReductionRPCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRPCToILP {} + #[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..447c376c8 100644 --- a/src/rules/registersufficiency_ilp.rs +++ b/src/rules/registersufficiency_ilp.rs @@ -30,12 +30,20 @@ impl ReductionResult for ReductionRegisterSufficiencyToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRegisterSufficiencyToILP {} + #[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 80a10beb6..7247687d4 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 { @@ -167,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, @@ -186,9 +191,11 @@ 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. + pub aggregate_view_fn: Option, /// Whether this is a Turing (multi-query) reduction. pub turing: bool, } @@ -248,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..99b8510ad 100644 --- a/src/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/rules/resourceconstrainedscheduling_ilp.rs @@ -33,7 +33,12 @@ impl ReductionResult for ReductionRCSToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -44,6 +49,9 @@ impl ReductionResult for ReductionRCSToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRCSToILP {} + #[reduction( transform = exact { num_vars = "num_tasks * deadline", diff --git a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs index 8c40f906a..a27f69c94 100644 --- a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -40,7 +40,12 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -54,8 +59,14 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult + for ReductionRootedTreeArrangementToRootedTreeStorageAssignment +{ +} + #[reduction( - transform = exact { + transform = upper_bound { universe_size = "num_vertices", num_subsets = "num_edges", } @@ -65,41 +76,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..0ca97f3a7 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -76,12 +76,20 @@ impl ReductionResult for ReductionRTSAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionRTSAToILP {} + #[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 +115,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 +175,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 +388,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..3f79a7a1e 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -30,7 +30,12 @@ impl ReductionResult for ReductionSATToCircuit { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 @@ -41,6 +46,9 @@ impl ReductionResult for ReductionSATToCircuit { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToCircuit {} + #[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..2c824337f 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,22 @@ 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 - }) + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target coloring is not valid", + )?; + Ok(self + .pos_vertices + .iter() + .map(|&vertex| target_solution[vertex] == target_solution[0]) + .collect()) } } @@ -298,10 +269,13 @@ impl ReductionSATToColoring { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToColoring {} + #[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 +296,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..b7aef7264 100644 --- a/src/rules/sat_ksat.rs +++ b/src/rules/sat_ksat.rs @@ -36,7 +36,12 @@ impl ReductionResult for ReductionSATToKSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 @@ -45,6 +50,11 @@ impl ReductionResult for ReductionSATToKSAT { } } +crate::register_aggregate_reduction!(ReductionSATToKSAT); + +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToKSAT {} + /// Add a clause to the K-SAT formula, splitting or padding as necessary. /// /// # Algorithm @@ -121,8 +131,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 +196,12 @@ impl ReductionResult for ReductionKSATToSAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 @@ -195,6 +210,11 @@ impl ReductionResult for ReductionKSATToSAT { } } +crate::register_aggregate_reduction!(ReductionKSATToSAT); + +#[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 { let clauses = ksat.clauses().to_vec(); diff --git a/src/rules/sat_maximumindependentset.rs b/src/rules/sat_maximumindependentset.rs index d8271f74e..79642a363 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 @@ -82,14 +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)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.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) { @@ -101,18 +98,8 @@ impl ReductionResult for ReductionSATToIS { } } -impl crate::rules::AggregateReductionResult for ReductionSATToIS { - type Source = Satisfiability; - type Target = MaximumIndependentSet; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, target_value: Max) -> Or { - Or(target_value == Max(Some(self.target_size))) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToIS {} impl ReductionSATToIS { /// Get the number of clauses in the source SAT problem. @@ -127,20 +114,20 @@ impl ReductionSATToIS { } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "num_literals", 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 +167,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 +200,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 bed0c45e8..9dea994c8 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 @@ -62,14 +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)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.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 { @@ -80,18 +76,8 @@ impl ReductionResult for ReductionSATToDS { } } -impl crate::rules::AggregateReductionResult for ReductionSATToDS { - type Source = Satisfiability; - type Target = MinimumDominatingSet; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, target_value: Min) -> Or { - Or(target_value == Min(Some(self.target_size))) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToDS {} impl ReductionSATToDS { /// Compute the graph dimensions and exact certificate before allocation. @@ -105,20 +91,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)) } @@ -134,13 +121,12 @@ impl ReductionSATToDS { } #[reduction( - aggregate = custom, transform = upper_bound { num_vertices = "3 * num_vars + num_clauses", 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 +182,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 +212,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( source, SolutionPair { diff --git a/src/rules/satisfiability_integralflowhomologousarcs.rs b/src/rules/satisfiability_integralflowhomologousarcs.rs index d6a1fa8dc..17b963efc 100644 --- a/src/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/rules/satisfiability_integralflowhomologousarcs.rs @@ -106,7 +106,12 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::traits::validate_target_witness( + self.target_problem(), + target_solution, + |value| value.0, + "target flow is not feasible", + )?; Ok({ self.variable_paths @@ -117,8 +122,11 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToIntegralFlowHomologousArcs {} + #[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 +177,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..c2d97a009 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 @@ -26,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)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.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()) } } -impl crate::rules::AggregateReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { - type Source = Satisfiability; - type Target = Maximum2Satisfiability; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: Max) -> Or { - Or(value == Max(Some(self.target_score))) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability {} fn add_normalized_clause( clause: &CNFClause, @@ -121,13 +108,12 @@ 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 +123,7 @@ impl ReduceTo for Satisfiability { .map_err( crate::rules::ReductionError::construction::< Satisfiability, - Maximum2Satisfiability, + Decision, >, )?; @@ -145,19 +131,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 +152,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 +182,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/satisfiability_naesatisfiability.rs b/src/rules/satisfiability_naesatisfiability.rs index 0be93eb62..e0efdf532 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -33,16 +33,14 @@ impl ReductionResult for ReductionSATToNAESAT { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; - 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,6 +49,9 @@ impl ReductionResult for ReductionSATToNAESAT { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToNAESAT {} + #[reduction( transform = exact { num_vars = "num_vars + 1", diff --git a/src/rules/satisfiability_nontautology.rs b/src/rules/satisfiability_nontautology.rs index 3ba7ee1c8..6196139c3 100644 --- a/src/rules/satisfiability_nontautology.rs +++ b/src/rules/satisfiability_nontautology.rs @@ -25,12 +25,20 @@ impl ReductionResult for ReductionSATToNonTautology { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionSATToNonTautology {} + #[reduction( transform = exact { num_vars = "num_vars", diff --git a/src/rules/schedulingwithindividualdeadlines_ilp.rs b/src/rules/schedulingwithindividualdeadlines_ilp.rs index a36ee872d..c073ba6c1 100644 --- a/src/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/rules/schedulingwithindividualdeadlines_ilp.rs @@ -43,12 +43,20 @@ impl ReductionResult for ReductionSWIDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWIDToILP {} + #[reduction( transform = exact { num_vars = "num_tasks * max_deadline", 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 f046bd232..feb90dd53 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -37,7 +37,12 @@ impl ReductionResult for ReductionSTMWTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -49,7 +54,11 @@ impl ReductionResult for ReductionSTMWTToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSTMWTToILP {} + +#[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..85dc2be3e 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -40,7 +40,12 @@ impl ReductionResult for ReductionSWDSTToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -50,7 +55,11 @@ impl ReductionResult for ReductionSWDSTToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWDSTToILP {} + +#[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..663628d5f 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -47,7 +47,12 @@ impl ReductionResult for ReductionSWIToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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() @@ -68,6 +73,9 @@ impl ReductionResult for ReductionSWIToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWIToILP {} + #[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..91c5d8109 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -33,7 +33,12 @@ impl ReductionResult for ReductionSWRTDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -50,7 +55,11 @@ impl ReductionResult for ReductionSWRTDToILP { } } -#[reduction(transform = upper_bound { +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSWRTDToILP {} + +#[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..547982fa4 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -33,7 +33,12 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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] @@ -43,6 +48,9 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSetSplittingToBetweenness {} + #[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..ecccd257b 100644 --- a/src/rules/setsplitting_ilp.rs +++ b/src/rules/setsplitting_ilp.rs @@ -32,12 +32,20 @@ impl ReductionResult for ReductionSetSplittingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSetSplittingToILP {} + #[reduction( transform = exact { num_vars = "universe_size", diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index b19b79368..b03552ee3 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -26,7 +26,12 @@ impl ReductionResult for ReductionSMCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -37,10 +42,13 @@ impl ReductionResult for ReductionSMCToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSMCToILP {} + #[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/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/rules/steinertree_ilp.rs b/src/rules/steinertree_ilp.rs index e19b19e68..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) @@ -61,7 +59,7 @@ impl ReduceTo> 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 +130,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/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index 1f6cf33d2..aa4e22978 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -58,7 +58,12 @@ impl ReductionResult for ReductionSTSCToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -107,10 +112,13 @@ impl ReductionResult for ReductionSTSCToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSTSCToILP {} + #[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..00fbacf36 100644 --- a/src/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/rules/strongconnectivityaugmentation_ilp.rs @@ -27,7 +27,12 @@ impl ReductionResult for ReductionSCAToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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() @@ -36,10 +41,13 @@ impl ReductionResult for ReductionSCAToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSCAToILP {} + #[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..7237dab2b 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -38,7 +38,12 @@ impl ReductionResult for ReductionSubIsoToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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, @@ -49,6 +54,9 @@ impl ReductionResult for ReductionSubIsoToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionSubIsoToILP {} + #[reduction( transform = upper_bound { num_vars = "num_pattern_vertices * num_host_vertices", @@ -79,9 +87,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 dd4762df5..8dbf54908 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -1,23 +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::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: Decision, num_elements: usize, - target_distance: f64, } impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { type Source = SubsetSum; - type Target = ClosestVectorProblem; + type Target = Decision; fn target_problem(&self) -> &Self::Target { &self.target @@ -27,14 +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)?; - let certificate = crate::rules::AggregateReductionResult::extract_value(self, value); - if !certificate.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) @@ -42,18 +38,8 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { } } -impl crate::rules::AggregateReductionResult for ReductionSubsetSumToClosestVectorProblem { - type Source = SubsetSum; - 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))) - } -} +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToClosestVectorProblem {} impl ReductionSubsetSumToClosestVectorProblem { /// Check the dense representation before allocating its columns. @@ -62,9 +48,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)?; @@ -81,13 +68,12 @@ 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", }, )] -impl ReduceTo> for SubsetSum { +impl ReduceTo> for SubsetSum { type Result = ReductionSubsetSumToClosestVectorProblem; fn reduce_to(&self) -> Result { @@ -112,9 +98,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 +111,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( - n, - "representing the subset-sum distance threshold", - )?; - let target_distance = crate::types::i64_to_exact_f64(count) - .map_err(ConstructionError::from) - .map_err(>>::target_construction)? - .sqrt(); + 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_distance, }) } } @@ -153,7 +132,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/rules/subsetsum_integerexpressionmembership.rs b/src/rules/subsetsum_integerexpressionmembership.rs index c187a66e4..b56afe8a2 100644 --- a/src/rules/subsetsum_integerexpressionmembership.rs +++ b/src/rules/subsetsum_integerexpressionmembership.rs @@ -21,7 +21,12 @@ impl ReductionResult for ReductionSubsetSumToIntegerExpressionMembership { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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. @@ -61,6 +66,9 @@ fn build_expression(sizes: &[i64]) -> Result { Ok(expr) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToIntegerExpressionMembership {} + #[reduction( transform = exact { num_union_nodes = "num_elements", 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/subsetsum_partition.rs b/src/rules/subsetsum_partition.rs index 8b1e2726f..ae35fb792 100644 --- a/src/rules/subsetsum_partition.rs +++ b/src/rules/subsetsum_partition.rs @@ -34,7 +34,12 @@ impl ReductionResult for ReductionSubsetSumToPartition { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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]; @@ -60,8 +65,11 @@ impl ReductionResult for ReductionSubsetSumToPartition { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionSubsetSumToPartition {} + #[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..b9a430bde 100644 --- a/src/rules/threedimensionalmatching_ilp.rs +++ b/src/rules/threedimensionalmatching_ilp.rs @@ -22,12 +22,20 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToILP {} + #[reduction( transform = exact { num_vars = "num_triples", diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index c8239dae0..20d9c722e 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,12 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 {}", @@ -64,6 +69,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 +109,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 +127,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..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,6 +340,9 @@ fn enumerate_pair_keys(num_regulars: usize) -> Option> { Some(pairs) } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionThreeDimensionalMatchingToThreePartition {} + #[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..48536d66c 100644 --- a/src/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/rules/threepartition_resourceconstrainedscheduling.rs @@ -42,12 +42,20 @@ impl ReductionResult for ReductionThreePartitionToRCS { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(identity)] +impl crate::rules::AggregateReductionResult for ReductionThreePartitionToRCS {} + #[reduction( transform = exact { num_tasks = "num_elements", diff --git a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index d1b480304..a77aea096 100644 --- a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -51,7 +51,12 @@ impl ReductionResult for ReductionThreePartitionToSRTD { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 @@ -86,6 +91,9 @@ impl ReductionResult for ReductionThreePartitionToSRTD { } } +#[crate::aggregate_reduction(identity)] +impl crate::rules::AggregateReductionResult for ReductionThreePartitionToSRTD {} + #[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..4ddfe908f 100644 --- a/src/rules/timetabledesign_ilp.rs +++ b/src/rules/timetabledesign_ilp.rs @@ -35,7 +35,12 @@ impl ReductionResult for ReductionTDToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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| { @@ -56,10 +61,13 @@ impl ReductionResult for ReductionTDToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionTDToILP {} + #[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 c33c39754..2507bb4ed 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -168,10 +168,29 @@ 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 /// 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; @@ -259,6 +278,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, @@ -333,6 +359,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 +385,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 +429,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 +472,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 +498,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/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index 42cbda1a9..6a02a8e2b 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)?; + 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 + .as_ref() + .expect("value mapping established a small tour") + .0 + .clone()); + } Ok({ let n = self.num_vertices; @@ -75,6 +95,34 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { } } +#[crate::aggregate_reduction] +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( transform = exact { num_vars = "num_vertices^2", @@ -87,43 +135,106 @@ 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"))?; + // 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 - 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 +300,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 +314,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/rules/undirectedflowlowerbounds_ilp.rs b/src/rules/undirectedflowlowerbounds_ilp.rs index 88db49edf..d3310c430 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,12 @@ impl ReductionResult for ReductionUFLBToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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; @@ -70,10 +75,13 @@ impl ReductionResult for ReductionUFLBToILP { } } +#[crate::aggregate_reduction(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionUFLBToILP {} + #[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 +147,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 +168,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..7cba64e21 100644 --- a/src/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -55,12 +55,20 @@ impl ReductionResult for ReductionU2CIFToILP { &self, target_solution: &::Solution, ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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(ilp_feasibility)] +impl crate::rules::AggregateReductionResult for ReductionU2CIFToILP {} + #[reduction( transform = exact { num_vars = "6 * num_edges", @@ -151,7 +159,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 +177,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 +192,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/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/solvers/customized/closest_vector_problem.rs b/src/solvers/customized/closest_vector_problem.rs index 7f901e8e9..ce382485f 100644 --- a/src/solvers/customized/closest_vector_problem.rs +++ b/src/solvers/customized/closest_vector_problem.rs @@ -1,15 +1,15 @@ -//! 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 crate::traits::Problem; +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,30 +21,25 @@ 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(); + let mut best_representable = problem.evaluate(&vec![0; n]).is_ok(); enumerate( + problem, + &mut best_representable, n - 1, BigRational::zero(), &mu, @@ -53,8 +48,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 { @@ -97,66 +97,76 @@ 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], 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(()); +) { + if partial_squared > *best_squared || (partial_squared == *best_squared && *best_representable) + { + 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 { + 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. - // 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..6d8bc66db 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,58 @@ 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, + crate::models::decision::Decision, "cvp-sphere-enumeration", - |problem| super::closest_vector_problem::solve(problem).map(Some) + |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", + |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/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 e5325a29f..05893d30a 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -1,27 +1,17 @@ //! 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)] 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, @@ -32,7 +22,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}")] @@ -57,19 +47,10 @@ 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, -/// 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. /// @@ -127,170 +108,26 @@ impl ILPSolver { .lookup(&key) .ilp .ok_or_else(|| ILPSolveError::MissingPipeline(key.label()))?; - pipeline.solve_typed(problem, self) - } - - fn solve_backend(&self, problem: &ILP) -> Result, ILPSolveError> - where - V: VariableDomain, - { - 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) + let solution = pipeline.solve_typed(problem, self)?; + problem + .evaluate(&solution) + .map_err(|error| ILPSolveError::InvalidSolution(error.to_string()))?; + Ok(solution) } /// 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 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/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/solvers/pipelines.rs b/src/solvers/pipelines.rs index 15e5aa525..d10636f6a 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,100 @@ 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")]), + ("DecisionMinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), ("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 +141,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 +187,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")]), + ("DecisionLongestCircuit", [("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 +226,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 +269,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 +312,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 +352,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 +359,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 +368,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 +382,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 +390,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 +398,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 +405,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 +437,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 +468,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 +677,107 @@ 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! { + ("SequencingToMinimizeWeightedCompletionTime", []), + ("ILP", [("variable", "i64"), ("coefficient", "i64")]), } 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 +790,147 @@ 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! { + ("SteinerTree", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } 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")]), +} + +register_ilp_pipeline! { + ("DecisionLongestCircuit", [("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", []), + ("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/solvers/registry.rs b/src/solvers/registry.rs index c044e3d36..c86d70ede 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; @@ -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") + ) } } @@ -103,7 +106,7 @@ inventory::collect!(CustomizedSolverRegistration); #[derive(Debug)] pub(crate) struct CompiledIlpPipeline { path: Vec, - reducers: Vec<(ReduceFn, Option)>, + reducers: Vec<(ReduceFn, Option)>, } impl CompiledIlpPipeline { @@ -139,29 +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(reduce) = self.reducers[index].1 { - let input = if index == 0 { - source - } else { - reductions[index - 1].target_problem_any() - }; - let aggregate = reduce(input)?; - // 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)) { - return Err(super::ILPSolveError::UnresolvedDecision( - self.path[index].label(), - )); + 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}" + )) + })?; + 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())) } @@ -287,7 +316,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), @@ -415,7 +444,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/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/example_db.rs b/src/unit_tests/example_db.rs index 73d85007f..168df6c82 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,55 @@ 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)" { + 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(); @@ -890,21 +960,21 @@ 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())]), }; 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()), + ("weight".to_string(), "One".to_string()), ]), }; 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] @@ -980,12 +1050,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] @@ -995,7 +1065,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()), @@ -1003,7 +1073,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] @@ -1031,12 +1101,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 @@ -1102,7 +1172,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()), @@ -1110,7 +1180,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] @@ -1266,7 +1336,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()), @@ -1274,7 +1344,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] @@ -1330,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()) + ); + } +} 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..a4928991b 100644 --- a/src/unit_tests/models/algebraic/closest_vector_problem.rs +++ b/src/unit_tests/models/algebraic/closest_vector_problem.rs @@ -3,23 +3,15 @@ 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")] + ClosestVectorProblem::variant(), + vec![("coefficient", "i64")] ); } @@ -27,10 +19,7 @@ fn test_cvp_constructs_integer_and_real_targets() { 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 +37,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() { +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::NAN]), - Err(ConstructionError::NonFiniteFloat(_)) - )); - 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_coefficient_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([( + "coefficient".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/algebraic/minimum_matrix_cover.rs b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs index 89eb9ad5f..2e6dc7ec6 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 _; @@ -167,3 +182,28 @@ 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()); +} + +#[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); + } +} diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 448fef75b..2c9539116 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]]}); + 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,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(); @@ -202,3 +269,87 @@ fn test_integer_qubo_reports_objective_overflow() { Err(crate::traits::EvaluationError::IntegerOverflow(_)) )); } + +#[test] +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}"); + } +} + +#[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}"); + } +} + +#[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() + ); + } +} 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/decision.rs b/src/unit_tests/models/decision.rs index 9bce00a5b..20fda0ef7 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()), @@ -356,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); + } + } +} 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..75663d77f 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; @@ -142,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/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/kcoloring.rs b/src/unit_tests/models/graph/kcoloring.rs index 588c5721e..55e0260b7 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] @@ -288,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); + } +} 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/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/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..6df940f4c 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] @@ -185,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/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..ac610314a 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; @@ -160,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/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..8ec290c2b 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 _; @@ -213,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 3576d79b5..9dec7081b 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 _; @@ -60,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_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_multiway_cut.rs b/src/unit_tests/models/graph/minimum_multiway_cut.rs index c9831cc6e..110e70296 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!( @@ -134,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 e636dee31..22e24a296 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 _; @@ -207,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/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/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/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/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/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 e5a801e5a..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" )); } @@ -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/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/graph/steiner_tree.rs b/src/unit_tests/models/graph/steiner_tree.rs index 92d4d7a29..f0f8e3d82 100644 --- a/src/unit_tests/models/graph/steiner_tree.rs +++ b/src/unit_tests/models/graph/steiner_tree.rs @@ -1,4 +1,33 @@ 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!({ + "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 +205,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] @@ -190,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]); @@ -198,12 +227,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/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/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/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/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 eabdcf4a9..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,5 +1,41 @@ use super::*; +#[test] +fn input_counts_and_witness_length_must_fit_usize() { + for (objects, domains, tables) in [ + (0, vec![usize::MAX, 1], vec![]), + (usize::MAX, vec![1, 1], vec![]), + ] { + 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()); + } +} + +#[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!( + 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] fn test_consistency_of_database_frequency_tables_validates_persisted_input() { let valid = serde_json::to_value(issue_yes_instance()).unwrap(); @@ -79,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/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/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_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/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/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/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/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/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()) + ); +} 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/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 6595c0007..ce165c6db 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()), }, ], @@ -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] @@ -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 }) @@ -931,12 +935,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) ); } } @@ -948,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)); } @@ -990,20 +990,20 @@ 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>, + Decision>, >(ReductionMode::Witness)); - assert!(!graph.has_direct_reduction_mode::< + assert!(graph.has_direct_reduction_mode::< KSatisfiability, - Decision>, + Decision>, >(ReductionMode::Aggregate)); assert!(!graph.has_direct_reduction_mode::< KSatisfiability, - Decision>, + Decision>, >(ReductionMode::Turing)); } @@ -1087,6 +1087,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/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}" + ); + } +} diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index 80b246964..18318bbb3 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() { @@ -377,7 +438,13 @@ fn unit_variants_construct_without_unit_inputs() { graph => panic!("missing construction case for {graph}"), }, "DecisionMaximumIndependentSet" => json!({"graph":[[0,1],[1,2]],"bound":2}), - "DecisionMinimumDominatingSet" => json!({"graph":graph,"bound":1}), + "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" | "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}), @@ -435,7 +502,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/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..7c5d1723b --- /dev/null +++ b/src/unit_tests/rules/aggregate_contracts.rs @@ -0,0 +1,331 @@ +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::<_, crate::models::decision::Decision>>( + &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(); + 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()); + 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::< + _, + 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); + } +} + +#[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/circuit_spinglass.rs b/src/unit_tests/rules/circuit_spinglass.rs index d44aaec9d..26f961931 100644 --- a/src/unit_tests/rules/circuit_spinglass.rs +++ b/src/unit_tests/rules/circuit_spinglass.rs @@ -1,6 +1,7 @@ use super::*; +use crate::models::decision::Decision; 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 +148,9 @@ 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 +176,9 @@ 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 +208,9 @@ 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 +242,11 @@ 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 +254,9 @@ 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 +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) @@ -276,7 +277,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 +300,9 @@ 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 +341,9 @@ 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::>>::reduce_to(&source).unwrap(); + let target = AggregateReductionResult::target_problem(&reduction).inner(); let expected: BTreeSet<_> = BruteForce::new() .find_all_witnesses(&source) .unwrap() @@ -353,8 +355,15 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&spins) + .unwrap(), + ) + .0 + { let decoded = reduction.extract_solution(&spins).unwrap(); assert!(source.evaluate(&decoded).unwrap().0); actual.insert(decoded); @@ -363,7 +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::Min(None)).0); + assert!(!reduction.extract_value(crate::types::Or(false)).0); } } } @@ -375,11 +384,20 @@ 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 +406,17 @@ 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 +447,9 @@ 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/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/coloring_qubo.rs b/src/unit_tests/rules/coloring_qubo.rs index 2b4ea3793..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,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 +30,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 +51,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 +70,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 +92,8 @@ 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 +118,13 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + ) + .0, expected ); match reduction.extract_solution(&config) { @@ -128,13 +139,16 @@ 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(false)).0 ); assert!(reduction.extract_solution(&vec![false; n * k + 1]).is_err()); } 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, diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 5f39c738d..ffbe8d295 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -27,11 +27,14 @@ 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::>>::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 +55,10 @@ 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::>>::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 +81,10 @@ 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::>>::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 +102,9 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins assert_eq!( crate::rules::AggregateReductionResult::extract_value( &reduction, - Min(Some(target_value)) + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&target_solution) + .unwrap() ), Or(false) ); @@ -127,9 +134,11 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_all_small_graphs() for bound in bounds { let source = decision_mds(n, &edges, bound); let reduction = - ReduceTo::>::reduce_to(&source) - .unwrap(); - let target = reduction.target_problem(); + ReduceTo::>>::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 +151,13 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&placement) + .unwrap(), + ) + .0; match reduction.extract_solution(&placement) { Ok(witness) => { assert!(accepted); @@ -153,7 +167,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/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 67ba7ee19..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(); @@ -107,11 +106,60 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_no_when_k_zero() } #[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 { .. } - )); +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, 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)], 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)], 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/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 df044287b..013856e87 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; @@ -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,182 @@ fn named_path(names: &[&str]) -> ReductionPath { } } +fn problem_step() -> ReductionStep { + ReductionStep { + name: P::NAME.into(), + variant: ReductionGraph::variant_to_map(&P::variant()), + } +} + +#[test] +fn decision_chain_shares_construction_for_solution_and_value_mapping() { + use crate::models::Decision; + use crate::solvers::BruteForce; + 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(); + assert!(chain.has_value_mapping()); + 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 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 solution_and_aggregate_chains_map_values_through_multiple_steps() { + use crate::models::formula::CNFClause; + use crate::solvers::BruteForce; + let graph = ReductionGraph::new(); + let path = ReductionPath { + steps: vec![ + problem_step::(), + problem_step::(), + problem_step::>>(), + problem_step::>(), + ], + }; + 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 chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + let aggregates = graph + .reduce_aggregate_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 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 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]); + let path = ReductionPath { + steps: vec![problem_step::(), problem_step::()], + }; + let chain = ReductionGraph::new() + .reduce_along_path(&path, &source) + .unwrap() + .unwrap(); + 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)) + ); +} + +#[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(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(serde_json::to_value(value).unwrap()) + .unwrap(), + json!(tautology) + ); + assert!(chain.extract_value(json!(123)).is_err()); + } +} + #[derive(Clone)] struct AggregateChainSource; @@ -397,6 +574,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 +649,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 +671,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 +826,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 +837,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 +878,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(json!(7)).unwrap(), json!(12)); + assert!(chain.extract_value(json!("not an aggregate")).is_err()); } #[test] @@ -712,6 +895,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 +933,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 +971,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 +1008,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 +1037,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 +1072,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, }, ); @@ -1027,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] @@ -1148,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] @@ -1169,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()); @@ -1178,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] @@ -1214,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] @@ -1660,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"); @@ -1731,7 +1927,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 @@ -1916,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)"); + } +} 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 466d811f9..061079c08 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -1,6 +1,7 @@ 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_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,10 +16,10 @@ 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_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "HamiltonianCircuit -> QuadraticAssignment", @@ -28,9 +29,9 @@ 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 target = reduction.target_problem(); + let reduction = 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); @@ -57,9 +58,9 @@ 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 target = reduction.target_problem(); + 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, // so the QAP optimum should be zero. @@ -75,9 +76,9 @@ 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 target = reduction.target_problem(); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); let best = BruteForce::new() .solve(target) @@ -98,8 +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]; @@ -131,9 +132,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 +161,29 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&best) + .unwrap() + ) + .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 +193,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 +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(|_| { @@ -221,7 +249,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 +258,17 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&order) + .unwrap() + ) + .0, expected ); if expected { @@ -262,6 +296,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(), @@ -284,7 +322,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(serde_json::to_value(optimum).unwrap()) + .unwrap(), serde_json::to_value(Or(expected)).unwrap(), ); assert_eq!( 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..9aa4c1038 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs @@ -1,6 +1,7 @@ +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_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 +16,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 +29,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 +54,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 +71,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 +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. @@ -118,9 +124,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 +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); + 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) @@ -152,9 +158,14 @@ 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, value).0, + crate::rules::AggregateReductionResult::extract_value( + &reduction, + crate::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + ) + .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..fbd7c8fbe 100644 --- a/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -1,6 +1,7 @@ use super::*; +use crate::models::decision::Decision; 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 +15,16 @@ 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 +39,10 @@ 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 +59,11 @@ 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 +83,10 @@ 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 +101,14 @@ 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", @@ -138,8 +139,10 @@ fn test_hamiltonian_path_extraction_for_all_small_graphs_and_endpoints() { end, ); let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction); + ReduceTo::>>::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 +150,10 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() ) .0, expected 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/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()); +} diff --git a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs index 387b9de80..3def3bbe3 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, @@ -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/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_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/ksatisfiability_qubo.rs b/src/unit_tests/rules/ksatisfiability_qubo.rs index 22aad0944..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,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 +37,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 +62,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 +84,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 +103,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 +126,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 +149,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 +173,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 +210,8 @@ 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 +235,14 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() + ), Or(penalty == 0) ); if penalty == 0 { @@ -243,11 +257,20 @@ 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(false) + ), Or(false) ); assert!(reduction.extract_solution(&vec![]).is_err()); @@ -258,7 +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] @@ -283,18 +306,17 @@ 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 { .. }) )); } #[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)] { @@ -302,27 +324,23 @@ 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() }) .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/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/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index 8a7e838e8..e2d428dbf 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -1,4 +1,24 @@ use super::*; + +#[test] +fn constraint_count_is_only_an_upper_bound() { + let entry = crate::rules::registry::reduction_entries() + .into_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/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/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/naesatisfiability_maxcut.rs b/src/unit_tests/rules/naesatisfiability_maxcut.rs index 9f07e13f0..b492d2436 100644 --- a/src/unit_tests/rules/naesatisfiability_maxcut.rs +++ b/src/unit_tests/rules/naesatisfiability_maxcut.rs @@ -1,8 +1,9 @@ use super::*; +use crate::models::decision::Decision; 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; @@ -19,16 +20,16 @@ fn test_naesatisfiability_to_maxcut_closed_loop() { CNFClause::new(vec![-1, -2, 3]), ], ); - let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); - let target = reduction.target_problem(); + let reduction = 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", @@ -39,15 +40,15 @@ 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 target = reduction.target_problem(); + let reduction = 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", @@ -59,15 +60,15 @@ 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 target = reduction.target_problem(); + let reduction = 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", @@ -78,15 +79,15 @@ 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 target = reduction.target_problem(); + 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. 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", @@ -103,8 +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 @@ -129,15 +130,15 @@ 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 target = reduction.target_problem(); + 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 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", @@ -155,9 +156,9 @@ 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 target = reduction.target_problem(); + let reduction = 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 +173,8 @@ 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 +183,13 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&cut) + .unwrap(), + ) + .0; match reduction.extract_solution(&cut) { Ok(assignment) => { assert!(certificate); @@ -209,10 +216,17 @@ 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(false)).0); assert!(reduction .extract_solution(&vec![false; target.num_vertices() + 1]) .is_err()); @@ -251,8 +265,8 @@ 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/naesatisfiability_partitionintoperfectmatchings.rs b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs index faf837e52..ab9a950a8 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 = crate::rules::registry::reduction_entries() + .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 = crate::rules::registry::reduction_entries() + .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")] 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/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/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_openshopscheduling.rs b/src/unit_tests/rules/partition_openshopscheduling.rs index 40bc779df..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,8 +16,8 @@ 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()); } @@ -24,9 +25,9 @@ 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 target = reduction.target_problem(); + let reduction = 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 +40,8 @@ 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 +50,20 @@ 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 +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 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(false)).0 ); for mask in 0..(1usize << n) { let assignment: Vec<_> = (0..n).map(|i| mask & (1 << i) != 0).collect(); @@ -118,7 +119,15 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap() + ) + .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 +146,22 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap() + ) + .0 + ); assert!(reduction.extract_solution(&schedule).is_err()); } @@ -152,11 +170,15 @@ 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..8743e1abb 100644 --- a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs @@ -1,7 +1,8 @@ #[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_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 +12,10 @@ 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) + 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, "Partition -> SequencingToMinimizeTardyTaskWeight closed loop", @@ -24,9 +25,9 @@ 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) + let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); - let target = reduction.target_problem(); + 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,7 +38,7 @@ 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) + let reduction = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); assert_eq!( @@ -49,9 +50,9 @@ 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) + 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() @@ -61,7 +62,9 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&best) + .unwrap() ) .0 ); @@ -80,18 +83,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 +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: SequencingToMinimizeTardyTaskWeight = + let target: Decision = serde_json::from_value(example.target.instance.clone()) .expect("target example deserializes"); @@ -131,8 +134,9 @@ 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); + 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(); source.evaluate(&bits).unwrap().0 @@ -150,8 +154,13 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap(), + ) + .0; let extracted = reduction.extract_solution(&schedule); assert_eq!(extracted.is_ok(), certified); if let Ok(bits) = extracted { @@ -161,7 +170,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 +183,11 @@ 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(false) + ) + .0 ); } } @@ -188,11 +204,21 @@ fn test_partition_to_tardy_weight_full_i64_domain() { ] { let source = Partition::new(sizes).unwrap(); let reduction = - ReduceTo::::reduce_to(&source).unwrap(); - let value = reduction.target_problem().evaluate(&schedule).unwrap(); + ReduceTo::>::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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap() + ) + .0, balanced ); let extracted = reduction.extract_solution(&schedule); 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/partitionintocliques_minimumcoveringbycliques.rs b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs index c3e5fb245..064b7bacc 100644 --- a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -1,5 +1,6 @@ use super::*; -use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +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; use crate::types::Min; @@ -18,7 +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::>::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), @@ -27,7 +29,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), ); } @@ -35,14 +43,11 @@ 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 reduction = ReduceTo::>::reduce_to(&source) + let source = PartitionIntoCliques::new(SimpleGraph::empty(1), 1); + 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, "PartitionIntoCliques -> MinimumCoveringByCliques closed loop", @@ -52,9 +57,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); let layout = OrlinLayout::new(source.graph()); assert_eq!(target.graph().num_vertices(), 14); @@ -111,9 +116,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&source) .expect("reduction should succeed"); - let target = reduction.target_problem(); + let target = reduction.target_problem().inner(); let layout = OrlinLayout::new(source.graph()); let target_solution = edge_labels_from_clique_cover( @@ -154,14 +159,19 @@ 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); + ReduceTo::>>::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(); @@ -181,7 +191,13 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() + ) + .0, n <= bound ); if n <= bound { 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/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 b05d19fa9..72de79349 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -1,6 +1,103 @@ 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 == "DecisionOpenShopScheduling" + }) + .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: "DecisionOpenShopScheduling", + 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", @@ -11,6 +108,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/sat_maximumindependentset.rs b/src/unit_tests/rules/sat_maximumindependentset.rs index 47b23dd0d..23d6c717a 100644 --- a/src/unit_tests/rules/sat_maximumindependentset.rs +++ b/src/unit_tests/rules/sat_maximumindependentset.rs @@ -1,9 +1,11 @@ use super::*; +use crate::models::decision::Decision; 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 +48,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let is_problem = reduction.target_problem().inner(); // Should have 1 vertex (one literal) assert_eq!(is_problem.graph().num_vertices(), 1); @@ -61,9 +63,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let is_problem = reduction.target_problem().inner(); // Should have 2 vertices assert_eq!(is_problem.graph().num_vertices(), 2); @@ -82,7 +84,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); // Select vertex 0 (literal x1) @@ -100,7 +102,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); let is_sol = vec![true]; @@ -112,9 +114,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let is_problem = reduction.target_problem().inner(); // 3 vertices, 3 edges (complete graph K3) assert_eq!(is_problem.graph().num_vertices(), 3); @@ -134,9 +136,9 @@ fn test_complement_edges_across_clauses() { CNFClause::new(vec![2]), ], ); - let reduction = ReduceTo::>::reduce_to(&sat) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + 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 +150,9 @@ fn test_is_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::>::reduce_to(&sat) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + 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 +162,9 @@ fn test_is_structure() { fn test_empty_sat() { // Empty SAT (trivially satisfiable) let sat = Satisfiability::new(0, vec![]); - let reduction = ReduceTo::>::reduce_to(&sat) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let is_problem = reduction.target_problem(); + let is_problem = reduction.target_problem().inner(); assert_eq!(is_problem.graph().num_vertices(), 0); assert_eq!(is_problem.graph().num_edges(), 0); @@ -172,7 +174,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); let literals = reduction.literals(); @@ -216,8 +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::>::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) @@ -227,19 +230,21 @@ 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::rules::ReductionResult::target_problem(&result) + .evaluate(&target_solution) + .unwrap() ), Or(false), ); } else { - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, &format!("SAT->IS [{label}]"), @@ -275,11 +280,12 @@ fn test_sat_to_independentset_all_certificates() { ], ); let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = reduction.target_problem(); + ReduceTo::>>::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 +295,12 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + ), Or(certificate) ); match reduction.extract_solution(&config) { @@ -313,13 +324,17 @@ 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(); + ReduceTo::>>::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(false) + ), Or(false) ); } diff --git a/src/unit_tests/rules/sat_minimumdominatingset.rs b/src/unit_tests/rules/sat_minimumdominatingset.rs index c6db9615d..4f9e16ebb 100644 --- a/src/unit_tests/rules/sat_minimumdominatingset.rs +++ b/src/unit_tests/rules/sat_minimumdominatingset.rs @@ -1,17 +1,19 @@ use super::*; +use crate::models::decision::Decision; 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + 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 +28,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + 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,7 +46,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); // Solution: select vertex 0 (positive literal x1) @@ -58,7 +60,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); // Solution: select vertex 1 (negative literal NOT x1) @@ -72,7 +74,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); // Only x1 occurs, so its triangle is the only gadget. The unused x2 @@ -88,9 +90,9 @@ fn test_ds_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::>::reduce_to(&sat) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + 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 +102,9 @@ fn test_ds_structure() { fn test_empty_sat() { // Empty SAT (trivially satisfiable) let sat = Satisfiability::new(0, vec![]); - let reduction = ReduceTo::>::reduce_to(&sat) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + 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 +116,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + let ds_problem = reduction.target_problem().inner(); // 3 gadget vertices + 1 clause vertex = 4 assert_eq!(ds_problem.graph().num_vertices(), 4); @@ -130,7 +132,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); assert_eq!(reduction.num_literals(), 2); @@ -140,7 +142,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); let ds_sol = vec![true, true, false, false]; @@ -153,7 +155,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); assert_eq!( @@ -168,7 +170,7 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); assert_eq!( @@ -184,9 +186,9 @@ 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) + let reduction = ReduceTo::>>::reduce_to(&sat) .expect("reduction should succeed"); - let ds_problem = reduction.target_problem(); + 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 +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::>::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) @@ -244,12 +247,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}]"), @@ -279,11 +282,12 @@ fn test_sat_to_dominatingset_native_certificates() { ] { let source = Satisfiability::new(n, clauses.into_iter().map(CNFClause::new).collect()); let result = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = result.target_problem(); + 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 mut accepted = false; for mask in 0..(1usize << target.num_vertices()) { @@ -291,9 +295,14 @@ 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::rules::ReductionResult::target_problem(&result) + .evaluate(&config) + .unwrap() + ), Or(certificate) ); match result.extract_solution(&config) { @@ -320,9 +329,10 @@ 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(); + ReduceTo::>>::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_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/satisfiability_maximum2satisfiability.rs b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs index 3db2ebc6d..7e6ffde0a 100644 --- a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs +++ b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs @@ -1,9 +1,11 @@ use super::*; +use crate::models::decision::Decision; 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() { @@ -12,11 +14,12 @@ 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 target = reduction.target_problem(); + let reduction = 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); @@ -34,11 +37,11 @@ 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 target = reduction.target_problem(); + let reduction = 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", @@ -57,9 +60,9 @@ 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 target = reduction.target_problem(); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem().inner(); assert_eq!( target @@ -75,7 +78,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) ); } @@ -84,9 +93,9 @@ 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 target = reduction.target_problem(); + let reduction = 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 +152,8 @@ 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 +164,12 @@ 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::rules::ReductionResult::target_problem(&reduction) + .evaluate(&assignment) + .unwrap() + ), Or(expected) ); if expected { @@ -171,7 +185,10 @@ 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(false) + ), Or(false) ); } diff --git a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs index f6f4b2f9a..328cb6159 100644 --- a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs +++ b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs @@ -1,49 +1,53 @@ 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 target_solution = - crate::solvers::customized::closest_vector_problem::solve(reduction.target_problem()) - .unwrap(); + 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, - 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 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); assert_eq!( - ClosestVectorProblem::::variant(), - vec![("target", "i64")] + ClosestVectorProblem::variant(), + vec![("coefficient", "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 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(2.0)); + assert_eq!(target.evaluate(&solution).unwrap().0, Some(4)); assert!( source .evaluate(&reduction.extract_solution(&solution).unwrap()) @@ -56,17 +60,19 @@ 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()) - .unwrap(); + 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() - > (source.num_elements() as f64).sqrt() + > i64::try_from(source.num_elements()).unwrap() ); } @@ -75,24 +81,25 @@ 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(), - Min(Some(1.0)) + 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!( @@ -114,11 +121,11 @@ 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; @@ -132,9 +139,14 @@ 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.bound())); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&result, value), + crate::rules::AggregateReductionResult::extract_value( + &result, + crate::rules::ReductionResult::target_problem(&result) + .evaluate(&config) + .unwrap() + ), Or(certificate) ); match result.extract_solution(&config) { @@ -155,7 +167,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, Min(None)), + crate::rules::AggregateReductionResult::extract_value(&result, crate::types::Or(false)), Or(false) ); } 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_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/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/traits.rs b/src/unit_tests/rules/traits.rs index bfc64c583..1f792a394 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,292 @@ 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()); + } +} + +#[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); } diff --git a/src/unit_tests/rules/travelingsalesman_qubo.rs b/src/unit_tests/rules/travelingsalesman_qubo.rs index 77199d7e3..eed7d6b60 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 = crate::rules::registry::reduction_entries() + .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] @@ -86,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)) + ); +} 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" diff --git a/src/unit_tests/solvers/brute_force.rs b/src/unit_tests/solvers/brute_force.rs index f2b6547d7..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, @@ -563,18 +628,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)); } diff --git a/src/unit_tests/solvers/customized/closest_vector_problem.rs b/src/unit_tests/solvers/customized/closest_vector_problem.rs index fe570f3f2..25c4c4c5a 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,38 +28,30 @@ 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, - BTreeMap::from([("target".to_string(), "i64".to_string())]), + ClosestVectorProblem::NAME, + BTreeMap::from([("coefficient".to_string(), "i64".to_string())]), ); let capabilities = solver_capabilities(&key).unwrap(); assert_eq!( @@ -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)); } } @@ -153,3 +136,43 @@ 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([("coefficient".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); + } + } +} + +#[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)); + } +} 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..59be07853 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, @@ -499,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}"); + } + } +} diff --git a/src/unit_tests/solvers/decision_search.rs b/src/unit_tests/solvers/decision_search.rs index 1a56f00de..a83129976 100644 --- a/src/unit_tests/solvers/decision_search.rs +++ b/src/unit_tests/solvers/decision_search.rs @@ -1,78 +1,157 @@ 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]); +impl DecisionProblemMeta for FixedObjective { + const DECISION_NAME: &'static str = "DecisionFixedObjective"; +} - assert_eq!(solve_via_decision(&problem, 0, 3).unwrap(), Some(2)); +impl crate::solvers::BruteForceProblem for FixedObjective { + fn dimensions(&self) -> Vec { + vec![] + } } -#[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]); +crate::register_brute_force! { + Decision>>, + Decision>>, +} - let solution = BruteForce::new().solve(&problem).unwrap().unwrap(); - let brute_force_value = problem.evaluate(&solution).unwrap(); +crate::declare_variants! { + default Decision>> => "1", + Decision>> => "1", +} - assert_eq!( - solve_via_decision(&problem, 0, 5).unwrap(), - brute_force_value.size().copied() - ); +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_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]); - - assert_eq!(solve_via_decision(&problem, 0, 0).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_max_returns_none_when_interval_is_above_optimum() { +fn test_decision_search_rejects_invalid_or_excluding_intervals() { 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); + 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_invalid_interval_returns_none() { - 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); +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(_)))); + } } #[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_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 + ); } 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 5c193553e..0b3107ef9 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( @@ -114,22 +114,16 @@ 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, +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!( - classify_backend_error(ResolutionError::Other("SolveError"), None), - ILPSolveError::BackendFailure(message) if message.contains("SolveError") + ILPSolver::new().solve(&ilp), + Err(ILPSolveError::InexactTransport(_)) )); } @@ -325,3 +319,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/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 97c55d085..c4480fa49 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,40 +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, - }, - StaticProblemStep { - name: "ILP", - variant: FLOAT_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)]), @@ -58,7 +47,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; @@ -72,22 +61,22 @@ 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::{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 +84,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,12 +113,13 @@ 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()), - Err(ILPSolveError::UnresolvedDecision(_)) + Err(ILPSolveError::Infeasible) )); assert!(matches!( pipeline.solve(&Decision::new(inner, 1), &ILPSolver::new()), @@ -124,6 +128,51 @@ fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { )); } +#[test] +fn ilp_negative_intermediate_does_not_require_remaining_value_mappings() { + 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) + )); + // Exercise completed-value recovery through the explicit optimization route. + let mut path = original.path.clone(); + path.insert( + 2, + ExactProblemKey::new("LongestCircuit", path[1].variant.clone()), + ); + let reducers = 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(); + let mut pipeline = CompiledIlpPipeline { path, reducers }; + pipeline.reducers[0].1 = None; + let result = pipeline.solve(&problem, &ILPSolver::new()); + assert!( + matches!(&result, Err(ILPSolveError::Infeasible)), + "{result:?}" + ); +} + static DIRECT_BOOL_A: IlpPipelineRegistration = IlpPipelineRegistration { path: &[StaticProblemStep { name: "ILP", @@ -406,11 +455,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( @@ -435,10 +480,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..f98171f99 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -4,6 +4,119 @@ 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]]}); + 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())]); @@ -41,18 +154,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 +186,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 +243,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 +315,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), @@ -365,7 +428,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 +444,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 +561,6 @@ fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { "MaximumIndependentSet", "MaximumSetPacking", "ILP", - "ILP", ] ); } @@ -588,24 +650,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"); }; 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); diff --git a/tests/suites/reductions.rs b/tests/suites/reductions.rs index 51f4746e4..4ec221377 100644 --- a/tests/suites/reductions.rs +++ b/tests/suites/reductions.rs @@ -337,13 +337,12 @@ 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"); + let reduction = ReduceTo::< + problemreductions::models::decision::Decision>, + >::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); let target_solution = BruteForce::new() @@ -358,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); } } @@ -617,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); @@ -724,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);