diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 438584ded..04fa55aa5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -37,7 +37,7 @@ These repo-local skills live under `.claude/skills/*/SKILL.md`. - [propose](skills/propose/SKILL.md) -- Interactive brainstorming to help domain experts propose a new model or rule. Asks one question at a time, uses mathematical language (no programming jargon), and files a GitHub issue. - [final-review](skills/final-review/SKILL.md) -- Interactive maintainer review for PRs in "Final review" column. Merges main, walks through agentic review bullets with human, then merge or hold. - [dev-setup](skills/dev-setup/SKILL.md) -- Interactive wizard to install and configure all development tools for new maintainers. -- [verify-reduction](skills/verify-reduction/SKILL.md) -- Standalone mathematical verification of a reduction rule: Typst proof, constructor Python (≥5000 checks), adversary Python (≥5000 independent checks). Reports verdict, no artifacts saved. Also called as a subroutine by `/add-rule` (default behavior). +- [verify-reduction](skills/verify-reduction/SKILL.md) -- Standalone mathematical verification of a reduction rule: Typst proof, constructor Python, and independent adversary Python with coverage justified by the construction. Reports verdict, no artifacts saved. Also called as a subroutine by `/add-rule` (default behavior). - [update-papers](skills/update-papers/SKILL.md) -- Update research paper collection: download new papers from references.bib, retry failed downloads, sync to Google Drive, regenerate index.md. - [find-solver](skills/find-solver/SKILL.md) -- Interactive guide: match a real-world problem to a library model, explore reduction paths, recommend solvers (built-in + external), and generate a solution doc. - [find-problem](skills/find-problem/SKILL.md) -- Reverse of find-solver: given a solver for a model, discover what other problems it can handle via incoming reductions, ranked by effective complexity. @@ -107,9 +107,9 @@ make papers-pull # Pull PDFs from shared remote - Run `pred list` for the full catalog of problems, variants, and reductions; `pred show ` for details on a specific problem - `src/rules/` - Reduction rules + inventory registration - `src/models/decision.rs` - Generic `Decision

` wrapper converting optimization problems to decision problems -- `src/solvers/` - BruteForce reference solver returning problem solutions, ILP solver (feature-gated), decision search (binary search via Decision queries), and the exact-variant solver capability registry. Solver dispatch uses only registered customized implementations and fixed ILP pipelines; reduction-graph reachability does not imply solver availability. Run `pred inspect ` to see the registered capabilities for that instance. +- `src/solvers/` - BruteForce reference solver returning problem solutions, ILP solver, and the exact-variant solver capability registry. Solver dispatch uses only registered customized implementations and fixed ILP pipelines; reduction-graph reachability does not imply solver availability. Turing edges are theoretical graph metadata, not executable reductions. Run `pred inspect ` to see the registered capabilities for that instance. - `src/traits.rs` - `Problem` trait -- `src/rules/traits.rs` - `ReduceTo`, `ReduceToAggregate`, `ReductionResult`, `AggregateReductionResult` traits +- `src/rules/traits.rs` - `ReduceTo` and mandatory `ReductionResult::recover_result` - `src/registry/` - Compile-time reduction metadata collection - `problemreductions-cli/` - `pred` CLI tool (separate crate in workspace) - `src/unit_tests/` - Unit test files (mirroring `src/` structure, referenced via `#[path]`) @@ -134,7 +134,7 @@ Problem (core trait — all problems must implement) ``` `BruteForceProblem` is a separate reference-solver capability. Its -`dimensions()` method describes only the finite Cartesian coordinate space used +fallible `num_variables()` and `dimension(variable)` methods describe only the finite Cartesian coordinate space used by the registered brute-force implementation. **Objective problems** (e.g., `MaximumIndependentSet`) typically use `Value = Max`, `Min`, or `Extremum`. @@ -151,6 +151,7 @@ Common aggregate wrappers live in `src/types.rs`: ```rust Max, Min, Sum, Or, And, Extremum, ExtremumSense ``` +All six values implement the `Aggregate` fold. `Sum` and `And` are evaluation and fold values only; they do not implement `EvaluationValue`. `OptimizationValue` trait (in `src/types.rs`) enables generic Decision conversion: - `Min`: meets bound when value ≤ bound @@ -161,14 +162,17 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `variant_params!` macro implements `Problem::variant()` — e.g., `crate::variant_params![G, W]` for two type params, `crate::variant_params![]` for none (see `src/variant.rs`) - `declare_variants!` proc macro registers concrete type instantiations with best-known complexity and registry-backed load/serialize/solution-solve metadata. One entry per problem may be marked `default`, and variable names in complexity strings are validated against the problem-owned parameter schema. Ordinary models are constructed directly from their construction schema. When user-facing construction differs from persisted JSON, define a model-local `#[derive(CreateSpec)]` DTO plus `TryFrom`, use its generated `FIELDS` in `ProblemSchemaEntry`, and register it with `create LocalSpec`; never add model-name branches in CLI or MCP code. - `decision_problem_meta!` macro registers `DecisionProblemMeta` for a concrete inner type, providing the `DECISION_NAME` constant. -- `register_decision_variant!` macro generates `declare_variants!`, `ProblemSchemaEntry`, and both `ReductionEntry` submissions (aggregate Decision→Opt + Turing Opt→Decision) for a `Decision

` variant. Callers must define inherent getters (`num_vertices()`, `num_edges()`, `k()`) on `Decision

` before invoking. Accepts an explicit structural `category` plus `dims`, `fields`, and `parameter_getters` parameters for problem-specific parameters. +- `register_decision_variant!` macro generates `declare_variants!`, `ProblemSchemaEntry`, and both `ReductionEntry` submissions (complete-result Decision→Opt + Turing Opt→Decision) for a `Decision

` variant. Callers must define inherent getters (`num_vertices()`, `num_edges()`, `k()`) on `Decision

` before invoking. Accepts an explicit structural `category` plus `dims`, `fields`, and `parameter_getters` parameters for problem-specific parameters. - 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. -- 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)`) +- Each executed step constructs one result and shares it through `Rc`. Document the rule's domain, witness premise, source guarantee, and infeasibility interpretation; all tied qualifying optima must map correctly. +- `EvaluationValue` exposes candidate feasibility through `is_valid()`; `Min`, `Max`, `Or`, and `Extremum` implement it. `Problem::Value` does not require it; `SolveOutcome`, `ReductionResult` endpoints, `SolutionAggregate`, `OptimizationValue`, and `declare_variants!` registration do, so a `Sum`- or `And`-valued problem evaluates and folds but fails to compile in solve, recovery, and registration. `SolveOutcome::optimal()` and `feasible()` evaluate once and reject constraint-violating candidates with `EvaluationError::ConstraintViolation`. This does not prove problem infeasibility or optimality. +- `SolutionAggregate` belongs to `solvers::BruteForce` witness selection. Models, pure reduction mappings, dynamic evaluation, and non-enumerative solving do not require it. See [executed lifecycle](../docs/src/design.md#executed-reduction-lifecycle). +- `ReductionResult` provides `target_problem()` and mandatory `recover_result(source, target_outcome)`. Recovery returns typed `Optimal`, `Feasible`, or `Infeasible` outcomes, including solution and evaluation. Each rule handles all statuses explicitly; no optional completion callback or separate value-only path exists. +- `pred solve bundle.json` and `pred extract bundle.json --result target-result.json` use the same complete recovery. External results declare their status; the transport boundary validates target feasibility, while the external solver supplies the optimality claim. Insufficient witness quality is an error, never evidence of source infeasibility. +- Decode only the reduction's defined mathematical mapping. Preserve reachable mathematical and representation errors; do not add fallback values or recovery branches for violations already excluded by the calling contract. Explicit mathematical alternatives and sentinels are allowed. +- CLI-facing dynamic formatting uses aggregate wrapper names directly (for example `Max(2)`, `Min(None)`, or `Or(true)`) - Graph types: SimpleGraph, PlanarGraph, BipartiteGraph, UnitDiskGraph, KingsSubgraph, TriangularSubgraph - Weight types: `One` (unit weight marker), `i64`, `f64` — all implement `WeightElement` trait - `WeightElement` trait: `type Sum: NumericSize` + `fn to_sum(&self)` — converts weight to a summable numeric type @@ -208,9 +212,9 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - Nodes come from concrete `declare_variants!` registrations - Same-name variant relations are explicit `#[reduction]` registrations - 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) +- Reduction edges carry `EdgeCapabilities { witness, turing }`; witness mode executes complete-result reductions, and Turing mode describes multi-query procedures +- `#[reduction]` requires one `transform = exact`, `transform = upper_bound`, or `transform = unavailable` declaration and currently registers complete-result reductions; proof-only and Turing edges require manual `ReductionEntry` registration +- `Decision

→ P` recovers the decision result from the optimized inner problem and its 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 @@ -219,7 +223,7 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - **Each construction input has one name and one concrete type per variant.** Do not add compatibility aliases or infer types from flag names. `CreateSpec` field names render as `snake_case → kebab-case` in CLI and remain `snake_case` in MCP. Add a reusable codec only for a genuinely new transport representation, never a model-name parser branch. - **Random generation is optional and variant-owned.** Not every model has a useful, well-defined random-instance distribution. Add `RandomGenerate` only when the generator has clear semantics and a concrete use (for example, testing or examples); never invent arbitrary bounds or distributions merely to make every model support `--random`. Implement it beside the model (normally through `impl_random_generate!` and a typed `CreateSpec` input DTO), then add `random` only to the applicable `declare_variants!` entries. CLI and MCP discover the exact variant's inputs and callback; never add a model-name random dispatch or advertise random generation on an unsupported variant. - **Decision variants** of optimization problems use `Decision

` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision`, (3) `register_decision_variant!` with `dims`, `fields`, `parameter_getters`. The generated construction spec accepts flat inner fields plus `bound`; persisted JSON remains `{inner: {...}, bound}`. `Decision

` delegates canonical parameters to `P`; its objective bound is semantic instance data, not a problem parameter. -- Aggregate-only and Turing reduction edges still need manual `ReductionEntry` wiring because `#[reduction]` only registers solution-mapping reductions today; this edge capability does not imply that a problem may solve successfully without a `Solution` +- Proof-only and Turing reduction edges still need manual `ReductionEntry` wiring because `#[reduction]` only registers solution-mapping reductions today; this edge capability does not imply that a problem may solve successfully without a `Solution` - Exact registry dispatch lives in `src/registry/`; alias resolution and partial/default variant resolution live in `problemreductions-cli/src/problem_name.rs` - `pred create` schema-driven dispatch lives in `problemreductions-cli/src/commands/create.rs` (`create_schema_driven()`) - Canonical model examples live in `src/example_db/model_builders.rs`; rule examples live beside their rules and are collected by `src/rules/mod.rs` @@ -228,22 +232,32 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Numeric Contract -Follow the [numeric types and arithmetic standard](../docs/src/design.md#numeric-types-and-arithmetic) -for every model and reduction. `usize` is reserved for in-memory indices, -collection lengths, and brute-force dimensions; canonical problem parameters -are `u64`; signed mathematical integers use `i64`; and approximate -real values use finite `f64`. Before implementation, identify each numeric -input and domain, each computed total and result type, the largest supported -value, every range/sign-changing conversion, overflow behavior, and whether -arithmetic is exact or approximate. Use `TryFrom` at range boundaries and -checked arithmetic for derived values that may overflow. Rust construction, -serde, CLI, and MCP must enforce the same range. +Follow the [numeric contract](../docs/src/design.md#numeric-types-and-arithmetic) +for models and reductions. Identify numeric domains, stored result types, +conversion boundaries, and overflow behavior before implementation. Rust +construction, serde, CLI, and MCP must use the same model validation; backend +transport limits must not narrow that model domain. Issue contributors provide the mathematical definition, domains, and constraints; implementers derive the Rust representation. Do not require issue authors to choose implementation types or add implementation-specific numeric fields to issue templates. Changes to issue templates require user approval. +### Reduction and Solver Boundary + +Follow the canonical [responsibility boundaries](../docs/src/design.md#responsibility-boundaries), +[complete recovery contracts](../docs/src/design.md#complete-result-recovery), +and [validation policy](../docs/src/design.md#validation-evidence). +Models own mathematical semantics; rules own construction and witness mappings; +adapters own numerical transport, termination interpretation, and returned-target +validation. Orchestration uses those results and maps solutions under the rules' premises. +External recovery parses complete target results and validates target witnesses. Rules own the mathematical interpretation of optimum values, source infeasibility, and insufficient feasible candidates. Solver and CLI callers invoke the same mandatory recovery; they do not add model-specific interpretation branches. + +In ILP tests, only `ILPSolveError::Infeasible` means infeasibility. Other errors +must fail with their details. Solver integration failures must be distinguished +from model or reduction errors; do not require backend precision stress tests +in every rule. See the design document for the shared search-representation contract. + ### File Naming - Reduction files: `src/rules/_.rs` (e.g., `maximumindependentset_qubo.rs`) - Model files: `src/models//.rs` — category is by input structure: `graph/` (graph input), `formula/` (boolean formula/circuit), `set/` (universe + subsets), `algebraic/` (matrix/linear system/lattice), `misc/` (other) @@ -260,6 +274,20 @@ fields to issue templates. Changes to issue templates require user approval. ## Testing Requirements +### Representative examples and validation priorities + +- Lead issues, plans, documentation, and acceptance tests with small, ordinary + instances that explain problem modeling, reduction construction, solution + recovery, and solver use. Prefer a complete source-to-target-to-source example + for reductions and a recognizable application for numerical models. +- Numeric extremes such as `i64::MAX`, huge vertex counts, or tiny floating-point + differences near `2^-50` are not the package's central use cases. Do not make + them the default examples or let them dominate task scope and acceptance criteria. +- Keep focused boundary regressions when they reproduce a concrete defect or + verify a required numeric contract. Preserve required overflow and non-finite + error handling, but do not invent extreme cases to justify extra infrastructure, + public numeric types, or stronger solver guarantees. + **No single test should take more than 5 seconds.** If a test requires solving a large instance (e.g., ILP with thousands of variables), use a smaller test instance or a faster solver. Tests that exceed 5s block CI and must be refactored. **Reference implementations — read these first:** @@ -270,7 +298,7 @@ fields to issue templates. Changes to issue templates require user approval. ### Coverage -New code must have >95% test coverage. Run `make coverage` to check. +New code must have >95% test coverage. Run `make coverage` to check. This is a hard gate: do not waive it, lower the threshold, or add exclusions to make a change pass. `make coverage` checks committed and uncommitted changed lines against `origin/main` using the workspace LCOV report; set `COVERAGE_BASE` when reviewing against another base. Whole-repository coverage is a separate metric. ### Naming @@ -286,7 +314,9 @@ See Key Patterns above for solver API signatures. Follow the reference files for Unit tests in `src/unit_tests/` linked via `#[path]` (see Core Modules above). Integration tests in `tests/suites/`, consolidated through `tests/main.rs`. Canonical example-db coverage lives in `src/unit_tests/example_db.rs`. -Model review automation checks for a dedicated test file under `src/unit_tests/models/...` with at least 3 test functions. The exact split of coverage is judged per model during review. +Model review checks for a dedicated test file under `src/unit_tests/models/...` +and evaluates its semantic coverage under the [validation policy](../docs/src/design.md#validation-evidence). +Do not impose minimum test-function, assertion, vertex, or generated-check counts. ## Documentation Locations - `README.md` — Project overview and quickstart diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 6cf14b947..698a487cb 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -17,16 +17,16 @@ Before any implementation, collect all required information. If called from `iss |---|------|-------------|---------| | 1 | **Problem name** | Struct name with optimization prefix | `MaximumClique`, `MinimumDominatingSet` | | 2 | **Mathematical definition** | Formal definition with objective/constraints | "Given graph G=(V,E), find max-weight subset S where all pairs in S are adjacent" | -| 3 | **Problem type** | Objective (`Max`/`Min`), witness (`bool`), or aggregate-only (`Sum`/`And`/custom `Aggregate`) | Objective (Maximize) | +| 3 | **Problem type** | Objective (`Max`/`Min`/`Extremum`) or witness (`Or`) | Objective (Maximize) | | 4 | **Type parameters** | Graph type `G`, weight type `W`, or other | `G: Graph`, `W: WeightElement` | | 5 | **Struct fields** | What the struct holds | `graph: G`, `weights: Vec` | -| 6 | **Configuration space** | What `dims()` returns | `vec![2; num_vertices]` for binary vertex selection | +| 6 | **Configuration space** | Mathematical solution representation and domain | One Boolean selection per vertex | | 7 | **Feasibility check** | How to validate a configuration | "All selected vertices must be pairwise adjacent" | | 8 | **Per-configuration value** | How `evaluate()` computes the aggregate contribution | "Return `Max(Some(total_weight))` for feasible configs" | | 9 | **Best known exact algorithm** | Complexity with variable definitions | "O(1.1996^n) by Xiao & Nagamochi (2017), where n = \|V\|" | | 10 | **Solving strategy** | How it can be solved | "BruteForce works; ILP reduction available" | | 11 | **Category** | Which sub-module under `src/models/` | `graph`, `formula`, `set`, `algebraic`, `misc` | -| 12 | **Expected outcome from the issue** | Concrete outcome for the issue's example instance | Objective: one optimal solution + optimal value. Witness: one valid/satisfying solution + why it is valid. Aggregate-only: the final aggregate value and how it is derived | +| 12 | **Expected outcome from the issue** | Concrete outcome for the issue's example instance | Objective: one optimal solution + optimal value. Witness: one valid/satisfying solution + why it is valid | If any item is missing, ask the user to provide it. Do NOT proceed until the checklist is complete. @@ -74,15 +74,15 @@ Read these first to understand the patterns: ## Pre-review Checklist Before implementing, make sure the plan explicitly covers these items that structural review checks later: -- Follow `docs/src/design.md#numeric-types-and-arithmetic`: `usize` is for in-memory indices, collection lengths, and brute-force dimensions; registered problem size parameters use `u64`; signed mathematical integers use `i64`; Boolean data uses `bool`; and approximate real or rational data uses finite `f64`. Use another format only when required by the mathematical problem or schema, such as `BigUint` for arbitrary-precision problems or `One` for unit weights; implementation convenience is not sufficient, and there is no `i32` model or I/O format. Implementation-local values are outside this contract. +- Read the canonical [numeric contract](../../../docs/src/design.md#numeric-types-and-arithmetic), [responsibility boundaries](../../../docs/src/design.md#responsibility-boundaries), and [validation policy](../../../docs/src/design.md#validation-evidence). Derive fields and arithmetic from the model's mathematical domain. Evaluation must not depend on solver tolerances, statuses, or enumeration cardinality. Reuse existing representations and shared APIs. - Keep failure phases explicit: fallible constructors, create specs, serde-facing validation, and random generation return `ConstructionError`; `evaluate()` returns `EvaluationError`; no public model path returns `Result<_, String>`. Stored-field arithmetic and evaluation arithmetic are checked and reported in their own phase. -- Serde/CLI construction uses the same validation as `new`/`try_new`, and boundary tests cover the supported maximum without requiring impractical allocation. +- Serde/CLI construction uses the same validation as `new`/`try_new`, and focused tests cover actual representation risks without impractical allocation or backend precision stress cases. - `ProblemSchemaEntry` metadata is complete (`display_name`, `aliases`, `dimensions`, explicit `category`, and construction `fields`) - `Problem::Value` uses the correct aggregate wrapper and witness support is intentional - `declare_variants!` is present with exactly one `default` variant when multiple concrete variants exist - CLI discovery and `pred create ` support are included where applicable - A canonical model example is registered for example-db / `pred create --example` -- If the issue explicitly claims direct ILP solving, the plan also includes the direct ` -> ILP` rule with exact overhead metadata, feature-gated registration, strong regression tests, and ILP-enabled verification +- If the issue explicitly claims direct ILP solving, the plan also includes the direct ` -> ILP` rule with correct parameter metadata, registration, semantic regression tests, and representative solver integration - `docs/paper/reductions.typ` adds both the display-name dictionary entry and the `problem-def(...)` ## Step 1: Determine the category @@ -129,7 +129,7 @@ Key decisions: - **Schema metadata:** `ProblemSchemaEntry` must include the explicit structural `category` and reflect the construction interface through `display_name`, `aliases`, `dimensions`, and `fields` - **Objective problems:** use `type Value = Max<_>`, `Min<_>`, or `Extremum<_>` when the model should expose optimization-style witness helpers - **Witness problems:** use `type Value = Or` for existential feasibility problems -- **Aggregate-only problems:** use a value-only aggregate such as `Sum<_>`, `And`, or a custom `Aggregate` when witnesses are not meaningful +- **Fold-only values:** `Sum<_>` and `And` are valid `Problem::Value` types for evaluation and `Aggregate` folding, but they do not implement `EvaluationValue`, so such a problem cannot be registered with `declare_variants!`, solved, or used as a reduction endpoint - **Weight management:** use inherent methods (`weights()`, `set_weights()`, `is_weighted()`), NOT traits - **`dims()`:** returns the configuration space dimensions (e.g., `vec![2; n]` for binary variables) - **`evaluate()`:** must return `Result`. Invalid configurations remain the aggregate's invalid/false contribution; arithmetic overflow and non-finite computed values are errors. @@ -155,7 +155,7 @@ crate::declare_variants! { - A compiled `complexity_eval_fn` plus registry-backed load/serialize/solve dispatch metadata are auto-generated alongside the symbolic expression - See `src/models/graph/maximum_independent_set.rs` for the reference pattern -`declare_variants!` now handles objective, witness-capable, and aggregate-only models uniformly. Use manual `VariantEntry` wiring only for unusual dynamic-registration work, not for ordinary models. +`declare_variants!` handles objective and witness models uniformly; it requires `Problem::Value: EvaluationValue`. Use manual `VariantEntry` wiring only for unusual dynamic-registration work, not for ordinary models. ## Step 3: Register the model @@ -215,11 +215,11 @@ This example is now the canonical source for: If the issue explicitly says the model is solvable by reducing **directly** to ILP, implement `src/rules/_ilp.rs` in the **same PR** as the model. This is the one exception to the normal "one item per PR" policy: the direct ` -> ILP` rule is part of the model feature, not optional follow-up work. Completeness bar: -- Feature-gate the rule under `ilp-solver` and register it normally -- Add exact overhead expressions and any required size-field getters; metadata must match the constructed ILP exactly -- Add strong tests in `src/unit_tests/rules/_ilp.rs`: structure/metadata, closed-loop semantics vs the source problem or brute force, extraction, `solve_reduced()` or ILP path coverage when appropriate, and weighted/infeasible/pathological regressions whenever the model semantics admit them +- Register the native ILP rule normally; there is no ILP solver feature gate +- Declare parameter equalities or upper bounds using existing metadata; verify the relationship against the constructed ILP +- Add strong tests in `src/unit_tests/rules/_ilp.rs`: structure/metadata, closed-loop semantics vs the source problem or brute force, extraction, `solve_reduced()` or ILP path coverage when appropriate, and weighted/infeasible cases and arithmetic regressions justified by the construction - Update CLI/example-db/paper paths so the claimed ILP solver route is actually usable and documented -- Verify with ILP-enabled workspace commands, not just non-ILP unit tests +- Run the relevant solver integration tests as well as direct mathematical tests; HiGHS is a regular dependency, not an optional feature A direct ILP rule shipped with a model issue must match the completeness bar of a standalone production ILP reduction. Do not add a stub just to satisfy the issue text. @@ -227,12 +227,12 @@ A direct ILP rule shipped with a model issue must match the completeness bar of Create `src/unit_tests/models//.rs`: -Every model needs **at least 3 test functions** (the structural reviewer enforces this). Choose from the coverage areas below — pick whichever are relevant to the model: +Choose coverage from the model semantics and concrete implementation risks under the canonical validation policy. There is no required test-function count: -- **Creation/basic** — exercise constructor inputs, key accessors, `dims()` / `num_variables()`. +- **Creation/basic** — exercise constructor inputs, key accessors, and the mathematical witness domain. - **Evaluation** — valid and invalid configs so the feasibility boundary or aggregate contribution is explicit. - **Direction / sense** — verify runtime optimization sense only for models that use `Extremum<_>`. -- **Solver** — brute-force `solve()` returns the correct aggregate value; if witnesses are supported, verify `find_witness()` / `find_all_witnesses()` as well. +- **Solver** — where registered, brute-force `solve()` returns a correct solution; use `find_all_witnesses()` when the test needs all optimal/satisfying witnesses. Keep solver integration separate from direct model evaluation. - **Serialization** — round-trip serde (when the model is used in CLI/example-db flows). - **Paper example** — verify the worked example from the paper entry (see below). @@ -298,8 +298,8 @@ make test clippy # Must pass If Step 4.7 applied, run ILP-enabled workspace verification instead: ```bash -cargo clippy --all-targets --features ilp-highs -- -D warnings -cargo test --features "ilp-highs example-db" --workspace --verbose +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --features example-db --workspace --verbose ``` Structural and quality review is handled by the `review-pipeline` stage, not here. The run stage just needs to produce working code. @@ -320,10 +320,9 @@ Structural and quality review is handled by the `review-pipeline` stage, not her | Omitting or inferring the model category | Set the required `ProblemSchemaEntry.category` explicitly to one of `Algebraic`, `Formula`, `Graph`, `Misc`, or `Set`; never parse `module_path!()`. | | Missing `#[path]` test link | Add `#[cfg(test)] #[path = "..."] mod tests;` at file bottom | | Wrong `dims()` | Must match the actual configuration space (e.g., `vec![2; n]` for binary) | -| Using the wrong aggregate wrapper | Objective models use `Max` / `Min` / `Extremum`, witness models use `bool`, aggregate-only models use a fold value like `Sum` / `And` | +| Using the wrong aggregate wrapper | Objective models use `Max` / `Min` / `Extremum`, witness models use `Or` | | Not registering in `mod.rs` | Must update both `/mod.rs` and `models/mod.rs` | | Forgetting `declare_variants!` | Required for variant complexity metadata and registry-backed load/serialize/solve dispatch | -| Wrong aggregate wrapper | Use `Max` / `Min` / `Extremum` for objective problems, `Or` for existential witness problems, and `Sum` / `And` (or a custom aggregate) for value-only folds | | Wrong `declare_variants!` syntax | Entries no longer use `opt` / `sat`; one entry per problem may be marked `default` | | Adding aliases in CLI code | Declare problem aliases in `ProblemSchemaEntry.aliases` and variant aliases in `declare_variants!` | | Adding a hand-written decision model | Use `Decision

` wrapper instead — see `decision_problem_meta!` + `register_decision_variant!` in `src/models/graph/minimum_vertex_cover.rs` for the pattern | @@ -333,4 +332,4 @@ Structural and quality review is handled by the `review-pipeline` stage, not her | Calling a panicking constructor from `TryFrom` | Share a fallible constructor and preserve its `ConstructionError`. | | Missing canonical model example | Add a builder in `src/example_db/model_builders.rs` and keep it aligned with paper/example workflows | | Paper example not tested | Must include `test__paper_example` that verifies the exact instance, solution, and solution count shown in the paper | -| Claiming direct ILP solving but leaving ` -> ILP` for later | If the issue promises a direct ILP path, implement that rule in the same PR with exact overhead metadata and production-level ILP tests | +| Claiming direct ILP solving but leaving ` -> ILP` for later | If the issue promises a direct ILP path, implement that rule in the same PR with correct parameter relationships and production-level ILP tests | diff --git a/.claude/skills/add-rule/SKILL.md b/.claude/skills/add-rule/SKILL.md index 3097be526..3620c2f84 100644 --- a/.claude/skills/add-rule/SKILL.md +++ b/.claude/skills/add-rule/SKILL.md @@ -36,47 +36,31 @@ Before any implementation, collect all required information. If called from `iss If any item is missing, ask the user to provide it. Put a high standard on item 7 (concrete example): it must be in tutorial style with clear intuition and easy to understand. Do NOT proceed until the checklist is complete. -## Step 0.5: Type Compatibility Gate - -Check source/target `Value` types before any work: - -```bash -grep "type Value = " src/models/*/.rs src/models/*/.rs -``` - -**Compatible pairs for `ReduceTo` (witness-capable):** -- `Or`->`Or`, `Min`->`Min`, `Max`->`Max` (same type) -- `Or`->`Min`, `Or`->`Max` (feasibility embeds into optimization) - -**Incompatible — STOP if any of these:** -- `Min`->`Or` or `Max`->`Or` — optimization source has no threshold K; needs a decision-variant source model -- `Max`->`Min` or `Min`->`Max` — opposite optimization directions; needs `ReduceToAggregate` or a decision-variant wrapper -- `Or`->`Sum` or `Min`->`Sum` — Sum is aggregate-only; needs `ReduceToAggregate` -- Any pair involving `And` or `Sum` on the target side - -If incompatible, STOP and comment on the issue explaining the type mismatch and options. Do NOT proceed. - -## Numeric Safety Gate - -Read `docs/src/design.md#numeric-types-and-arithmetic`. Derive implementation -types, supported ranges, and checked conversions from the mathematical source, -target, and reduction algorithm. Use `usize` for in-memory indices, collection -lengths, and brute-force dimensions; `u64` for registered problem size -parameters; `i64` for signed mathematical integers; `bool` for Boolean data; -and finite `f64` for real or rational data. Another format needs mathematical -or target-schema justification; there is no `i32` boundary format. -Temporary reduction calculations are outside this format contract, but fields -written into the target must use the target model's format. - -Ask the contributor only when a mathematical domain or constraint is ambiguous; -do not ask them to choose Rust types. Do not use `as` for range/sign changes. -Check target-size arithmetic and auxiliary identifiers before constructing the -target, verify serde/CLI uses the same ranges, and add focused boundary tests. -The public reduction returns `ReductionError`: preserve a target constructor's -`ConstructionError` as `ReductionError::Construction`, and report reduction -arithmetic directly as the corresponding `ReductionError`; do not stringify or -silently handle either error. Convert model-derived `i64` values to `f64` only -through `i64_to_exact_f64`. +## Step 0.5: Mathematical and API Contract + +Read [the canonical complete-result recovery contract](../../../docs/src/design.md#complete-result-recovery). +Resolve the source and target's concrete `Solution` and `Value` types from their +implementations and check the construction, extraction preconditions, and +objective relationship. Different optimization directions or numeric value +types do not by themselves invalidate a witness reduction. Use the existing +complete-result, proof-only, or Turing capability required by the actual operation. +Report a concrete mathematical or Rust implementation mismatch if one exists; +do not apply a wrapper-pair whitelist. + +## Arithmetic and Validation + +Follow [the canonical arithmetic and boundary policy](../../../docs/src/design.md#arithmetic) +and [validation evidence](../../../docs/src/design.md#validation-evidence). +Derive representation requirements from the source, target, and construction. +Ask for clarification only when the mathematical domain is ambiguous, not to +make the contributor choose Rust types. + +Check the construction's actual size arithmetic, coefficients, and auxiliary +identifiers. Preserve target `ConstructionError` as `ReductionError::Construction` +and report reduction arithmetic through `ReductionError`; do not stringify or +silently handle failures. Reuse shared conversion and extraction APIs according +to their contracts. Backend transport limits and precision checks belong to the +adapter, not this rule's applicability domain or mandatory test template. ## Reference Implementations @@ -84,19 +68,19 @@ Read these first to understand the patterns: - **Reduction rule:** `src/rules/minimumvertexcover_maximumindependentset.rs` - **Reduction tests:** `src/unit_tests/rules/minimumvertexcover_maximumindependentset.rs` - **Paper entry:** search `docs/paper/reductions.typ` for `MinimumVertexCover` `MaximumIndependentSet` -- **Traits:** `src/rules/traits.rs` (`ReduceTo`, `ReduceToAggregate`, `ReductionResult`, `AggregateReductionResult`) +- **Traits:** `src/rules/traits.rs` (`ReduceTo`, `ReductionResult`) ## Step 1: Mathematical Verification (default, skip with `--no-verify`) **If `--no-verify` was passed, skip to Step 2.** -Invoke the `/verify-reduction` skill to mathematically verify the reduction before writing Rust code. This runs the full verification pipeline: Typst proof, constructor Python script (>=5000 checks), adversary subagent (>=5000 independent checks), and cross-comparison. +Invoke the `/verify-reduction` skill to mathematically verify the reduction before writing Rust code. This runs the full verification pipeline: Typst proof, constructor Python script, independent adversary checks, and cross-comparison with coverage justified by the construction. All verification artifacts are ephemeral — they exist only in conversation context and temp files. Nothing is committed to the repository. -**If verification FAILS: STOP. Report to user. Do NOT proceed to implementation.** +**Proceed to implementation only when verification reports VERIFIED. For FAILED or INCOMPLETE, report the concrete defect or missing evidence and resolve it before implementing.** -If verification passes, the verified Python `reduce()` and `extract_solution()` functions, along with the YES/NO instances, carry forward in conversation context to inform Steps 2-5. Use them as the canonical spec for the Rust implementation. +If verification passes, the checked construction, recovery mapping, and YES/NO instances carry forward in conversation context to inform Steps 2-5. Use them as the canonical spec for the Rust `reduce_to()` and `recover_result()` implementation. ## Step 2: Implement the reduction @@ -105,8 +89,8 @@ Create `src/rules/_.rs` (all lowercase, no underscores between w ```rust // Required structure: // 1. ReductionResult struct (holds the target problem + mapping state) -// 2. ReductionResult trait impl (target_problem + extract_solution) -// 3. #[reduction(overhead = { ... })] on ReduceTo impl +// 2. ReductionResult trait impl (target_problem + mandatory recover_result) +// 3. #[reduction(transform = exact { ... })] on ReduceTo impl // 4. ReduceTo trait impl (reduce_to method) // 5. #[cfg(test)] #[path = "..."] mod tests; ``` @@ -118,51 +102,85 @@ Key elements: #[derive(Debug, Clone)] pub struct ReductionXToY { target: TargetType, - // any additional mapping state needed for extract_solution + // any additional mapping state needed for recovery } ``` **ReductionResult trait impl:** + +`recover_result` is mandatory; it has no default implementation. For a rule whose +proof establishes all three implications (target optimal -> source optimal, +target feasible -> source feasible, target infeasible -> source infeasible), +explicitly use the internal helper: + ```rust +use crate::rules::traits::recover_preserving_status; +use crate::solvers::ProblemOutcome; + impl ReductionResult for ReductionXToY { type Source = SourceType; type Target = TargetType; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution( + + fn recover_result( &self, - target_solution: &[usize], - ) -> crate::rules::ExtractionResult> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let source_solution = /* translate the verified mathematical mapping exactly */; - Ok(source_solution) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + self.map_solution(solution) + }) } } ``` -Every direct extractor must call `validate_target_solution()` once before decoding. It checks only length and value domains, not feasibility, optimality, or rule-specific structure; reject malformed structure with `ExtractionError`. - -**ReduceTo with `#[reduction]` macro** (overhead is **required**): +Keep the mathematical mapping in a private `map_solution` method, or inline a +short mapping in the closure. Do not add a forwarding method solely to call the +helper. It preserves the declared status, maps the witness, evaluates the source +candidate once, and propagates mapping/evaluation errors. The old target value +is not reused as the source value. `Infeasible` does not invoke the mapping. + +**Choose this helper only when the proof supports all three implications.** It +cannot prove the premise, optimality, or source infeasibility from an invalid +mapped candidate. When recovery needs an optimum threshold, rejects feasible +incumbents, or has another mathematical interpretation, write an explicit +`match target` in `recover_result` instead. Examples: MVC -> FeedbackArcSet +rejects merely feasible targets; ILP -> QUBO interprets the optimum penalty. +Insufficient witness quality returns `ExtractionError::InsufficientSolutionQuality`, +never `SolveOutcome::Infeasible` without a proof. + +Follow the canonical [recovery contract](../../../docs/src/design.md#complete-result-recovery). +Document the instance domain, witness premises, source guarantee, and +infeasibility interpretation. Target validation belongs to the solver/transport +boundary; keep the existing source evaluation when constructing recovered +outcomes. Do not duplicate model constraint checks inside the mapping or add +fallbacks for inputs excluded by its premises. Do not introduce default recovery, +policy flags, macros, or a new public mapping trait to remove this explicit choice. + +**ReduceTo with `#[reduction]` macro** (a parameter relation is **required**): ```rust -#[reduction(overhead = { +#[reduction(transform = exact { field_name = "source_field", })] impl ReduceTo for SourceType { type Result = ReductionXToY; - fn reduce_to(&self) -> Self::Result { + fn reduce_to(&self) -> Result { // If Step 1 ran: translate the verified Python reduce() logic } } ``` -Each primitive reduction is determined by the exact source/target variant pair. Keep one primitive registration per endpoint pair and use only the `overhead` form of `#[reduction]`. +Each primitive reduction is determined by the exact source/target variant pair. Keep one primitive registration per endpoint pair and declare `transform = exact`, `upper_bound`, or `unavailable` according to the actual parameter relationship; follow `.claude/CLAUDE.md` for metadata requirements. -**Aggregate-only reductions:** when the rule preserves aggregate values but cannot recover a source witness from a target witness, implement `AggregateReductionResult` + `ReduceToAggregate` instead of `ReductionResult` + `ReduceTo`. Those edges are not auto-registered by `#[reduction]` yet; register them manually with `ReductionEntry { reduce_aggregate_fn: ..., capabilities: EdgeCapabilities::aggregate_only(), ... }`. See `src/unit_tests/rules/traits.rs` and `src/unit_tests/rules/graph.rs` for the reference pattern. +A construction that cannot recover complete source results must not be registered +as a complete-result edge. Use the existing proof-only or Turing capability when +it describes the actual reduction; do not invent an aggregate-only recovery API. ## Step 3: Register in mod.rs Add to `src/rules/mod.rs`: - `mod _;` -- If feature-gated (e.g., ILP): wrap with `#[cfg(feature = "ilp-solver")]` +- Register native ILP rules normally; there is no ILP solver feature gate. ## Step 4: Write unit tests @@ -171,31 +189,26 @@ Create `src/unit_tests/rules/_.rs`: **Required: closed-loop test** (`test__to__closed_loop`): ```rust // 1. Create source problem instance -// 2. Reduce: let reduction = ReduceTo::::reduce_to(&source); -// 3. Solve target: solver.find_all_witnesses(reduction.target_problem()) -// 4. Extract: reduction.extract_solution(&target_sol) +// 2. Reduce: let reduction = ReduceTo::::reduce_to(&source).unwrap(); +// 3. Solve target; wrap each proven optimum with SolveOutcome::optimal(target, solution) +// 4. Recover: reduction.recover_result(&source, target_outcome) // 5. Verify: extracted solution is valid and optimal for source ``` -If Step 1 ran, use the verified YES/NO instances from conversation context to construct test cases. Include both a feasible (closed-loop) and infeasible (no witnesses) test. +If Step 1 ran, use the verified YES/NO instances from conversation context to construct test cases. Include feasible and infeasible cases when both exist; for always-feasible optimization models, check the objective relationship instead. Additional recommended tests: - Verify target problem structure (correct size, edges, constraints) - Edge cases (empty graph, single vertex, etc.) - Weight preservation (if applicable) -Test every malformed representation distinguished by the decoder (for example, zero or multiple one-hot selections, or duplicate permutation entries). The canonical example supplies shared wrong-length and out-of-domain tests. - -For aggregate-only reductions, replace the closed-loop witness test with value-chain tests: -- Solve the target with `Solver::solve()` -- Map the aggregate value back with `extract_value()` -- If testing a path, use `ReductionGraph::reduce_aggregate_along_path(...)` +Test the mathematical mapping for witnesses satisfying its premises, including all tied optima on suitable small instances. Also exercise feasible incumbents and infeasibility when reachable, checking the rule's declared implications or rejection. Keep necessary parsing/type-conversion tests at the transport boundary. Link via `#[cfg(test)] #[path = "..."] mod tests;` at the bottom of the rule file. ## Step 5: Add canonical example -Define `canonical_rule_example_specs()` in the rule module and include it from `src/rules/mod.rs::canonical_rule_example_specs()`. This enrolls the rule in shared round-trip, wrong-length, and out-of-domain extraction tests. +Define `canonical_rule_example_specs()` in the rule module and include it from `src/rules/mod.rs::canonical_rule_example_specs()`. This enrolls the rule in shared example checks. Extraction correctness checks use witnesses satisfying the mapping contract; model evaluation retains its own domain checks. ## Step 6: Document in paper (MANDATORY — DO NOT SKIP) @@ -272,8 +285,8 @@ Structural and quality review is handled by the `review-pipeline` stage, not her ## Solver Rules - If the target problem already has a solver, use it directly. -- If the solving strategy requires ILP, implement the ILP reduction rule alongside (feature-gated under `ilp-solver`). -- A direct-to-ILP rule is a production reduction, not a stub. Match the completeness bar used by strong ILP reductions in this repo: exact overhead metadata, structure + closed-loop + extraction tests, weighted/infeasible/pathological regressions whenever the semantics require them, and ILP-enabled workspace verification. +- If the solving strategy requires ILP, implement and register the ILP reduction rule alongside. +- A direct-to-ILP rule is a production reduction, not a stub. Match the completeness bar used by strong ILP reductions in this repo: correct parameter relationships, structure + closed-loop + extraction tests, weighted/infeasible cases and arithmetic regressions justified by the construction, and representative solver integration. - When this rule is the companion to a `[Model]` issue that explicitly claims ILP solvability, it belongs in the same PR as the model. - If a custom solver is needed, implement in `src/solvers/` and document. @@ -283,11 +296,6 @@ Adding a witness-preserving reduction rule does NOT require CLI changes -- the r `ExtractionError` already propagates through `pred extract` and bundle `pred solve`; add a rule-specific CLI test only when the CLI surface changes. -Aggregate-only reductions currently have a narrower CLI surface: -- `pred solve ` can still compute direct aggregate values for aggregate-only problems -- `pred reduce` and `pred solve bundle.json` remain witness-only workflows and reject aggregate-only paths -- Manual aggregate-edge registration affects runtime graph search and internal value extraction, but not bundle solving - ## File Naming - Rule file: `src/rules/_.rs` -- no underscores within a problem name @@ -300,14 +308,28 @@ Aggregate-only reductions currently have a narrower CLI surface: | Mistake | Fix | |---------|-----| | Forgetting `#[reduction(...)]` macro | Required for compile-time registration in the reduction graph | -| Using `#[reduction]` for an aggregate-only rule | `#[reduction]` currently registers witness/config edges only; aggregate-only rules need manual `ReductionEntry` wiring with `reduce_aggregate_fn` | +| Registering proof-only or Turing reductions as complete-result edges | Register the actual capability; `#[reduction]` requires complete source-result recovery | | Wrong overhead expression | Must accurately reflect the size relationship | | Adding extra reduction metadata or duplicate primitive endpoint registration | Keep one primitive registration per endpoint pair and use only the `overhead` form of `#[reduction]` | -| Missing `extract_solution` mapping state | Store any index maps needed in the ReductionResult struct | -| Permissive extraction | Validate first, then map exactly or return `ExtractionError` | +| Missing recovery mapping state | Store any index maps needed in the ReductionResult struct | +| Using status-preserving recovery without its premises | Prove all three status implications, or interpret outcomes explicitly in `recover_result` | | Not adding a canonical example | Add the rule-local spec and include it from `src/rules/mod.rs` | | Not regenerating reduction graph | Run `cargo run --example export_graph` after adding a rule | | Skipping Step 6 (paper documentation) | **Every rule MUST have a `reduction-rule` entry in the paper. This is mandatory, not optional. PRs without documentation will be rejected.** | | Source/target model not fully registered | Both problems must already have `ProblemSchemaEntry`, `declare_variants!`, registry aliases as needed, and a construction contract -- use `add-model` skill first | -| Treating a direct-to-ILP rule as a toy stub | Direct ILP reductions need exact overhead metadata and strong semantic regression tests, just like other production ILP rules | +| Treating a direct-to-ILP rule as a toy stub | Direct ILP reductions need correct parameter relationships and strong semantic regression tests, just like other production ILP rules | | Skipping verification for complex reductions | Verification is default for a reason — `--no-verify` is for trivial identity/complement reductions only | + +## Reduction lifecycle responsibilities + +Apply the canonical [executed lifecycle](../../../docs/src/design.md#executed-reduction-lifecycle). +State the rule's instance domain, qualifying-witness premise, source guarantee, +and infeasibility interpretation. Check every qualifying tied optimum in small +exhaustive cases where ties are relevant. A witness flag alone does not prove +complete solvability or that adjacent path premises compose. + +Construct each executed result once and share the target and mapping state. Outcome interpretation uses the rule's mathematical relation; +ordinary extraction assumes its premises. Keep necessary dynamic/JSON conversion +and reachable representation failures, but no checked/unchecked extraction or +pure forwarding wrappers. Do not add `SolutionAggregate` bounds to models or +mathematical mappings; it belongs to brute-force witness selection. diff --git a/.claude/skills/check-issue/SKILL.md b/.claude/skills/check-issue/SKILL.md index e63957476..3f80845dd 100644 --- a/.claude/skills/check-issue/SKILL.md +++ b/.claude/skills/check-issue/SKILL.md @@ -192,10 +192,10 @@ If the algorithm is a high-level sketch rather than an implementable procedure ### 4d: Example Quality -- **Non-trivial**: Must have enough structure to exercise the reduction meaningfully (not just 2 vertices) +- **Meaningful structure**: Exercise the defining constraints or reduction gadgets; explain what an incorrect implementation would get wrong. - **Brute-force solvable**: Small enough to verify by hand or with `pred solve` - **Fully worked**: Shows the source instance, the reduction construction step by step, and the target instance — not just "apply the reduction to get..." -- **Round-trip testable**: The example must be complex enough to validate correctness via a closed-loop test: reduce the source instance → solve the target → extract the solution back → verify it is optimal for the source. A too-simple example (e.g., a single edge, a trivially satisfiable formula) can pass the round trip even with a buggy reduction. The example should have multiple feasible solutions with different objective values so that only a correct reduction maps to the true optimum. Rule of thumb: the source instance should have at least 2 suboptimal feasible solutions in addition to the optimal one. +- **Round-trip testable**: Choose examples that can expose a concrete construction, evaluation, objective-mapping, or extraction defect under the [canonical validation policy](../../../docs/src/design.md#validation-evidence). Explain the expected outcome independently of backend success. There is no fixed number of feasible alternatives that establishes correctness. --- @@ -242,7 +242,7 @@ Read the `size_fields` and any variant getters, then enumerate corner cases the | Set systems | empty universe, empty subsets, identical subsets, universe element appearing in no subset | | Algebraic | zero matrix, identity, singular matrix | -Then trace the **issue's** algorithm by hand against at least 2 corner cases that are not the worked example: +Then trace the **issue's** algorithm by hand on relevant corner cases beyond the worked example, chosen to test concrete assumptions: 1. Pick a corner case from the table above that the source model actually allows. 2. Simulate the issue's construction step by step. @@ -404,13 +404,13 @@ The formal definition must be **precise and implementable**: ### 4d: Example Quality -- **Non-trivial**: Enough vertices/variables to exercise constraints meaningfully (not just a triangle) +- **Meaningful structure**: Exercise the defining constraints or reduction gadgets; explain what an incorrect implementation would get wrong. - **Exercises core structure**: Examples must use the defining features of the problem. For instance, a "MultivariateQuadratic" example that only has linear terms does not exercise the quadratic structure → **Fail**. If the problem's name or definition highlights a specific structural feature (quadratic, k-colorable, bipartite, etc.), at least one example must exercise that feature. - **Expected outcome provided**: - Satisfaction problems must include a concrete valid / satisfying solution and say why it is valid - Optimization problems must include a concrete optimal solution and the optimal objective value - **Detailed enough for paper**: This example will appear in the paper — it needs to be illustrative -- **Round-trip testable**: The example must be complex enough that a round-trip test (construct instance → solve → verify) can catch implementation bugs. A too-simple instance (e.g., 2 vertices, a single clause) may have a trivially correct solution that passes even with a wrong implementation. The example should have multiple feasible configurations with different objective values (for optimization) or a mix of satisfying and non-satisfying configurations (for satisfaction problems), so that correctness is meaningfully tested. Rule of thumb: the instance should have at least 2 suboptimal feasible solutions in addition to the optimal one. +- **Round-trip testable**: Choose examples that can expose a concrete construction, evaluation, objective-mapping, or extraction defect under the [canonical validation policy](../../../docs/src/design.md#validation-evidence). Explain the expected outcome independently of backend success. There is no fixed number of feasible alternatives that establishes correctness. - **ILP-testable when claimed**: If the issue advertises a direct ILP path, the example should be rich enough to support strong ILP closed-loop tests rather than a degenerate "any formulation passes" case. ### 4e: Representation Feasibility diff --git a/.claude/skills/dev-setup/SKILL.md b/.claude/skills/dev-setup/SKILL.md index 333a693e0..f8039e9de 100644 --- a/.claude/skills/dev-setup/SKILL.md +++ b/.claude/skills/dev-setup/SKILL.md @@ -123,7 +123,7 @@ This runs `fmt-check + clippy + test`. Print a pass/fail summary for each stage. | Failure | Fix | |---------|-----| | `fmt-check` fails | Run `make fmt` to auto-fix | -| Linker errors in clippy/test | Missing C/C++ toolchain for `ilp-highs` feature. Install Xcode CLT (`xcode-select --install` on macOS) or `build-essential` (`sudo apt install build-essential` on Linux) | +| Linker errors in clippy/test | Missing C/C++ toolchain required by the HiGHS backend. Install Xcode CLT (`xcode-select --install` on macOS) or `build-essential` (`sudo apt install build-essential` on Linux) | | "HiGHS not found" or cmake errors | Install cmake: `brew install cmake` (macOS) or `sudo apt install cmake` (Linux) | | `cargo llvm-cov` fails with "missing llvm-profdata" | `rustup component add llvm-tools-preview` | diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md index fe1d1d777..f4bc84483 100644 --- a/.claude/skills/fix-issue/SKILL.md +++ b/.claude/skills/fix-issue/SKILL.md @@ -196,7 +196,7 @@ Tag each issue as: | Incorrect mathematical claims | Domain expertise needed | | Incomplete reduction algorithm | Core technical content | | Incomplete or trivial example | Present **3 concrete example options** with pros/cons (use `AskUserQuestion` with previews showing vertex/edge counts, optimal values, and suboptimal cases). Prefer examples that match the model issue's example when a companion model exists. | -| Decision vs optimization framing | **Default to objective-style models** unless evidence points otherwise. In the current aggregate-value architecture, that usually means `type Value = Max<_>`, `Min<_>`, or `Extremum<_>` when the sense is runtime data. Check associated `[Rule]` issues (`gh issue list --search " in:title label:rule"`) to see how rules use this model — if rules only need the decision version (e.g., reducing to SAT with a bound), an objective model still works because the bound can be read from the optimal aggregate value. Use `Or` for inherently existential feasibility problems (SAT, KColoring) where there is no natural objective. Use aggregate-only values such as `Sum<_>` or `And` only when the answer is genuinely a fold over all configurations and there is no representative witness. If switching to an objective model, add the appropriate `Minimum`/`Maximum` prefix per codebase conventions. | +| Decision vs optimization framing | **Default to objective-style models** unless evidence points otherwise. In the current aggregate-value architecture, that usually means `type Value = Max<_>`, `Min<_>`, or `Extremum<_>` when the sense is runtime data. Check associated `[Rule]` issues (`gh issue list --search " in:title label:rule"`) to see how rules use this model — if rules only need the decision version (e.g., reducing to SAT with a bound), an objective model still works because the bound can be read from the optimal aggregate value. Use `Or` for inherently existential feasibility problems (SAT, KColoring) where there is no natural objective. `Sum<_>` and `And` are fold-only values: a model using them evaluates and folds, but cannot be registered with `declare_variants!`, solved, or used as a reduction endpoint, because those paths require `EvaluationValue`. If switching to an objective model, add the appropriate `Minimum`/`Maximum` prefix per codebase conventions. | | Ambiguous overhead expressions | Requires understanding the reduction | --- diff --git a/.claude/skills/review-pipeline/SKILL.md b/.claude/skills/review-pipeline/SKILL.md index 51930ca79..15644c153 100644 --- a/.claude/skills/review-pipeline/SKILL.md +++ b/.claude/skills/review-pipeline/SKILL.md @@ -152,7 +152,7 @@ Run three independent sub-reviews. All three are **read-only** — they evaluate Invoke `/review-structural` (file: `.claude/skills/review-structural/SKILL.md`) with the pre-generated `IMPL_REPORT`. This runs the model/rule checklists, build checks, semantic review, and issue compliance checks. **Mathematical correctness is critical.** In addition to the standard structural checks, verify: -- **For rules**: Is the reduction mathematically correct? Trace through the `reduce_to()` logic with a small example and confirm the target instance encodes the same problem. Check that `extract_solution` correctly inverts the mapping. Verify the paper proof sketch is sound — not just present, but logically valid. +- **For rules**: Is the reduction mathematically correct? Trace through the `reduce_to()` logic with a small example and confirm the target instance encodes the same problem. Check that `recover_result` correctly inverts the mapping for each target status. Verify the paper proof sketch is sound — not just present, but logically valid. - **For models**: Does `evaluate()` correctly compute the objective for the mathematical definition? Are edge cases handled (empty graph, zero weights, infeasible configs)? - **Overhead expressions**: Manually count the sizes in `reduce_to()` output and verify they match the `overhead = { ... }` formulas. diff --git a/.claude/skills/review-quality/SKILL.md b/.claude/skills/review-quality/SKILL.md index 7c43c8241..aeec3ad0d 100644 --- a/.claude/skills/review-quality/SKILL.md +++ b/.claude/skills/review-quality/SKILL.md @@ -67,13 +67,18 @@ Only check these if the diff touches `problemreductions-cli/`: ## Step 5: Evaluate Test Quality +Read the canonical [validation policy](../../../docs/src/design.md#validation-evidence). Flag tests that: -- **Only check types/shapes, not values**: e.g., `assert!(result.is_some())` without checking the solution is correct -- **Mirror the implementation**: Tests recomputing the same formula as the code prove nothing -- **Lack adversarial cases**: Only happy path. Tests must include infeasible configs and boundary cases -- **Use trivial instances only**: Single-edge or 2-node tests may pass with bugs. Need 5+ vertex instances -- **Closed-loop without verification**: Must verify extracted solution is **optimal** (compare brute-force on both source and target) -- **Assert count too low**: 1-2 asserts for non-trivial code is insufficient + +- Check only types/shapes when the behavior requires a semantic value or witness assertion. +- Mirror the implementation without an independent expected result. +- Miss a concrete construction branch, infeasible configuration, or representation risk relevant to the change. +- Fail to check the reduction's stated mapping or objective relationship. Use explicit witnesses or small exhaustive oracles as appropriate; arbitrary feasible witnesses need not be optimal. +- Treat backend failures as mathematical counterexamples, or relax tolerances to make integration tests pass. +- Duplicate shared backend decoding/precision checks across rules. + +Use the smallest instances that distinguish correct from incorrect behavior. +Judge assertions by what they establish, not vertex, assertion, or test counts. ## Output Format @@ -110,3 +115,18 @@ Flag tests that: ### Summary - [list of all ISSUE items as bullet points with severity] ``` + +## Reduction lifecycle responsibilities + +Apply the canonical [executed lifecycle](../../../docs/src/design.md#executed-reduction-lifecycle). +State the rule's instance domain, qualifying-witness premise, source guarantee, +and infeasibility interpretation. Check every qualifying tied optimum in small +exhaustive cases where ties are relevant. A witness flag alone does not prove +complete solvability or that adjacent path premises compose. + +Construct each executed result once and share target, witness, value, and +completion state. Outcome interpretation uses the rule's mathematical relation; +ordinary extraction assumes its premises. Keep necessary dynamic/JSON conversion +and reachable representation failures, but no checked/unchecked extraction or +pure forwarding wrappers. Do not add `SolutionAggregate` bounds to models or +mathematical mappings; it belongs to brute-force witness selection. diff --git a/.claude/skills/review-structural/SKILL.md b/.claude/skills/review-structural/SKILL.md index d291a7e02..0dfe94a3e 100644 --- a/.claude/skills/review-structural/SKILL.md +++ b/.claude/skills/review-structural/SKILL.md @@ -57,7 +57,7 @@ Only run if review type includes "model". Given: problem name `P`, category `C`, | 5 | Aggregate value is present | `Grep("type Value =", file)` | | 6 | `#[cfg(test)]` + `#[path = "..."]` test link | `Grep("#\\[path =", file)` | | 7 | Test file exists | `Glob("src/unit_tests/models/{C}/{F}.rs")` | -| 8 | Test file has >= 3 test functions | `Grep("fn test_", test_file)` — count matches, FAIL if < 3 | +| 8 | Semantic test coverage | Inspect cases against the [validation policy](../../../docs/src/design.md#validation-evidence); require meaningful coverage of the changed behavior, not a test-function count. | | 9 | Registered in `{C}/mod.rs` | `Grep("mod {F}", "src/models/{C}/mod.rs")` | | 10 | Re-exported in `models/mod.rs` | `Grep("{P}", "src/models/mod.rs")` | | 11 | Variant registration exists | `Grep("declare_variants!|VariantEntry", file)` | @@ -66,7 +66,7 @@ Only run if review type includes "model". Given: problem name `P`, category `C`, | 14 | Canonical model example registered | `Grep("{P}", "src/example_db/model_builders.rs")` | | 15 | Paper `display-name` entry | `Grep('"{P}"', "docs/paper/reductions.typ")` | | 16 | Paper `problem-def` block | `Grep('problem-def.*"{P}"', "docs/paper/reductions.typ")` | -| 17 | Numeric and error contracts | Derive the expected boundary representation from the mathematical definition, then compare schema types, Rust fields, aggregate/total type, constructor and serde validation, conversions, overflow behavior, and boundary tests against `docs/src/design.md#numeric-types-and-arithmetic`. Verify construction paths return `ConstructionError`, `evaluate()` returns `EvaluationError`, and no public model path returns `Result<_, String>`. | +| 17 | Numeric and error contracts | Read the canonical [responsibility and arithmetic contract](../../../docs/src/design.md#responsibility-boundaries). Check model representation, constructor/serde consistency, and actual arithmetic risks; flag backend tolerances or enumeration limits used as model semantics. Verify construction paths return `ConstructionError`, `evaluate()` returns `EvaluationError`, and no public model path returns `Result<_, String>`. | ### Rule Checklist @@ -85,8 +85,8 @@ Only run if review type includes "rule". Given: source `S`, target `T`, rule fil | 9 | Canonical rule example registered | `Grep("canonical_rule_example_specs", rule file)` and verify it is included by `src/rules/mod.rs` | | 10 | Example-db lookup tests exist | `Grep("find_rule_example|build_rule_db", "src/unit_tests/example_db.rs")` | | 11 | Paper `reduction-rule` entry | `Grep('reduction-rule.*"{S}".*"{T}"', "docs/paper/reductions.typ")` | -| 12 | Extraction contract | Direct decoders call `validate_target_solution()`, enforce rule-specific structure, and test malformed cases; the helper does not establish feasibility or optimality. Composed extractors may delegate. | -| 13 | Numeric and error contracts | Compare source/target boundary types, size arithmetic, coefficients, bounds, auxiliary IDs, conversions, overflow behavior, and boundary tests against `docs/src/design.md#numeric-types-and-arithmetic`. Verify public reduction paths return `ReductionError`, preserve target `ConstructionError` as its construction cause, and never stringify or silently handle either failure. | +| 12 | Extraction contract | Follow the canonical responsibility boundaries. Both external extraction and internal rule mappings rely on documented premises; parsing and type conversion stay at the transport boundary. Reject repeated feasibility checks and error branches excluded by construction. Rules own the mathematical interpretation of optimum values, source infeasibility, and insufficient feasible candidates; solver and CLI callers invoke the same `recover_result` and add no interpretation branches. No independent optimality certification is required. | +| 13 | Numeric and error contracts | Check the [complete-result recovery contract](../../../docs/src/design.md#complete-result-recovery) and actual construction arithmetic under the canonical policy. Do not reject different objective directions/value types or demand backend precision tests for every rule. Verify public reduction paths return `ReductionError`, preserve target `ConstructionError` as its construction cause, and never stringify or silently handle either failure. | ## Step 2b: Blacklisted File Check @@ -109,14 +109,14 @@ Report pass/fail. If tests fail, identify which tests. **Do NOT fix anything** ## Step 4: Semantic Review ### For Models: -1. **`evaluate()` correctness** — Does it check feasibility before computing the objective when the model has invalid configurations? Objective models should return `Max/Min/Extremum(None)` for infeasible configs, witness problems should return `false`, and aggregate-only models should return the per-configuration contribution that matches the intended fold semantics. +1. **`evaluate()` correctness** — Does it check feasibility before computing the objective when the model has invalid configurations? Objective models should return `Max/Min/Extremum(None)` for infeasible configs, and witness problems should return `Or(false)`. 2. **`dims()` correctness** — Does it return the actual configuration space? (e.g., `vec![2; n]` for binary) 3. **Size getter consistency** — Do inherent getter methods (e.g., `num_vertices()`, `num_edges()`) match names used in overhead expressions? 4. **Weight handling** — Are weights managed via inherent methods, not traits? 5. **Numeric safety** — Are element and total types distinct where required, do serde and constructors enforce the same range, and are overflow and non-finite values rejected explicitly? ### For Rules: -1. **`extract_solution` correctness** — Does it implement the mathematical inverse? Is every branch either a defined mathematical case or an `ExtractionError`, with no defaulting, truncation, clamping, panic, or recovery? +1. **`recover_result` correctness** — Does it implement the mathematical inverse and handle `Optimal`, `Feasible`, and `Infeasible` explicitly? Is every branch either a defined mathematical case or an `ExtractionError`, with no defaulting, truncation, clamping, panic, or recovery? 2. **Overhead accuracy** — Does `overhead = { field = "expr" }` reflect the actual size relationship? 3. **Example quality** — Is it tutorial-style? Does the JSON export include both source and target data? 4. **Paper quality** — Is the reduction-rule statement precise? Is the proof sketch sound? @@ -131,7 +131,7 @@ Only if a linked issue was provided. |---|-------| | 1 | Problem name matches issue | | 2 | Mathematical definition matches | -| 3 | Problem framing (objective / witness / aggregate-only) matches | +| 3 | Problem framing (objective / witness) matches | | 4 | Type parameters match | | 5 | Configuration space matches | | 6 | Feasibility check matches | @@ -177,3 +177,18 @@ Flag any deviation as ISSUE. - X/Y issue compliance checks passed (if applicable) - [list of all FAIL/ISSUE items as bullet points] ``` + +## Reduction lifecycle responsibilities + +Apply the canonical [executed lifecycle](../../../docs/src/design.md#executed-reduction-lifecycle). +State the rule's instance domain, qualifying-witness premise, source guarantee, +and infeasibility interpretation. Check every qualifying tied optimum in small +exhaustive cases where ties are relevant. A witness flag alone does not prove +complete solvability or that adjacent path premises compose. + +Construct each executed result once and share target, witness, value, and +completion state. Outcome interpretation uses the rule's mathematical relation; +ordinary extraction assumes its premises. Keep necessary dynamic/JSON conversion +and reachable representation failures, but no checked/unchecked extraction or +pure forwarding wrappers. Do not add `SolutionAggregate` bounds to models or +mathematical mappings; it belongs to brute-force witness selection. diff --git a/.claude/skills/verify-reduction/SKILL.md b/.claude/skills/verify-reduction/SKILL.md index 91765ce3f..b328e7d8b 100644 --- a/.claude/skills/verify-reduction/SKILL.md +++ b/.claude/skills/verify-reduction/SKILL.md @@ -1,13 +1,14 @@ --- name: verify-reduction -description: Standalone mathematical verification of a reduction rule — generates a Typst proof plus constructor and independent adversary scripts with at least 5000 checks each. Reports a verdict without saving artifacts. +description: Verify a reduction mathematically using a Typst proof and independent constructor/adversary scripts, with coverage chosen from the construction's risks. Report findings without committing artifacts. --- # Verify Reduction -Mathematical verification of a reduction rule. Produces a Typst proof + dual Python verification scripts, iterating until all checks pass. Reports a VERIFIED/FAILED verdict. All artifacts are ephemeral — nothing is committed to the repository. - -Use standalone to check correctness before implementation, or as a subroutine of `/add-rule` (which calls this by default). +Verify a reduction before implementation, standalone or as the default mathematical +verification step of `/add-rule`. Produce a proof and independent executable +checks in a temporary directory. Report what was established and any limitations; +finite checks support the proof but do not replace it. ## Invocation @@ -16,280 +17,148 @@ Use standalone to check correctness before implementation, or as a subroutine of /verify-reduction SubsetSum Partition ``` -## Step 0: Parse Input - -```bash -REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner) -ISSUE= -ISSUE_JSON=$(gh issue view "$ISSUE" --json title,body,number) -``` - -If invoked with problem names instead of an issue number, use the names directly. - -## Step 1: Read Issue, Study Models, Type Check - -```bash -gh issue view "$ISSUE" --json title,body -pred show --json -pred show --json -``` - -### Type compatibility gate — MANDATORY - -Check source/target `Value` types before any work. The `grep` only locates the definitions; it does -not resolve generic parameters or associated types: - -```bash -grep "type Value = " src/models/*/.rs src/models/*/.rs -``` - -Resolve both concrete types completely before declaring compatibility: - -1. Substitute every concrete generic argument from the proposed rule. -2. Follow every type alias and associated type to its defining `impl`. -3. Record the substitution chain and the source file evidence in the verification report. -4. If any generic or associated type remains unresolved, run a compile-backed temporary Rust probe - using `std::any::type_name::<::Value>()`. Build the probe from `/tmp` - with a path dependency on this repository; do not modify the repository. - -Never infer a Rust value type from the mathematical problem name, from unit-weight terminology, or -from the Python verifier's integer representation. In particular, arbitrary-precision Python -integers do not establish that a Rust objective type is `usize` or that it is closed under all -legal source instances. - -Required report format: +## Step 1: Read the Definition and Resolve the API + +For an issue, read it with `gh issue view --json title,body`. Inspect both +concrete models with `pred show --json` and read their implementations. +Extract the construction, mathematical domain, correctness argument, witness +mapping, parameter formulas, worked example, and references. Consult the cited +literature when needed to resolve a mathematical claim. + +Read the canonical [complete-result recovery contract](../../../docs/src/design.md#complete-result-recovery), +[arithmetic policy](../../../docs/src/design.md#arithmetic), and +[validation policy](../../../docs/src/design.md#validation-evidence). + +Locate `Solution` and `Value` definitions with `rg`, substitute concrete generic +arguments, and follow associated types to their implementations. Record the +resolved types and source evidence. If resolution remains unclear, use a temporary +compile-backed Rust probe with a path dependency on the repository. Do not infer +Rust types from problem names, unit-weight terminology, or Python integers. + +Check the actual operation: + +- A witness reduction maps solutions and justifies feasibility and, where claimed, + optimality preservation. Different objective directions or Rust value types are + not automatic failures. For example, complementing an independent set of size + `k` gives a vertex cover of size `n-k` and reverses optimization direction. +- An aggregate reduction must justify its actual value conversion. Check the + domain of arithmetic the construction or mapping performs, not a hypothetical + conversion between all source and target objective values. +- A multi-query algorithm needs the existing Turing capability. An arbitrary + feasibility witness does not establish a source optimum without an argument. + +Report a concrete mathematical/API mismatch before implementation if one exists. +Do not replace that analysis with a wrapper-pair whitelist or backend range gate. + +## Step 2: Write the Proof + +Write a standalone Typst proof in the temporary directory containing: + +- Source/target definitions and the precise applicability domain. +- Construction steps with symbols defined before use. +- Independent forward and reverse correctness arguments. For optimization, + state the objective relationship and why target optima yield source optima. +- Witness extraction, including its mathematical preconditions. +- Target parameter formulas, distinguishing equalities from upper bounds. +- Small worked examples that exercise the construction. Include YES and NO + examples where both exist; for always-feasible optimization problems, show + the relevant objective relationship instead of inventing an infeasible case. + +Use enough detail to make the argument checkable. Do not substitute phrases such +as “obviously” or “the converse is similar” for a missing proof. Example size is +chosen for clarity and coverage, not a minimum vertex count. + +## Step 3: Implement Constructor Checks + +Write a temporary Python script with independent source/target feasibility and +objective oracles. Cover the claims relevant to this construction: + +| Claim | Evidence | +|-------|----------| +| Forward/reverse correctness | Small exhaustive instances or justified sampling; compare feasibility and the stated optimum relationship | +| Witness extraction | Target witnesses satisfying the mapping's preconditions produce valid source witnesses; check optimal mappings where claimed | +| Parameter formulas | Measure constructed targets and compare with equalities or upper bounds; use symbolic checking when it adds evidence | +| Target structure | Check the actual target invariants and gadget interactions | +| Worked examples | Reproduce the proof's values and witnesses | +| Arithmetic/case splits | Exercise concrete branches and representation risks in the construction | + +Choose exhaustive bounds and sampling from the construction's risks and cost. +Record bounds, seeds, counts, and omissions so the evidence is reproducible. +There is no universal minimum generated-check count. Do not duplicate solver +precision tests or require a backend to establish mathematical equivalence. +Python's arbitrary-precision arithmetic is not evidence that Rust construction +arithmetic cannot overflow; inspect the actual stored representation separately. + +## Step 4: Run Checks and Analyze Gaps + +Run the script and investigate failures. Correct the proof, construction, or +checker according to the evidence, then rerun affected checks. Map each proof +claim to its executable evidence or explain why it is established by proof alone. +Report untested areas rather than increasing check counts without new coverage. + +If a backend integration run is included, identify it separately. Record whether +failure occurs in construction, solving, extraction, or source validation. A +backend timeout, numerical rejection, or non-optimal termination is not itself a +counterexample to the reduction theorem and must not be reported as a pass. + +## Step 5: Independent Adversary Verification + +Dispatch an independent subagent with the problem definitions and Typst proof, +without the constructor script. Ask it to implement its own construction, +extraction, feasibility, and objective checks. It must not import the constructor +implementation. Have it challenge the proof's actual risks: + +- Complement/identity mappings: objective direction and witness correspondence. +- Algebraic mappings: case boundaries, coefficients, and extraction per case. +- Gadget mappings: unintended paths, gadget interactions, and target invariants. + +Use exhaustive checks or property-based strategies where they provide useful +independent coverage, not to satisfy a count. Reproduce applicable worked examples. +Compare both implementations on shared instances. Investigate disagreements; +structurally different but equivalent encodings may be valid. One checker passing +does not establish that the other checker is at fault. + +## Step 6: Review and Report + +Before reporting, confirm: + +- The concrete Rust types and actual witness/aggregate contract were checked. +- The proof covers construction, both directions, extraction, and parameters. +- Independent checks exercise relevant branches and mappings with reproducible + bounds/seeds; remaining gaps are stated. +- Disagreements and failures are resolved or explicitly reported. +- Mathematical evidence and backend integration results are distinguished. + +Report: ```text -TYPE RESOLUTION: - Source syntax: Min - Substitutions: W = One; ::Sum = i64 - Source resolved: Min - Target syntax: Min - Target resolved: Min - Full-domain compatibility: FAILED -``` - -**Compatible pairs for `ReduceTo` (witness-capable):** -- `Or`->`Or` -- `Min`->`Min`, `Max`->`Max` (identical resolved inner type) -- `Or`->`Min`, `Or`->`Max` (feasibility embeds into optimization) - -`Min`->`Min` or `Max`->`Max` with `S != T` is not automatically compatible. Proceed -only if the rule or source model declares a bound covering every legal source instance and the -verification proves a total, order-preserving conversion over that full declared domain. Otherwise -STOP and report a value-domain mismatch. - -**Incompatible — STOP if any of these:** -- `Min`->`Or` or `Max`->`Or` — optimization source has no threshold K; needs a decision-variant source model -- `Max`->`Min` or `Min`->`Max` — opposite optimization directions; needs `ReduceToAggregate` or a decision-variant wrapper -- `Or`->`Sum` or `Min`->`Sum` — Sum is aggregate-only; needs `ReduceToAggregate` -- Any pair involving `And` or `Sum` on the target side - -**Regression case:** `MinimumDominatingSet` resolves to `Min` because -`::Sum = i64`; `MinimumHittingSet` resolves to `Min`. Report -`Min -> Min`, not `Min -> Min`. Without an explicit source-size bound, -the full-domain type gate fails even though the classical cardinality reduction is mathematically -correct and exhaustive small-instance checks pass. - -If incompatible, STOP and report the type mismatch and options. Do NOT proceed. - -### If compatible - -Extract: construction algorithm, correctness argument, overhead formulas, worked example, reference. Use WebSearch if the issue is incomplete. - -## Step 2: Write Typst Proof - -Write a standalone Typst proof (in a temp file, not committed). - -**Mandatory structure:** - -```typst -== Source $arrow.r$ Target -#theorem[...] -#proof[ - _Construction._ (numbered steps, every symbol defined before first use) - _Correctness._ - ($arrow.r.double$) ... (genuinely independent, NOT "the converse is similar") - ($arrow.l.double$) ... - _Solution extraction._ ... -] -*Overhead.* (table with target metric -> formula) -*Feasible example.* (YES instance, >=3 variables, fully worked with numbers) -*Infeasible example.* (NO instance, fully worked — show WHY no solution exists) -``` - -**Hard rules:** -- Zero instances of "clearly", "obviously", "it is easy to see", "straightforward" -- Zero scratch work ("Wait", "Hmm", "Actually", "Let me try") -- Two examples minimum, both with >=3 variables/vertices -- Every symbol defined before first use - -## Step 3: Write Constructor Python Script - -Write a Python verification script (temp file) with ALL 7 mandatory sections: - -| Section | What to verify | Notes | -|---------|---------------|-------| -| 1. Symbolic (sympy) | Overhead formulas symbolically for general n | "The overhead is trivial" is NOT an excuse to skip | -| 2. Exhaustive forward+backward | Source feasible <=> target feasible | n <= 5 minimum. ALL instances or >=300 sampled per (n,m) | -| 3. Solution extraction | Extract source solution from every feasible target witness | Most commonly skipped section. DO NOT SKIP | -| 4. Overhead formula | Build target, measure actual size, compare against formula | Catches off-by-one in construction | -| 5. Structural properties | Target well-formed, no degenerate cases | Gadget reductions: girth, connectivity, widget structure | -| 6. YES example | Reproduce exact Typst feasible example numbers | Every value must match | -| 7. NO example | Reproduce exact Typst infeasible example, verify both sides infeasible | Must verify WHY infeasible | - -### Minimum check counts — STRICTLY ENFORCED - -| Type | Minimum checks | Minimum n | -|------|---------------|-----------| -| Identity (same graph, different objective) | 10,000 | n <= 6 | -| Algebraic (padding, complement, case split) | 10,000 | n <= 5 | -| Gadget (widget, cycle construction) | 5,000 | n <= 5 | - -Every reduction gets at least 5,000 checks regardless of perceived simplicity. - -## Step 4: Run and Iterate - -```bash -python3 /tmp/verify__.py -``` - -### Iteration 1: Fix failures - -Run the script. Fix any failures. Re-run until 0 failures. - -### Iteration 2: Check count audit - -Print and fill this table honestly: - -``` -CHECK COUNT AUDIT: - Total checks: ___ (minimum: 5,000) - Forward direction: ___ instances (minimum: all n <= 5) - Backward direction: ___ instances (minimum: all n <= 5) - Solution extraction: ___ feasible instances tested - Overhead formula: ___ instances compared - Symbolic (sympy): ___ identities verified - YES example: verified? [yes/no] - NO example: verified? [yes/no] - Structural properties: ___ checks -``` - -If ANY line is below minimum, enhance the script and re-run. Do NOT proceed. - -### Iteration 3: Gap analysis - -List EVERY claim in the Typst proof and whether it's tested: - -``` -CLAIM TESTED BY -"Universe has 2n elements" Section 4: overhead -"Complementarity forces consistency" Section 3: extraction -"Forward: NAE-sat -> valid splitting" Section 2: exhaustive -... -``` - -If any claim has no test, add one. If untestable, document WHY. - -## Step 5: Adversary Verification - -Dispatch a subagent that reads ONLY the Typst proof (not the constructor script) and independently implements + tests the reduction. - -**Adversary requirements:** -- Own `reduce()` function from scratch -- Own `extract_solution()` function -- Own `is_feasible_source()` and `is_feasible_target()` validators -- Exhaustive forward + backward for n <= 5 -- `hypothesis` property-based testing (>=2 strategies) -- Reproduce both Typst examples (YES and NO) -- >=5,000 total checks -- Must NOT import from the constructor script - -**Typed adversary focus** (include in prompt): -- **Identity reductions:** exhaustive enumeration n <= 6, edge-case configs (all-zero, all-one, alternating) -- **Algebraic reductions:** case boundary conditions (e.g., S = 2T exactly, S = 2T +/- 1), per-case extraction -- **Gadget reductions:** widget structure invariants, traversal patterns, interior vertex isolation - -### Cross-comparison - -After both scripts pass, compare `reduce()` outputs on shared instances. Both must produce structurally identical targets and agree on feasibility for all tested instances. - -### Verdict table - -| Constructor | Adversary | Cross-compare | Verdict | Action | -|-------------|-----------|---------------|---------|--------| -| Pass | Pass | Agree | **VERIFIED** | Done (or proceed to add-rule Step 2) | -| Pass | Pass | Disagree | **Suspect** | Investigate — may be isomorphic or latent bug | -| Pass | Fail | -- | **Adversary bug** | Fix adversary or clarify Typst spec | -| Fail | Pass | -- | **Constructor bug** | Fix constructor, re-run from Step 4 | -| Fail | Fail | -- | **Proof bug** | Re-examine Typst proof, return to Step 2 | - -## Step 6: Self-Review Checklist - -Every item must be YES. If any is NO, go back and fix. - -### Typst proof -- [ ] Construction with numbered steps, symbols defined before use -- [ ] Correctness with independent => and <= paragraphs -- [ ] Solution extraction section present -- [ ] Overhead table with formulas -- [ ] YES example (>=3 variables, fully worked) -- [ ] NO example (fully worked, explains WHY infeasible) -- [ ] Zero hand-waving language -- [ ] Zero scratch work - -### Type gate -- [ ] Concrete Rust `Value` types fully resolved with substitution evidence -- [ ] Different numeric domains either rejected or covered by an explicit full-domain range proof - -### Constructor Python -- [ ] 0 failures, >=5,000 total checks -- [ ] All 7 sections present and non-empty -- [ ] Exhaustive n <= 5 -- [ ] Extraction tested for every feasible instance -- [ ] Gap analysis: every Typst claim has a test - -### Adversary Python -- [ ] 0 failures, >=5,000 total checks -- [ ] Independent implementation (no imports from constructor) -- [ ] `hypothesis` PBT with >=2 strategies -- [ ] Reproduces both Typst examples - -### Cross-consistency -- [ ] Cross-comparison: 0 disagreements, 0 feasibility mismatches - -## Step 7: Report Verdict - -Report the final verdict to the user: - -``` -VERIFICATION RESULT: VERIFIED / FAILED - Source: - Target: - Constructor checks: - Adversary checks: - Cross-comparison: instances, 0 disagreements - Issue: # +VERIFICATION RESULT: VERIFIED / FAILED / INCOMPLETE + Source and target: + Mathematical claim and applicability domain:

+ Constructor coverage: + Independent coverage: + Cross-comparison: + Remaining gaps or counterexamples:
+ Backend integration, if run: ``` -If called as a subroutine of `/add-rule`, the verified Python `reduce()`, `extract_solution()`, and YES/NO instances remain in conversation context for use in the Rust implementation steps. No files are saved. - -If called standalone, the verdict is the final output. The user can inspect the proof and scripts interactively during the session. - -## Common Mistakes - -| Mistake | Consequence | -|---------|-------------| -| Proceeding past type gate with incompatible types | Wasted work — math may be correct but `ReduceTo` impl is impossible | -| Adversary imports from constructor script | Rejected — must be independent | -| No `hypothesis` PBT in adversary | Rejected | -| Section 1 (symbolic) empty | Rejected — "overhead is trivial" is not an excuse | -| Only YES example, no NO example | Rejected | -| n <= 3 or n <= 4 "because it's simple" | Rejected — minimum n <= 5 | -| No gap analysis | Rejected — perform before proceeding | -| Example has < 3 variables | Rejected — too degenerate | -| Either script has < 5,000 checks | Rejected — enhance testing | -| Extraction (Section 3) not tested | Rejected — most commonly skipped | -| Cross-comparison skipped | Rejected | -| Disagreements dismissed without investigation | Rejected | -| Saving artifacts to the repository | All files are ephemeral — use temp directory, nothing committed | +Use VERIFIED only when the proof and independent checks support the stated claim; +use FAILED for an established defect and INCOMPLETE for unresolved evidence. +When called by `/add-rule`, provide the checked construction, extraction, and +examples for the Rust implementation. Keep proof/scripts/results temporary; do +not commit generated verification artifacts. + +## Reduction lifecycle responsibilities + +Apply the canonical [executed lifecycle](../../../docs/src/design.md#executed-reduction-lifecycle). +State the rule's instance domain, qualifying-witness premise, source guarantee, +and infeasibility interpretation. Check every qualifying tied optimum in small +exhaustive cases where ties are relevant. A witness flag alone does not prove +complete solvability or that adjacent path premises compose. + +Construct each executed result once and share target, witness, value, and +completion state. Outcome interpretation uses the rule's mathematical relation; +ordinary extraction assumes its premises. Keep necessary dynamic/JSON conversion +and reachable representation failures, but no checked/unchecked extraction or +pure forwarding wrappers. Do not add `SolutionAggregate` bounds to models or +mathematical mappings; it belongs to brute-force witness selection. diff --git a/.config/nextest.toml b/.config/nextest.toml index 402f0ff76..a4e5c4b83 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -7,15 +7,7 @@ # whole job was SIGTERM-killed at the runner level) — nextest reports it as a # clean per-test timeout failure instead of stalling the job. # -# The large headroom (300s, not a tight ~10s) is deliberate. The subprocess -# example tests in tests/suites/examples.rs shell out to `cargo run --example -# … --features ilp-highs`: -# - In the Test job, CI pre-builds those examples (see ci.yml) so the -# subprocess reuses artifacts and each test runs in well under a second. -# - In the Code Coverage job, the subprocess inherits llvm-cov's -# `-C instrument-coverage` RUSTFLAGS, so it recompiles the examples -# *instrumented* (a non-instrumented pre-build would not match its -# fingerprint, so pre-building there is pointless). That instrumented -# recompile legitimately takes >120s, hence the 300s bound. +# The large headroom (300s, not a tight ~10s) is deliberate: this is a final +# safety bound for genuinely hung tests, not the expected runtime budget. [profile.default] slow-timeout = { period = "60s", terminate-after = 5 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ef1a9fd8..47399c5d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Check formatting run: cargo fmt --all --check - # Lints. + # Lints every workspace crate with every feature (CLI `mcp`, `benchmarks`). clippy: name: Clippy runs-on: ubuntu-latest @@ -49,7 +49,7 @@ jobs: components: clippy - uses: Swatinem/rust-cache@v2 - name: Run clippy - run: cargo clippy --all-targets --features example-db -- -D warnings + run: cargo clippy --workspace --all-targets --all-features -- -D warnings # Build and exercise the HiGHS-backed CLI natively on Apple Silicon. macos-arm64: @@ -191,5 +191,5 @@ jobs: uses: codecov/codecov-action@v5 with: files: lcov.info - fail_ci_if_error: false # Don't fail CI if upload fails + fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Cargo.toml b/Cargo.toml index 1fa1906fa..78f32a08b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,25 +21,25 @@ example-db = [] benchmarks = ["dep:criterion"] [dependencies] -petgraph = { version = "0.8", features = ["serde-1"] } -bitvec = "1.0" -serde = { version = "1.0", features = ["derive"] } +petgraph = { version = "0.8.3", features = ["serde-1"] } +bitvec = "1.1.1" +serde = { version = "1.0.229", features = ["derive"] } # Persisted problem and reduction data must preserve every finite f64 on replay. -serde_json = { version = "1.0", features = ["float_roundtrip"] } -thiserror = "2.0" -num-bigint = "0.4" -num-rational = "0.4" -num-traits = "0.2" -good_lp = { version = "=1.14.2", default-features = false, features = ["highs"] } -inventory = "0.3" -ordered-float = "5.0" -rand = "0.10" -criterion = { version = "0.8", optional = true } +serde_json = { version = "1.0.151", features = ["float_roundtrip"] } +thiserror = "2.0.20" +num-bigint = { version = "0.4.8", features = ["serde"] } +num-rational = { version = "0.4.2", features = ["serde"] } +num-traits = "0.2.19" +sprs = { version = "0.11.5", default-features = false, features = ["serde"] } +highs = "=2.4.0" +inventory = "0.3.24" +rand = "0.10.2" +criterion = { version = "0.8.2", optional = true } problemreductions-macros = { version = "0.6.0", path = "problemreductions-macros" } problemreductions-expr = { version = "0.6.0", path = "problemreductions-expr" } [dev-dependencies] -proptest = "1.0" +proptest = "1.11.0" [[bench]] name = "solver_benchmarks" diff --git a/Makefile b/Makefile index 1cb637f32..3a91d10e1 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,7 @@ fmt-check: # Run clippy clippy: - cargo clippy --all-targets --features "$(TEST_FEATURES)" -- -D warnings + cargo clippy --workspace --all-targets --all-features -- -D warnings node_modules/elkjs/package.json: package.json package-lock.json npm ci @@ -163,10 +163,13 @@ paper: cargo run --features "$(TEST_FEATURES)" --example export_schemas typst compile --root . docs/paper/reductions.typ docs/paper/reductions.pdf -# Generate coverage report (requires: cargo install cargo-llvm-cov) +# Check changed-line coverage against the PR base, including uncommitted changes. +COVERAGE_BASE ?= origin/main +# Requires cargo-llvm-cov and uv. coverage: @command -v cargo-llvm-cov >/dev/null 2>&1 || { echo "Installing cargo-llvm-cov..."; cargo install cargo-llvm-cov; } - cargo llvm-cov --workspace --html --open + cargo llvm-cov --workspace --lcov --output-path target/coverage.lcov + uvx diff-cover target/coverage.lcov --compare-branch $(COVERAGE_BASE) --fail-under 95 --total-percent-float --format html:target/coverage-diff.html # Clean build artifacts clean: diff --git a/codecov.yml b/codecov.yml index 27c6f0cd2..7091d9e6f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,11 +10,11 @@ coverage: project: default: target: 95% - threshold: 2% + threshold: 0% patch: default: target: 95% - threshold: 2% + threshold: 0% # Exclude proc-macro crate from coverage since it runs at compile time # and traditional runtime coverage tools cannot measure it. diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ee531965e..d8a1ab962 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -189,6 +189,7 @@ "MaximumLeafSpanningTree": [Maximum Leaf Spanning Tree], "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], + "DecisionMaxCut": [Decision Max-Cut], "GeneralizedHex": [Generalized Hex], "GraphPartitioning": [Graph Partitioning], "HamiltonianCircuit": [Hamiltonian Circuit], @@ -200,7 +201,9 @@ "DirectedHamiltonianPath": [Directed Hamiltonian Path], "IntegralFlowBundles": [Integral Flow with Bundles], "LongestCircuit": [Longest Circuit], + "DecisionLongestCircuit": [Decision Longest Circuit], "LongestPath": [Longest Path], + "DecisionLongestPath": [Decision Longest Path], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], "UndirectedFlowLowerBounds": [Undirected Flow with Lower Bounds], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], @@ -213,6 +216,7 @@ "KColoring": [$k$-Coloring], "KClique": [$k$-Clique], "MinimumCoveringByCliques": [Minimum Covering by Cliques], + "DecisionMinimumCoveringByCliques": [Decision Minimum Covering by Cliques], "MinimumIntersectionGraphBasis": [Minimum Intersection Graph Basis], "MinimumDominatingSet": [Minimum Dominating Set], "MinimumGeometricConnectedDominatingSet": [Minimum Geometric Connected Dominating Set], @@ -236,7 +240,9 @@ "SetSplitting": [Set Splitting], "MinimumCardinalityKey": [Minimum Cardinality Key], "SpinGlass": [Spin Glass], + "DecisionSpinGlass": [Decision Spin Glass], "QUBO": [QUBO], + "DecisionQUBO": [Decision QUBO], "ILP": [Integer Linear Programming], "IntegerKnapsack": [Integer Knapsack], "Knapsack": [Knapsack], @@ -245,6 +251,7 @@ "NAESatisfiability": [NAE-SAT], "KSatisfiability": [$k$-SAT], "Maximum2Satisfiability": [Maximum 2-Satisfiability], + "DecisionMaximum2Satisfiability": [Decision Maximum 2-Satisfiability], "NonTautology": [Non-Tautology], "OneInThreeSatisfiability": [1-in-3 SAT], "Planar3Satisfiability": [Planar 3-SAT], @@ -266,14 +273,17 @@ "CapacityAssignment": [Capacity Assignment], "ConsistencyOfDatabaseFrequencyTables": [Consistency of Database Frequency Tables], "ClosestVectorProblem": [Closest Vector Problem], + "DecisionClosestVectorProblem": [Decision Closest Vector Problem], "ConsecutiveSets": [Consecutive Sets], "DisjointConnectingPaths": [Disjoint Connecting Paths], "MinimumMultiwayCut": [Minimum Multiway Cut], "OptimalLinearArrangement": [Optimal Linear Arrangement], "RootedTreeArrangement": [Rooted Tree Arrangement], "RuralPostman": [Rural Postman], + "DecisionRuralPostman": [Decision Rural Postman], "MixedChinesePostman": [Mixed Chinese Postman], "StackerCrane": [Stacker Crane], + "DecisionStackerCrane": [Decision Stacker Crane], "LongestCommonSubsequence": [Longest Common Subsequence], "ClosestString": [Closest String], "ClosestSubstring": [Closest Substring], @@ -307,14 +317,17 @@ "IntegralFlowHomologousArcs": [Integral Flow with Homologous Arcs], "IntegralFlowWithMultipliers": [Integral Flow With Multipliers], "MinMaxMulticenter": [Min-Max Multicenter], + "DecisionMinMaxMulticenter": [Decision Min-Max Multicenter], "FlowShopScheduling": [Flow Shop Scheduling], "JobShopScheduling": [Job-Shop Scheduling], "OpenShopScheduling": [Open Shop Scheduling], + "DecisionOpenShopScheduling": [Decision Open Shop Scheduling], "GroupingBySwapping": [Grouping by Swapping], "IntegerExpressionMembership": [Integer Expression Membership], "MinimumCutIntoBoundedSets": [Minimum Cut Into Bounded Sets], "MinimumDummyActivitiesPert": [Minimum Dummy Activities in PERT Networks], "MinimumSumMulticenter": [Minimum Sum Multicenter], + "DecisionMinimumSumMulticenter": [Decision Minimum Sum Multicenter], "MinimumTardinessSequencing": [Minimum Tardiness Sequencing], "MonochromaticTriangle": [Monochromatic Triangle], "MultipleChoiceBranching": [Multiple Choice Branching], @@ -333,6 +346,7 @@ "PreemptiveScheduling": [Preemptive Scheduling], "PrimeAttributeName": [Prime Attribute Name], "QuadraticAssignment": [Quadratic Assignment], + "DecisionQuadraticAssignment": [Decision Quadratic Assignment], "EquilibriumPoint": [Equilibrium Point], "QuadraticCongruences": [Quadratic Congruences], "QuadraticDiophantineEquations": [Quadratic Diophantine Equations], @@ -360,6 +374,7 @@ "SchedulingWithIndividualDeadlines": [Scheduling With Individual Deadlines], "SequencingToMinimizeMaximumCumulativeCost": [Sequencing to Minimize Maximum Cumulative Cost], "SequencingToMinimizeTardyTaskWeight": [Sequencing to Minimize Tardy Task Weight], + "DecisionSequencingToMinimizeTardyTaskWeight": [Decision Sequencing to Minimize Tardy Task Weight], "SequencingToMinimizeWeightedCompletionTime": [Sequencing to Minimize Weighted Completion Time], "SequencingToMinimizeWeightedTardiness": [Sequencing to Minimize Weighted Tardiness], "SequencingWithDeadlinesAndSetUpTimes": [Sequencing with Deadlines and Set-Up Times], @@ -369,7 +384,6 @@ "ShortestCommonSuperstring": [Shortest Common Superstring], "StaffScheduling": [Staff Scheduling], "SteinerTree": [Steiner Tree], - "SteinerTreeInGraphs": [Steiner Tree in Graphs], "MinimumAxiomSet": [Minimum Axiom Set], "MinimumExternalMacroDataCompression": [Minimum External Macro Data Compression], "MinimumInternalMacroDataCompression": [Minimum Internal Macro Data Compression], @@ -728,7 +742,7 @@ = 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 _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 catalogues #graph-data.edges.len() edges connecting #graph-data.nodes.len() problem types. Executable reductions construct a target instance and recover a source result. The graph also retains proof-only NP-hardness embeddings and theoretical Turing relations; both are excluded from runtime path search. Turing relations require multiple adaptive queries and have no executor in the library. == Notation @@ -1342,7 +1356,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) @@ -3219,11 +3233,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 edge weights $w: E -> ZZ$ and a set of terminal vertices $T subset.eq V$ with $|T| >= 1$, 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_. ][ 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 nonnegative weights, Dreyfus--Wagner runs in $O^*(3^(|T|) dot n + 2^(|T|) dot n^2)$ time using 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, enumerating the $2^(n-|T|)$ nonterminal subsets and computing a minimum spanning tree on each induced graph gives an $O(2^(n-|T|) n^2)$ exact algorithm. Selected edges must still form a single acyclic tree; disconnected negative edges and cycles are invalid. With one terminal, the zero-edge tree at that terminal is feasible, but a tree containing negative edges can have lower cost. // Find the unique direct terminal-terminal edge (both endpoints in T, not in the optimal tree) #let terminal-set = terminals @@ -3809,74 +3825,6 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } -#{ - let x = load-model-example("SteinerTreeInGraphs") - let nv = graph-num-vertices(x.instance) - let edges = x.instance.graph.edges - let ne = edges.len() - let terminals = x.instance.terminals - let weights = x.instance.edge_weights - let sol = (config: x.optimal_config, metric: x.optimal_value) - let opt-weight = metric-value(sol.metric) - // Derive tree edges from optimal config - let tree-edge-indices = sol.config.enumerate().filter(((i, v)) => v).map(((i, _)) => i) - let tree-edges = tree-edge-indices.map(i => edges.at(i)) - // Steiner vertices: non-terminal vertices that appear in tree edges - let steiner-verts = range(nv).filter(v => not terminals.contains(v) and tree-edges.any(e => e.at(0) == v or e.at(1) == v)) - [ - #problem-def("SteinerTreeInGraphs")[ - Given an undirected graph $G = (V, E)$ with edge weights $w: E -> RR_(>= 0)$ and a set of terminal vertices $R subset.eq V$, find a subtree $T$ of $G$ that spans all terminals in $R$ and minimizes the total edge weight $sum_(e in T) w(e)$. - ][ - A classical NP-complete problem from Karp's list (as "Steiner Tree in Graphs," Garey & Johnson ND12) @karp1972. Central to network design, VLSI layout, and phylogenetic reconstruction. The problem generalizes minimum spanning tree (where $R = V$) and shortest path (where $|R| = 2$). The Dreyfus--Wagner dynamic programming algorithm @dreyfuswagner1971 solves it in $O(3^k dot n + 2^k dot n^2 + n^3)$ time, where $k = |R|$ and $n = |V|$. Bjorklund et al. @bjorklund2007 achieved $O^*(2^k)$ using subset convolution over the Mobius algebra, and Nederlof @nederlof2009 gave an $O^*(2^k)$ polynomial-space algorithm. - - *Example.* Consider a graph $G$ with $n = #nv$ vertices and $|E| = #ne$ edges. The terminals are $R = {#terminals.map(i => $v_#i$).join(", ")}$ (blue). The optimal Steiner tree uses Steiner vertex #steiner-verts.map(i => $v_#i$).join(", ") (gray, dashed border) and edges #tree-edges.map(e => [$\{v_#(e.at(0)), v_#(e.at(1))\}$]).join(", ") with total weight #tree-edge-indices.map(i => str(weights.at(i))).join(" + ") $= #opt-weight$. - - #pred-commands( - "pred create --example SteinerTreeInGraphs -o steiner-tree-in-graphs.json", - "pred solve steiner-tree-in-graphs.json", - "pred evaluate steiner-tree-in-graphs.json --config " + cli-config(x.optimal_config), - ) - - #figure({ - // Graph: 6 vertices arranged in two rows (layout positions) - let verts = ((0, 1), (1.5, 1), (3, 1), (1.5, -0.5), (3, -0.5), (4.5, 0.25)) - canvas(length: 1cm, { - import draw: * - // Edge (0,2) idx=1 would otherwise pass straight through the collinear - // vertex $v_1$ at $(1.5, 1)$, so route it as a quadratic Bezier arc above. - let arc-ctrl = ("1": (1.5, 1.85)) - for (idx, (u, v)) in edges.enumerate() { - let on-tree = tree-edges.any(t => (t.at(0) == u and t.at(1) == v) or (t.at(0) == v and t.at(1) == u)) - let stk = if on-tree { 2pt + graph-colors.at(0) } else { 1pt + luma(200) } - let key = str(idx) - if key in arc-ctrl { - let c = arc-ctrl.at(key) - bezier(verts.at(u), verts.at(v), c, stroke: stk) - let mx = 0.25 * verts.at(u).at(0) + 0.5 * c.at(0) + 0.25 * verts.at(v).at(0) - let my = 0.25 * verts.at(u).at(1) + 0.5 * c.at(1) + 0.25 * verts.at(v).at(1) - draw.content((mx, my + 0.18), text(7pt, fill: luma(80))[#weights.at(idx)]) - } else { - g-edge(verts.at(u), verts.at(v), stroke: stk) - let mx = (verts.at(u).at(0) + verts.at(v).at(0)) / 2 - let my = (verts.at(u).at(1) + verts.at(v).at(1)) / 2 - draw.content((mx, my), text(7pt, fill: luma(80))[#weights.at(idx)]) - } - } - for (k, pos) in verts.enumerate() { - let is-terminal = terminals.contains(k) - let is-steiner = steiner-verts.contains(k) - g-node(pos, name: "v" + str(k), - fill: if is-terminal { graph-colors.at(0) } else if is-steiner { luma(220) } else { white }, - stroke: if is-steiner { (dash: "dashed", paint: graph-colors.at(0)) } else { 1pt + black }, - label: if is-terminal { text(fill: white)[$v_#k$] } else { [$v_#k$] }) - } - }) - }, - caption: [Steiner Tree: terminals $R = {#terminals.map(i => $v_#i$).join(", ")}$ (blue), Steiner vertex #steiner-verts.map(i => $v_#i$).join(", ") (dashed). Optimal tree (blue edges) has weight #opt-weight.], - ) - ] - ] -} #{ let x = load-model-example("MinimumSumMulticenter") @@ -4945,10 +4893,19 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } +// Expand small sparse QUBO examples only for typesetting their matrices. +#let qubo-matrix(instance) = { + let n = instance.num_vars + let Q = range(n).map(_ => range(n).map(_ => 0)) + for (i, j, value) in instance.entries { + Q.at(i).at(j) = value + } + Q +} #{ let x = load-model-example("QUBO") let n = x.instance.num_vars - let Q = x.instance.matrix + let Q = qubo-matrix(x.instance) let sol = (config: x.optimal_config, metric: x.optimal_value) let xstar = sol.config let fstar = metric-value(sol.metric) @@ -5436,21 +5393,20 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| let basis = x.instance.basis let target = x.instance.target let sol = (config: x.optimal_config, metric: x.optimal_value) - let dist = metric-value(sol.metric) let coords = sol.config // Compute B*x: sum over j of coords[j] * basis[j] let dim = basis.at(0).len() let bx = range(dim).map(d => coords.enumerate().fold(0.0, (acc, (j, c)) => acc + c * basis.at(j).at(d))) // Format basis vectors let fmt-vec(v) = $paren.l #v.map(e => str(e)).join(", ") paren.r^top$ - let dist-rounded = calc.round(dist, digits: 3) + let distance-squared = range(dim).fold(0, (total, d) => total + calc.pow(bx.at(d) - target.at(d), 2)) [ #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 lattice basis $bold(B) in RR^(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^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 provides two coefficient variants: `i64` basis and target entries for exact reductions, and finite `f64` entries for numerical modeling. Both return integer coefficients. Integer evaluation uses checked integer squared distance; float evaluation uses ordinary floating-point squared distance. The integer solver uses exact rational Gram--Schmidt projections and sphere enumeration following Fincke and Pohst @fincke1985. The numerical float solver uses floating-point projections and reports a feasible candidate without claiming exact optimality. Both solvers are 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 #distance-squared. #pred-commands( "pred create --example ClosestVectorProblem -o closest-vector-problem.json", @@ -5485,7 +5441,7 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| content((rel: (-0.3, 0), to: "b2.mid"), text(7pt)[$bold(b)_2$]) content((rel: (0.45, 0.3), to: "p" + str(coords.at(0)) + str(coords.at(1))), text(7pt)[$bold(B)(#coords.map(c => str(c)).join(","))^top$]) }), - caption: [2D lattice with basis #range(basis.len()).map(j => $bold(b)_#(j + 1) = #fmt-vec(basis.at(j))$).join(", "). Target $bold(t) = #fmt-vec(target)$ (red) and closest lattice point $bold(B)(#coords.map(c => str(c)).join(","))^top = (#bx.map(v => str(int(v))).join(", "))^top$ (blue). Distance $approx #dist-rounded$.], + caption: [2D lattice with basis #range(basis.len()).map(j => $bold(b)_#(j + 1) = #fmt-vec(basis.at(j))$).join(", "). Target $bold(t) = #fmt-vec(target)$ (red) and closest lattice point $bold(B)(#coords.map(c => str(c)).join(","))^top = (#bx.map(v => str(int(v))).join(", "))^top$ (blue). Squared distance $#distance-squared$.], ) ] ] @@ -7604,7 +7560,7 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| $ min_(c, p_1, dots, p_n) max_(1 lt.eq i lt.eq n) d_H (c, s_i [p_i .. p_i + ell)), $ where $d_H$ is the Hamming distance and $s_i [p_i .. p_i + ell)$ is the length-$ell$ substring of $s_i$ starting at position $p_i$. ][ - Introduced by #cite(, form: "prose"), who showed that the decision version is NP-complete (even over the binary alphabet) and gave the first polynomial-time approximation scheme. Closest Substring strictly generalizes Closest String: the special case $ell = |s_i|$ for all $i$ forces a unique window in each string and recovers Closest String. The registered exact baseline enumerates every center in $Sigma^ell$ together with every tuple of window starts, giving $O(q^ell dot product_i (|s_i| - ell + 1))$ configurations. + Introduced by #cite(, form: "prose"), who showed that the decision version is NP-complete (even over the binary alphabet) and gave the first polynomial-time approximation scheme. Closest Substring strictly generalizes Closest String: the special case $ell = |s_i|$ for all $i$ forces a unique window in each string and recovers Closest String. The registered exact baseline enumerates every center in $Sigma^ell$ together with every tuple of window starts, giving $O(q^ell dot product_i (|s_i| - ell + 1))$ configurations. Writing $W = sum_i (|s_i| - ell + 1)$, AM–GM bounds this count by $q^ell (W/n)^n$; the registered complexity uses this bound without storing the product as an instance parameter. *Example.* Let $Sigma = {0, 1}$ ($q = #alphabet-size$), $ell = #ell$, and consider the $n = #n$ binary strings $s_1 = #fmt-str(strings.at(0))$, $s_2 = #fmt-str(strings.at(1))$, $s_3 = #fmt-str(strings.at(2))$. @@ -8097,7 +8053,7 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| #problem-def("KthLargestMTuple")[ Given $m$ finite sets $X_1, dots, X_m$ of positive integers, a bound $B in ZZ^+$, and a threshold $K in ZZ^+$, count the number of distinct $m$-tuples $(x_1, dots, x_m) in X_1 times dots.c times X_m$ satisfying $sum_(i=1)^m x_i >= B$. The answer is _yes_ iff this count is at least $K$. ][ - The $K$th Largest $m$-Tuple problem is MP10 in Garey and Johnson's appendix @garey1979. It is _not known to be in NP_, because a "yes" certificate may need to exhibit $K$ qualifying tuples and $K$ can be exponentially large. The problem is PP-complete under polynomial-time Turing reductions @haase2016, though the special case $m = 2$, $K = 1$ is NP-complete via reduction from Subset Sum. In the general case, the only known exact approach is brute-force enumeration of all $product_(i=1)^m |X_i|$ tuples, so the registered catalog complexity is `total_tuples * num_sets`#footnote[No algorithm improving on brute-force is known for the general $K$th Largest $m$-Tuple problem.]. + The $K$th Largest $m$-Tuple problem is MP10 in Garey and Johnson's appendix @garey1979. It is _not known to be in NP_, because a "yes" certificate may need to exhibit $K$ qualifying tuples and $K$ can be exponentially large. The problem is PP-complete under polynomial-time Turing reductions @haase2016, though the special case $m = 2$, $K = 1$ is NP-complete via reduction from Subset Sum. In the general case, the only known exact approach is brute-force enumeration of all $product_(i=1)^m |X_i|$ tuples, so AM–GM gives the registered catalog bound `(num_elements / num_sets)^num_sets * num_sets`#footnote[No algorithm improving on brute-force is known for the general $K$th Largest $m$-Tuple problem.]. *Example.* Let $m = #m$, $B = #bound$, and $K = #k$ with sets #sets.enumerate().map(((i, s)) => [$X_#(i+1) = {#fmt-values(s)}$]).join([, ]). The Cartesian product has $#total$ tuples. Exactly #k tuples have sum at least #bound, so the answer is _yes_ (count $= K$). The evaluator enumerates the Cartesian product internally and stops once it has found $K$ qualifying tuples. @@ -11532,14 +11488,21 @@ the displayed rule, extracted from the corresponding `pred path` entry. _Solution extraction._ For IS solution $S$, return $C = V backslash S$, i.e.\ flip each variable: $c_v = 1 - s_v$. ] +The decision reductions below construct `Decision`: the target contains an +optimization instance `inner` and a bound `bound`. Feasibility requires an inner +solution whose objective is at most the bound for minimization, or at least the +bound for maximization. A numeric objective discussed in a proof refers to +`inner`; the decision target itself returns YES or NO. The bound is serialized +with the target, rather than stored separately by solution extraction. + #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"), @@ -11553,28 +11516,28 @@ 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.inner)$ vertices and $#graph-num-edges(dmds_mmmc.target.instance.inner)$ 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 ], )[ - The radius-threshold relation between centers and dominating sets @hochbaumshmoys1985 is extended here to all signed source bounds using two mandatory isolated centers. This $O(n+m+1)$ construction preserves the existing endpoint variants. + The radius-threshold relation between centers and dominating sets @hochbaumshmoys1985 is extended here to all signed source bounds using two mandatory isolated centers. This $O(n+m+1)$ construction includes the radius bound in the target decision instance. ][ _Construction._ For source graph $G=(V,E)$ with $n$ vertices and integer bound $K$, set $q=max(-1,min(K,n))$. Add isolated vertices $a=n$ and $b=n+1$, leaving every original edge record unchanged. Give all vertices and edges unit weights and lengths, and require exactly $k=q+2$ centers. Then $1<=k<=n+2$ for every input, including an empty graph. _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._ 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 target bound is $1$. Its evaluation accepts exactly the placements of finite radius at most $1$; the aggregate map preserves this Boolean value. In particular, a four-vertex path with $K=1$ has inner optimum radius $2$; with target bound $1$, the decision target is infeasible. 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"), @@ -11588,7 +11551,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 ], @@ -11900,7 +11863,7 @@ the displayed rule, extracted from the corresponding `pred path` entry. )[ This reduction encodes vertex cover as a minimum-weight solution subgraph problem on a three-layer AND/OR DAG. The root AND gate requires all edges to be covered; each edge becomes an OR gate selecting which endpoint covers it; and each vertex becomes a sink whose arc weight equals the vertex weight. The minimum-weight solution subgraph selects exactly the arcs corresponding to a minimum vertex cover. ][ - _Construction._ Given a Minimum Vertex Cover instance $(G = (V, E), bold(w))$ with $n = |V|$ vertices and $m = |E|$ edges, build an AND/OR graph $D$ with $1 + m + 2n$ vertices arranged in three layers: + _Construction._ First let $N = {v in V : w_v < 0}$. Every optimum contains $N$: adding an omitted negative-weight vertex preserves coverage and strictly decreases cost. Remove all edges incident to $N$ and set their vertices' residual weights to zero. Keep the original vertex indices. Below, $E$ denotes these residual edges, $m = |E|$, and $w$ denotes the nonnegative residual weights; the original optimum equals the residual optimum plus $sum_(v in N) w_v$ using the original weights. Build an AND/OR graph $D$ with $1 + m + 2n$ vertices, where $n = |V|$: - *Root (AND gate):* A single vertex $r$ (index 0) with gate type AND. - *Edge layer (OR gates):* For each edge $e_i = {u, v}$ ($i = 0, dots, m-1$), create vertex $e_i$ (index $1 + i$) with gate type OR and an arc $(r, e_i)$ of weight 1. @@ -11909,9 +11872,9 @@ the displayed rule, extracted from the corresponding `pred path` entry. Since $r$ is AND, any solution subgraph must include all arcs from $r$ to the edge-layer vertices (cost $m$). Each edge-OR vertex $e_i$ requires at least one of its two outgoing arcs to $c_u$ and $c_v$ (selecting which endpoint covers edge $i$). Each activated cover vertex $c_j$ requires its outgoing arc to $s_j$ (contributing $w_j$). The total weight is $m + |{"activated cover arcs"}| + sum_(j in C) w_j$. - _Correctness._ ($arrow.r.double$) If $C subset.eq V$ is a vertex cover with weight $W$, then for each edge $e_i = {u, v}$, at least one endpoint lies in $C$; select the arc from $e_i$ to that endpoint's cover vertex. Activate all cover-to-sink arcs for vertices in $C$. This satisfies the AND gate at the root (all edge arcs selected), every edge OR gate (at least one child selected), and all activated cover vertices (sink arc selected). The total weight is $m + |{"edge-to-cover arcs"}| + W$. ($arrow.l.double$) In any valid solution subgraph, the AND root forces all $m$ edge arcs. Each edge OR vertex selects at least one arc to a cover vertex, activating that cover vertex and its sink arc. The set of activated cover vertices forms a vertex cover (every edge has at least one endpoint activated). The sink arc weights sum to the cover weight, so any minimum-weight solution subgraph corresponds to a minimum vertex cover. + _Correctness._ ($arrow.r.double$) Given a residual cover $C$, choose exactly one endpoint in $C$ for each edge. Activate only cover vertices reached by these choices, and their sink arcs. This is a valid solution of cost at most $2m + w(C)$, since omitted cover vertices have nonnegative weight. ($arrow.l.double$) Any valid target solution activates a residual cover $C$ and costs at least $2m + w(C)$: the root requires all $m$ arcs and each edge gate requires at least one unit-weight outgoing arc. These bounds imply that the target optimum is exactly $2m$ plus the residual cover optimum, and every target optimum recovers an optimal residual cover. Adding $N$ restores an original optimum, including negative vertices that are isolated or redundant for coverage. - _Solution extraction._ Examine the cover-to-sink arcs (indices $3m, dots, 3m + n - 1$ in the arc list): $c_j = 1$ if arc $(c_j, s_j)$ is selected, $c_j = 0$ otherwise. + _Solution extraction._ Select every vertex in $N$, together with vertices whose cover-to-sink arcs (indices $3m, dots, 3m + n - 1$) are selected. Evaluate this cover using the original weights. ] #reduction-rule("MaximumMatching", "MaximumSetPacking")[ @@ -11978,7 +11941,7 @@ 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 = qubo-matrix(cvp_qubo.target.instance) 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))) @@ -12035,9 +11998,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: [ @@ -12106,7 +12069,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 @@ -12225,17 +12188,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: [ @@ -12252,7 +12215,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 objective 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. ], @@ -12261,15 +12224,15 @@ where $P$ is a penalty weight large enough that any constraint violation costs m ][ _Construction._ Let $n$ be the number of items, and let $b >= 1$ be the maximum bit length of their nonnegative sizes and target $T$. Write $s_(i,j), t_j in {0,1}$ for bit $j$ of size $s_i$ and target $T$. Introduce integer coefficients $x_0, dots, x_(n-1)$ and carries $c_1, dots, c_(b-1)$, with fixed boundary values $c_0=c_b=0$. The displacement vector consists of $x_i$ for all items, then $x_i-1$ for all items, then residuals $ r_j = sum_(i=0)^(n-1) s_(i,j) x_i + c_j - 2 c_(j+1) - t_j $ - in descending bit order. These linear expressions define the integer basis columns and a target containing only zeros and ones. Carry columns are also ordered by descending bit index. The first $n$ coordinate rows form an identity on item columns; the remaining carry block has unit pivots in this order. Consequently the full-column-rank check has no exponentially growing pivots. + in descending bit order. These linear expressions define the integer basis columns and a target containing only zeros and ones. Carry columns are also ordered by descending bit index. The first $n$ coordinate rows form an identity on item columns; the remaining carry block has unit pivots in this order. This triangular block together with the item identity proves full column rank. _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 squared-distance optimum 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 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 model evaluates squared distances in exact rational arithmetic and the solver uses exact rational sphere-enumeration bounds; runtime limitations are separate from the mathematical equivalence. ] ] } @@ -12515,18 +12478,18 @@ 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. $ + $ D = (sum_(j,a) |c_(j,a)| + |g_x|)^2 + (sum_(j,a) |s_(j,a)| + |g_y|)^2, quad P = 2(1 + D). $ 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 implementation drops the full additive constant $C = g_x^2 + g_y^2 + n P$, including the one-hot constants. - _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. + _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$. Every feasible assignment has $H <= D$, whereas every violating assignment has $H >= P$. Thus a feasible source has only qualifying optima; if the source is infeasible, every target assignment has $H >= P$. Among the penalty-zero assignments, minimizing $H$ is exactly minimizing the source squared distance. - _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)`. + _Value recovery and extraction._ For target optimum $E$, compare $E$ to $3(1+D)/2 - C$, which lies strictly between the feasible and infeasible energy ranges. An optimum above this separator yields `Min(None)` for the source. Otherwise the source optimum is $E+C$, and each block has a unique active selector whose sample index is the source witness. Value recovery precedes extraction; extraction does not recheck one-hot or pair feasibility. Floating-point results follow the numerical contract; the scaled penalty leaves a gap proportional to the coefficient scale, and nonfinite construction arithmetic is an error. ] #let mwc_qubo = load-example("MinimumMultiwayCut", "QUBO") @@ -12564,23 +12527,22 @@ 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. + A multiway cut deletes edges to separate every terminal pair. For signed weights, every negative edge is deleted first; the remaining nonnegative problem is represented by a terminal-labelled partition. The penalty method (@sec:penalty-method) enforces one label per vertex and pins each terminal to its own label. Reference for the partition encoding: @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 integer weights $w: E -> ZZ$ and $k >= 2$ distinct terminals, let $w^+(e) = max(w(e), 0)$ and $C_- = sum_(e: w(e) < 0) w(e)$. Introduce $n k$ binary variables $x_(u,t)$, where label $t$ indicates the terminal group of vertex $u$. Let $alpha = 1 + sum_(e in E) w^+(e)$. - The QUBO Hamiltonian is $H = H_A + H_B$ where: + The Hamiltonian is $H = H_A + H_B$, with $ 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)) $ - The first term is a _one-hot constraint_ ensuring each vertex is assigned to exactly one component. The second term _pins_ each terminal $t_i$ to position $i$ by penalizing any other assignment. Expanding the one-hot term using $x^2 = x$: - $ Q_(u k+t, u k+t) = -alpha, quad Q_(u k+s, u k+t) = 2 alpha quad (s < t) $ - Terminal pinning adds $alpha$ to the diagonal $Q_(t_i k+s, t_i k+s)$ for $s != i$, canceling the one-hot incentive. + and + $ H_B = sum_((u,v) in E) sum_(s != t) w^+(u,v) x_(u,s) x_(v,t). $ + The implemented QUBO omits the constant $n alpha$ from $H_A$. + + _Correctness._ Deleting a negative edge strictly reduces cost and cannot reconnect terminals, so every source optimum deletes all negative edges. Both Hamiltonian terms are nonnegative for every binary assignment. A pinned one-hot assignment exists and has energy at most $sum_e w^+(e) < alpha$; any constraint violation costs at least $alpha$. Thus every target optimum is pinned and one-hot. - 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. + Given any feasible deletion set, label each remaining connected component by its terminal, choosing any label for components without a terminal. Every nonnegative edge crossing labels was already deleted. Conversely, deleting all negative edges and every edge crossing labels separates the terminals. These two directions show that the minimum partition cost plus $C_-$ is exactly the minimum source cost. Consequently every QUBO optimum recovers a source optimum, whose value is the QUBO optimum plus $n alpha + C_-$. All source instances are feasible because deleting all edges separates distinct terminals. - _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. + _Solution extraction._ Find the selected label of each vertex. Delete edge $(u,v)$ exactly when $w(u,v) < 0$ or its endpoint labels differ. Extraction assumes an optimal target witness; it does not check the one-hot constraints again. Integer coefficient overflow is reported by construction. - _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. ] #reduction-rule("GraphPartitioning", "QUBO")[ @@ -12702,9 +12664,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: [ @@ -12715,7 +12677,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 ], )[ @@ -12762,9 +12724,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: [ @@ -12775,7 +12737,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 ], )[ @@ -12877,9 +12839,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: [ @@ -12903,7 +12865,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. ], @@ -13017,9 +12979,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: [ @@ -13030,7 +12992,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 ], )[ @@ -14019,15 +13981,18 @@ 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 >= 3$, discard self-loops and retain one cheapest edge per endpoint pair; a Hamiltonian cycle on at least three vertices uses neither a loop nor two parallel edges. Let $b = min(0, min_e w(e))$, using $b=0$ if no edges remain. Define $c(e)=w(e)-b >= 0$ and $A=1+sum_e c(e)$. - _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$. + _Variables:_ Binary $x_(v,p)$ indicates that vertex $v$ occupies position $p$, with index $v n+p$. The Hamiltonian is + $ H = A sum_v (1-sum_p x_(v,p))^2 + A sum_p (1-sum_v x_(v,p))^2 + sum_(u= A-2n A$, return source infeasibility. Otherwise the source optimum is $E+2n A+n b$. Read the unique vertex at each position and select the stored cheapest edge between consecutive vertices. This maps every qualifying optimum; extraction does not certify permutation or edge feasibility again. Checked construction arithmetic reports coefficients that cannot be represented in the integer QUBO. + + _Small instances._ The source witness is a connected edge set with degree two at every vertex. For $n=1$ its optimum is the cheapest self-loop, if one exists. For $n=2$ it is the two cheapest parallel edges joining the vertices, if two exist. For $n=0$ the source has no cycle. These cases are solved during construction by selecting the one or two smallest relevant edge weights in linear time. The target is a zero objective on $n^2$ variables; every target optimum maps to the stored source optimum or infeasibility. This preserves the source model's accepted graph domain. - _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. ] #let lcs_mis = load-example("LongestCommonSubsequence", "MaximumIndependentSet") @@ -14397,7 +14362,7 @@ The following reductions to Integer Linear Programming are straightforward formu )[ This $O(n + sum_(S in cal(C)) |S|)$ reduction @garey1979[MS1] first normalizes each large subset to size 2 or 3 with complementarity pairs, then builds a Betweenness instance with one pole element $p$, one element $a_u$ per normalized universe element, and one auxiliary betweenness element for each size-3 subset. If the normalized Set Splitting instance has universe size $n'$ with $s_2$ size-2 subsets and $s_3$ size-3 subsets, the target has $n' + 1 + s_3$ elements and $s_2 + 2 s_3$ triples. ][ - _Construction._ Given Set Splitting instance $(U, cal(C))$, first normalize every subset to size 2 or 3. For a subset $S = {s_1, dots, s_k}$ with $k >= 4$, introduce fresh elements $y^+, y^-$, replace $S$ by the size-3 subset ${s_1, s_2, y^+}$ and the complementarity subset ${y^+, y^-}$, and continue recursively on ${y^-, s_3, dots, s_k}$. Repeating this step yields an equivalent normalized instance $(U', cal(C)')$ in which every subset has size 2 or 3. + _Construction._ Given Set Splitting instance $(U, cal(C))$, discard repeated occurrences within each subset: multiplicity does not affect its colors. A resulting singleton ${u}$ is unsplittable; encode it with a fresh element $d$ and the incompatible triples $(a_u,p,d)$ and $(p,a_u,d)$. Each singleton adds one element and two triples to the counts above. For each remaining subset $S = {s_1, dots, s_k}$ with $k >= 4$, introduce fresh elements $y^+, y^-$, replace $S$ by the size-3 subset ${s_1, s_2, y^+}$ and the complementarity subset ${y^+, y^-}$, and continue recursively on ${y^-, s_3, dots, s_k}$. Repeating this step yields equivalent size-2 and size-3 constraints. Create one Betweenness element $a_u$ for each $u in U'$ and one distinguished pole $p$. For every size-2 subset ${u, v} in cal(C)'$, add triple $(a_u, p, a_v)$. For every size-3 subset ${u, v, w} in cal(C)'$, introduce a fresh auxiliary element $d_(u,v,w)$ and add triples $(a_u, d_(u,v,w), a_v)$ and $(d_(u,v,w), p, a_w)$. @@ -14584,7 +14549,7 @@ The following reductions to Integer Linear Programming are straightforward formu *Step 4 -- Target optimum.* The fixture selects the matrix-domination set $C = {(0, 2), (1, 3)}$ at 1-entry indices #selected-target.map(i => str(i)).join(", "). Every other 1-entry of $M$ shares row $0$ or row $1$ with one of the chosen entries, so $C$ is dominating with $|C| = #selected-target.len() = 2 = "mm"(B) #sym.checkmark$. - *Extraction.* For this fixture the selected 1-entries happen to be pairwise independent (no shared row or column), so they already correspond to a matching of $B$. In general a matrix-domination witness only maps back to an edge dominating set $F_C subset.eq F$ that need not be a matching: for instance, the optimal witness $C = {(0, 3), (0, 4)}$ corresponds to source edges ${(l_0, r_1), (l_0, r_2)}$, which share endpoint $l_0$. In such cases the polynomial-time Yannakakis-Gavril transformation @yannakakis1980 -- implemented in `extract_solution` as a sequence of drop / swap moves on $F_C$ -- converts $F_C$ into a maximal matching of $B$ of the same or smaller size, here e.g. ${(l_0, r_0), (l_1, r_1)}$ or ${(l_0, r_1), (l_1, r_2)}$. + *Extraction.* For this fixture the selected 1-entries happen to be pairwise independent (no shared row or column), so they already correspond to a matching of $B$. In general a matrix-domination witness only maps back to an edge dominating set $F_C subset.eq F$ that need not be a matching: for instance, the optimal witness $C = {(0, 3), (0, 4)}$ corresponds to source edges ${(l_0, r_1), (l_0, r_2)}$, which share endpoint $l_0$. In such cases the polynomial-time Yannakakis-Gavril transformation @yannakakis1980 -- implemented in `recover_result` as a sequence of drop / swap moves on $F_C$ -- converts $F_C$ into a maximal matching of $B$ of the same or smaller size, here e.g. ${(l_0, r_0), (l_1, r_1)}$ or ${(l_0, r_1), (l_1, r_2)}$. ] } ], @@ -14610,7 +14575,7 @@ The following reductions to Integer Linear Programming are straightforward formu ($arrow.l.double$) A dominating set $C$ of $M$ with $|C| <= K'$ corresponds to an EDS $F_C$ of size $|C| <= K' = K$. Applying the polynomial-time Yannakakis-Gavril transformation to $F_C$ yields a maximal matching of $B$ of the same size, so $"mm"(B) <= K$. - _Solution extraction._ Read the selected 1-entries of the matrix-domination witness, map each $(i, m + j)$ back to the bipartite edge $(l_i, r_j)$ to obtain an EDS $F_C$ of $B$. Arbitrary optimal MMD witnesses may select 1-entries whose corresponding source edges form a connected subgraph (e.g. two edges sharing a left endpoint) rather than a matching; in that case the polynomial-time Yannakakis-Gavril EDS-to-IEDS transformation iteratively resolves each adjacent pair in $F_C$ by either dropping a redundant edge or swapping it for an edge whose new endpoint lies outside the current vertex cover, terminating in $O(|F|^3)$ time. The result is a maximal matching $M^*$ of $B$ with $|M^*| <= |F_C|$, which `extract_solution` returns as the source-side configuration. + _Solution extraction._ Read the selected 1-entries of the matrix-domination witness, map each $(i, m + j)$ back to the bipartite edge $(l_i, r_j)$ to obtain an EDS $F_C$ of $B$. Arbitrary optimal MMD witnesses may select 1-entries whose corresponding source edges form a connected subgraph (e.g. two edges sharing a left endpoint) rather than a matching; in that case the polynomial-time Yannakakis-Gavril EDS-to-IEDS transformation iteratively resolves each adjacent pair in $F_C$ by either dropping a redundant edge or swapping it for an edge whose new endpoint lies outside the current vertex cover, terminating in $O(|F|^3)$ time. The result is a maximal matching $M^*$ of $B$ with $|M^*| <= |F_C|$, which `recover_result` returns as the source-side configuration. _Note on source variant._ The reduction crucially requires the source graph to be bipartite. The biadjacency matrix faithfully represents the edge structure of $B$ (each edge contributes exactly one 1-entry). The adjacency matrix of a general undirected graph would produce two symmetric 1-entries per edge that do not preserve the row/column sharing pattern. ] @@ -15233,24 +15198,6 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ For each position $p$, return the unique $i$ with $x_(i,p)=1$, using the existing one-hot decoder. There are $m^2+m^3$ binary variables and $2m+3m^3+m r$ constraints, where $r$ is the number of unreachable ordered required-arc pairs; hence at most $2m+4m^3$ constraints. ] -#reduction-rule("SteinerTreeInGraphs", "ILP")[ - Select edges and certify terminal connectivity by sending one unit of flow from a root terminal to every other terminal through the selected subgraph. -][ - _Construction._ Fix a root terminal $r in R$. Variables: binary $y_(u,v)$ for each undirected edge $\{u,v\}$ and nonnegative flow variables $f^t_(u,v)$ on each directed edge orientation for every terminal $t in R backslash {r}$. The ILP is: - $ - min quad & sum_({u,v} in E) w_(u,v) y_(u,v) \ - "subject to" quad & sum_(u) f^t_(u,v) - sum_(w) f^t_(v,w) = b_(t,v) quad forall t in R backslash {r}, v in V \ - & f^t_(u,v) <= y_(u,v) quad forall {u, v} in E, t in R backslash {r} \ - & f^t_(v,u) <= y_(u,v) quad forall {u, v} in E, t in R backslash {r} \ - & y_(u,v) in {0, 1}, f^t_(u,v) in ZZ_(>=0), - $ - where $b_(t,v) = -1$ if $v = r$, $b_(t,v) = 1$ if $v = t$, and $b_(t,v) = 0$ otherwise. - - _Correctness._ ($arrow.r.double$) A Steiner tree supports a unit flow from the root to every other terminal using exactly its selected edges, with the same total weight. ($arrow.l.double$) Any feasible ILP solution selects a connected subgraph spanning all terminals, and with nonnegative edge weights an optimum solution is a minimum-weight Steiner tree. - - _Solution extraction._ Output the binary edge-selection vector $(y_e)_(e in E)$. -] - // Scheduling #reduction-rule("FlowShopScheduling", "ILP")[ @@ -15577,14 +15524,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: [ @@ -16599,6 +16546,16 @@ Problems parameterized by graph type, weight type, target type, or clause width _Solution extraction._ Return the target configuration unchanged. ] +#reduction-rule("DecisionMinimumVertexCover", "DecisionMinimumVertexCover")[ + A unit-weight Decision Minimum Vertex Cover instance converts to the integer-weight variant by mapping every unit weight to $1_ZZ$ and keeping the graph and the bound. +][ + _Construction._ Given $(G, k)$ with unit weights, construct the integer-weight instance $(G, w, k)$ with $w(v) = 1$ for every vertex $v$. + + _Correctness._ For every vertex set $C$, $sum_(v in C) w(v) = |C|$, so $C$ is a vertex cover of cost at most $k$ in the target if and only if it is a vertex cover of size at most $k$ in the source. The size map is the exact identity. + + _Solution extraction._ Return the target configuration unchanged. +] + #reduction-rule("KSatisfiability", "KSatisfiability")[ A $k$-SAT instance with fixed clause width ($k = 2$ or $k = 3$) converts to generic $k$-SAT by constructing the registered $K_N$ variant. The clauses and variables are preserved verbatim; the target uses `new_allow_less` to accept clauses with fewer than $k$ literals. ][ @@ -16609,12 +16566,14 @@ Problems parameterized by graph type, weight type, target type, or clause width _Solution extraction._ Return the target configuration unchanged. ] +The numerical variant embeddings below preserve individual stored coefficients or coordinates. Their algebraic identities describe the formal objectives. Floating-point model evaluation still follows finite `f64` arithmetic and can round intermediate expressions; a lossless scalar embedding does not certify backend optimality. CVP instead evaluates its stored coordinates with exact rational squared distances. + #reduction-rule("SpinGlass", "SpinGlass")[ An Ising spin-glass instance with integer couplings and fields ($J_(i j), h_i in ZZ$) converts to the floating-point variant ($J_(i j), h_i in RR$) through exact `i64_to_exact_f64` embeddings. The graph topology is preserved. ][ _Construction._ Given $"SpinGlass"(G, bold(J), bold(h))$ with $J_(i j) in ZZ$ and $h_i in ZZ$, construct $"SpinGlass"(G, bold(J)', bold(h)')$ with $J'_(i j) = J_(i j) in RR$ and $h'_i = h_i in RR$. - _Correctness._ The spin-glass Hamiltonian $H(bold(s)) = sum_((i,j) in E) J_(i j) s_i s_j + sum_i h_i s_i$ is preserved exactly under the integer-to-float embedding (no rounding). Spin configurations and the objective value are unchanged. + _Correctness._ The spin-glass Hamiltonian $H(bold(s)) = sum_((i,j) in E) J_(i j) s_i s_j + sum_i h_i s_i$ is the same formal Hamiltonian under the coefficient embedding. Spin configurations are unchanged. _Solution extraction._ Return the target configuration unchanged. ] @@ -16631,20 +16590,10 @@ 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`. ][ - _Construction._ Given $Q in ZZ^(n times n)$, construct $Q' in RR^(n times n)$ with $Q'_(i j) = "f64"(Q_(i j))$ for every exactly representable coefficient $|Q_(i j)| lt.eq 2^53 - 1$. + _Construction._ Given $Q in ZZ^(n times n)$, construct $Q' in RR^(n times n)$ with $Q'_(i j) = "f64"(Q_(i j))$ when every matrix coefficient satisfies $abs(Q_(i j)) <= 2^53 - 1$, the supported conversion range. _Correctness._ For every binary vector $bold(x)$, exact coefficient conversion gives $bold(x)^top Q' bold(x) = bold(x)^top Q bold(x)$. The objective ordering and minimizers are preserved. @@ -16707,22 +16656,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"), @@ -16907,11 +16856,11 @@ The following table shows concrete target-variable counts for example instances, #reduction-rule("ILP", "ILP")[ ILP variants convert between binary and bounded integer variable domains and between exact-integer and floating-point coefficients. Binary variables embed directly into integer variables. A finitely bounded integer variable is encoded by binary variables with truncated positional weights. Integer coefficients are embedded only when every stored coefficient and right-hand side has an exact `f64` representation. ][ - _Construction._ For the binary-to-integer edge, copy the variables, constraints, objective, and optimization direction unchanged. For an integer variable $x_i in [L_i, U_i]$, let $D_i = U_i - L_i$ and choose positive truncated binary weights $w_(i j)$ whose subset sums represent every integer from $0$ through $D_i$; substitute $x_i = L_i + sum_j w_(i j)y_(i j)$ into every constraint and objective term. This edge rejects variables without two finite bounds. For the coefficient edge, copy the variable bounds and optimization direction and convert each entry of the constraint matrix, right-hand side, and objective independently; reject the instance if any integer lies outside the exactly representable `f64` integer range. + _Construction._ For the binary-to-integer edge, copy the variables, constraints, objective, and optimization direction unchanged. For an integer variable $x_i in [L_i, U_i]$, let $D_i = U_i - L_i$ and choose positive truncated binary weights $w_(i j)$ whose subset sums represent every integer from $0$ through $D_i$; substitute $x_i = L_i + sum_j w_(i j)y_(i j)$ into every constraint and objective term. This edge rejects variables without two finite bounds. For the coefficient edge, copy the variable bounds and optimization direction and convert each entry of the constraint matrix, right-hand side, and objective independently; reject the instance if any converted integer is outside the supported range $[-(2^53 - 1), 2^53 - 1]$. _Correctness._ The binary-to-integer embedding changes no mathematical expression. For bounded integer variables, every $x_i in [L_i,U_i]$ has a truncated binary representation, and every binary assignment decodes inside that interval; substitution preserves all constraints and objective values. Exact conversion preserves every stored coefficient, so it constructs the same formal linear objective and constraints over the same integer variables. - _Solution extraction._ Binary-to-integer and coefficient conversions preserve the assignment; coefficient conversion additionally checks the assignment against the source integer ILP. Binary encoding returns $x_i = L_i + sum_j w_(i j)y_(i j)$. + _Solution extraction._ Binary-to-integer and coefficient conversions preserve the assignment after the standard target-solution validation. Numerical solver accuracy is independent of the mathematical coefficient conversion. Binary encoding returns $x_i = L_i + sum_j w_(i j)y_(i j)$. ] #let hc_hp = load-example("HamiltonianCircuit", "HamiltonianPath") @@ -17263,54 +17212,13 @@ The following table shows concrete target-variable counts for example instances, *Multiplicity:* The fixture stores one canonical Hamiltonian circuit. Rotating or reversing that same cycle yields equivalent target witnesses with the same extracted cover. ], )[ - 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$. + 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. The registered source uses the `One` weight variant of Decision Minimum Vertex Cover. 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 $L$ be the vertices with self-loops. Every cover contains $L$. Delete their incident edges, deduplicate remaining edges, and reduce the budget by $|L|$; keep original vertex indices. A cover of this residual loopless graph lifts by adjoining $L$, and every original cover restricts to a residual cover. A negative residual budget or zero budget with remaining edges gives a fixed NO path on three vertices. A budget covering all non-isolated residual vertices gives a fixed YES triangle and a stored cover. Below, $(G=(V,E),k)$ denotes the residual instance with $0 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. -] - -#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. + _Solution extraction._ Given a Hamiltonian circuit witness, inspect the two endpoints of each residual vertex-path. Select vertices whose two endpoints touch selectors, and add all loop-forced vertices $L$. For a fixed YES instance, return the stored cover. The resulting indicator vector is a valid source-side vertex cover. ] #let ksat_mono = load-example("KSatisfiability", "MonochromaticTriangle") @@ -17683,7 +17591,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 every filler layer is nonempty; its size is $p-c_t$. *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$. @@ -17694,14 +17602,14 @@ 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. ][ - _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$. + _Construction._ An empty conjunction maps to one unit job with threshold 1; a formula containing an empty clause maps to that job with threshold 0. Otherwise repeat literals cyclically in each short clause until it has three positions, preserving its disjunction. Let $phi$ be the resulting 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 >= 1$ filler jobs and impose complete-bipartite precedences from every filler at slot $t$ to every filler at slot $t+1$. Every filler belongs to a chain through all $T$ nonempty layers. Thus, in any schedule finishing by $T$, layer $t$ must occupy slot $t$. The total work is exactly $p T$, so every slot is saturated and precisely $c_t$ positions remain for original jobs. This is the nonempty-layer invariant of Ullman's Lemma 1. _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._ An optimal makespan greater than $T$ proves source infeasibility; a merely feasible schedule above $T$ is insufficient. For a schedule within $T$, set $x_i = 1$ iff starter job $x_(i,0)$ is in slot 0. The capacity proof guarantees a satisfying assignment; a failed extraction is an error, not a proof of infeasibility. The constant YES case returns any assignment. ] #let ksat_td = load-example("KSatisfiability", "TimetableDesign") @@ -17849,13 +17757,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: [ @@ -17868,7 +17776,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\ @@ -17888,10 +17796,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: [ @@ -17904,9 +17812,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. ], @@ -17931,7 +17839,8 @@ The following table shows concrete target-variable counts for example instances, "pred create --example DecisionMaximumIndependentSet/One -o independent-set.json", "pred reduce independent-set.json --via route.json -o bundle.json", "pred solve bundle.json", - "pred extract bundle.json --config " + cli-config(mis_ifb_sol.target_config), + "echo '" + json.encode((status: "feasible", solution: mis_ifb_sol.target_config), pretty: false) + "' > result.json", + "pred extract bundle.json --result result.json", ) Source bound: #mis_ifb.source.instance.bound; selected vertices: #fmt-values(mis_ifb_sol.source_config) \ Target: #mis_ifb.target.instance.graph.num_vertices vertices, #mis_ifb.target.instance.graph.arcs.len() arcs, #mis_ifb.target.instance.bundles.len() bundles; requirement #mis_ifb.target.instance.requirement \ @@ -17949,9 +17858,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: [ @@ -17964,7 +17873,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 @@ -18427,9 +18336,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: [ @@ -18442,7 +18351,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. @@ -18638,9 +18547,9 @@ 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$. + The ternary gadget reduction @schaefer1978 @garey1979[GT16] builds 4-vertex variable gadgets, 2-vertex signal pairs, 4-vertex $K_4$ clause gadgets, and 2-vertex equality-chain links. After clause normalization, let $n'$ and $m'$ count variables and clauses. The target has $4n' + 16m'$ vertices, $3n' + 21m'$ edges, and $K = 2$. Normalization and construction take linear time in the original variables and 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._ For every long clause, repeatedly use $"NAE"(a,b,R) arrow.l.r.double exists z: "NAE"(a,b,z) and "NAE"(not z,R)$ with a fresh variable $z$. If $a=b$, the first constraint forces $z=not a$, and the second is satisfiable exactly when some value in $R$ differs from $a$. If $a!=b$, choose $z$ equal to any value in $R$; both constraints hold. Conversely, an all-equal original clause makes the two constraints incompatible. A length-$k$ clause adds $k-3$ variables and becomes $k-2$ triples. Let $phi$ denote the resulting formula on $n$ variables with clauses of size 2 or 3. 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}$. 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)$. @@ -18648,7 +18557,7 @@ The following table shows concrete target-variable counts for example instances, ($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 only the original variable gadgets: set $alpha(x_i) = 1$ iff $t_i$ lies in group 0. Discard auxiliary variables. ] // 7. ExactCoverBy3Sets → SubsetProduct (#388) @@ -18847,9 +18756,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: [ @@ -18861,9 +18770,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() [ @@ -18904,11 +18813,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", @@ -18919,7 +18828,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() [ @@ -18958,9 +18867,9 @@ The following table shows concrete target-variable counts for example instances, _Aggregation and extraction._ Map a finite optimum equal to $D$ to true and all other values to false. Validate a target configuration once, apply this same certificate, then identify the middle machine and select its element jobs completing by $Q$. Reject invalid schedules and feasible schedules that do not attain the certificate. The existing checked target constructor validates its total horizon $3(S+Q)$ before computing $D$, so the smaller nonnegative certificate is representable. Target construction failures retain their formal error type. ] // 13. NAESatisfiability → MaxCut (#166) -#let nae_mc = load-example("NAESatisfiability", "MaxCut") +#let nae_mc = load-example("NAESatisfiability", "DecisionMaxCut") #let nae_mc_sol = nae_mc.solutions.at(0) -#reduction-rule("NAESatisfiability", "MaxCut", +#reduction-rule("NAESatisfiability", "DecisionMaxCut", example: true, example-caption: [$n = #nae_mc.source.instance.num_vars$ variables, $m = #sat-num-clauses(nae_mc.source.instance)$ clauses, $M = #(sat-num-clauses(nae_mc.source.instance) + 1)$], extra: [ @@ -18975,12 +18884,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. ] @@ -19053,7 +18962,7 @@ The following table shows concrete target-variable counts for example instances, )[ This $O(t^2)$ reduction @garey1979 first checks whether every coordinate of $W$, $X$, and $Y$ appears in some triple; uncovered coordinates yield a fixed infeasible 3-Partition instance. Otherwise it composes the classical 3DM $arrow.r$ ABCD-Partition, ABCD-Partition $arrow.r$ 4-Partition, and 4-Partition $arrow.r$ 3-Partition constructions, producing $24 t^2 - 3 t$ integers arranged into $8 t^2 - t$ triples. ][ - _Construction._ Let the source instance have universe size $q$ and triples $m_l = (w_(a_l), x_(b_l), y_(c_l))$ for $l = 0, dots, t - 1$. If some coordinate of $W union X union Y$ is absent from all triples, the source instance is trivially NO, so the implementation returns a fixed infeasible 3-Partition instance with sizes $(6, 6, 6, 6, 7, 9)$ and bound $20$. + _Construction._ Let the source instance have universe size $q$ and triples $m_l = (w_(a_l), x_(b_l), y_(c_l))$ for $l = 0, dots, t - 1$. If $q=0$, the empty matching is a solution: return sizes $(1,1,1)$ with bound $3$, and recover the empty matching. Otherwise, if some coordinate of $W union X union Y$ is absent from all triples (including $t=0$), return the fixed infeasible instance $(6,6,6,6,7,9)$ with bound $20$. Including these constant cases, $24t^2-3t+6$ elements and $8t^2-t+2$ groups are upper bounds, not exact counts. Otherwise set $r = 32 q$ and $T_1 = 40 r^4$. For each triple create $ u_l = 10 r^4 - c_l r^3 - b_l r^2 - a_l r, $ @@ -19075,9 +18984,9 @@ The following table shows concrete target-variable counts for example instances, ($arrow.r.double$) Given a perfect matching $M'$, form one ABCD group for every source triple. If $m_l in M'$, combine $u_l$ with the unique first-occurrence $B$, $C$, and $D$ items of coordinates $(a_l, b_l, c_l)$; otherwise combine $u_l$ with the corresponding later-occurrence dummy items. Because $r = 32 q$ prevents carries between the $r$, $r^2$, $r^3$, and $r^4$ digits, every such group sums to $T_1$, so the tagged instance has a 4-partition. For each tagged 4-set choose any two members $a_i, a_j$ and let the other two be $a_k, a_l$. Then ${w_i, w_j, u_(i j)}$ and ${w_k, w_l, u'_(i j)}$ both sum to $B$. Every pairing gadget not used this way joins one filler in a triple ${u_(i j), u'_(i j), 20 T_2}$. Hence the produced 3-Partition instance is feasible. - ($arrow.l.double$) In any feasible target solution every number lies strictly between $B / 4$ and $B / 2$, so the partition really is into triples. Modulo 4, regular numbers are congruent to 1, pairing numbers to 2, and fillers to 0. Therefore every triple is either of type $(1, 1, 2)$ or $(0, 2, 2)$. The $(0, 2, 2)$ triples identify the unused pairing gadgets, leaving a family of $(1, 1, 2)$ triples that reconstructs a 4-partition of the tagged numbers. Since $1 + 2 + 4 + 8 equiv 15 mod 16$, every recovered tagged 4-set contains exactly one former $A$-, $B$-, $C$-, and $D$-item. The carry-free base-$r$ encoding then forces each ABCD group to be either a real group (all first occurrences) or a dummy group (all later occurrences). The real groups pick exactly $q$ source triples, one for each coordinate of $W$, $X$, and $Y$, so they form a perfect 3-dimensional matching. + ($arrow.l.double$) In any feasible target solution every number lies strictly between $B / 4$ and $B / 2$, so the partition really is into triples. Modulo 4, regular numbers are congruent to 1, pairing numbers to 2, and fillers to 0. Therefore every triple is either of type $(1, 1, 2)$ or $(0, 2, 2)$. First normalize the $(0, 2, 2)$ triples as in @garey1979: if a filler shares a triple with pairing elements $p, q$, exchange $q$ with the original mate of $p$. Both have the same size, since every original pair sums to $44 T_2 + 4 = B - 20 T_2$, so both affected triples remain valid. Each exchange fixes a filler triple without disturbing a previously fixed one. After normalization, every remaining original pair occurs in two $(1, 1, 2)$ triples. Their four actual regular elements sum to $2 B - (44 T_2 + 4) = 84 T_2 + 4$, so the corresponding tagged numbers sum to $T_2$. These disjoint four-sets reconstruct a 4-partition. Since $1 + 2 + 4 + 8 equiv 15 mod 16$, every recovered tagged 4-set contains exactly one former $A$-, $B$-, $C$-, and $D$-item. The carry-free base-$r$ encoding then forces each ABCD group to be either a real group (all first occurrences) or a dummy group (all later occurrences). The real groups pick exactly $q$ source triples, one for each coordinate of $W$, $X$, and $Y$, so they form a perfect 3-dimensional matching. - _Solution extraction._ Reverse the 4-Partition $arrow.r$ 3-Partition gadget by pairing each triple containing some $u_(i j)$ with the unique triple containing the matching $u'_(i j)$. This recovers the tagged 4-set. Undo the mod-16 tags to obtain one ABCD group, discard every dummy group whose $B$, $C$, and $D$ items are not first occurrences, and read the selected source triple from the surviving $A$-item. + _Solution extraction._ Normalize filler triples by the equal-size exchanges above, maintaining each element's current group and position. Then pair the remaining triples containing original mates $u_(i j), u'_(i j)$ and collect their four actual regular elements; their indices need not equal the indices used to construct the pairing gadget. The normalization and pairing take linear time in the target element count. Undo the mod-16 tags to obtain one ABCD group, discard every dummy group whose $B$, $C$, and $D$ items are not first occurrences, and read the selected source triple from the surviving $A$-item. ] #let tdm_ilp = load-example("ThreeDimensionalMatching", "ILP") @@ -19450,9 +19359,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: [ @@ -19478,7 +19387,7 @@ The following table shows concrete target-variable counts for example instances, _Correctness._ ($arrow.r.double$) A Hamiltonian $s$-$t$ path has $n - 1$ edges of length 1 each, giving total length $n - 1 = K$. ($arrow.l.double$) A simple $s'$-$t'$ path of length $>= K = n - 1$ has $>= n - 1$ edges. Since a simple path on $n$ vertices can have at most $n - 1$ edges, it has exactly $n - 1$ edges and visits all vertices -- it is a Hamiltonian $s$-$t$ path. - _Solution extraction._ Evaluate the target configuration once and apply the aggregate predicate: the value must be finite and equal to $n-1$. Reject infeasible selections and shorter paths before traversing any edges. The target feasibility check guarantees a single connected simple path from $s$ to $t$; its $n-1$ edges visit all $n$ vertices. Start at $s$ and repeatedly take the neighbor other than the preceding vertex. This terminates at $t$ and yields the source permutation without repetitions. The same argument applies to every supplied target configuration, independently of any caller claim of optimality. An absent target maximum or a proven maximum below $n-1$ certifies a NO source instance. The source requires distinct valid endpoints, so $n>=2$; no empty-graph or equal-endpoint convention is introduced. The target value resolves to `Max` because unit weight `One` has sum type `i64`. Checked evaluation errors are propagated, and the threshold comparison uses exact integer conversion. + _Solution extraction._ Evaluate the target configuration once and apply the aggregate predicate: the value must be finite and equal to $n-1$. Reject infeasible selections and shorter paths before traversing any edges. The target feasibility check guarantees a single connected simple path from $s$ to $t$; its $n-1$ edges visit all $n$ vertices. Start at $s$ and repeatedly take the neighbor other than the preceding vertex. This terminates at $t$ and yields the source permutation without repetitions. The same argument applies to every supplied target configuration, independently of any caller claim of optimality. An absent target maximum or a proven maximum below $n-1$ certifies a NO source instance. The source requires distinct valid endpoints, so $n>=2$; no empty-graph or equal-endpoint convention is introduced. The inner value is `Max` because unit weight `One` has sum type `i64`; the decision target evaluates to `Or` with bound $n-1$. Checked evaluation errors are propagated, and the threshold comparison uses exact integer conversion. ] // 18. GraphPartitioning → MaxCut (from main codebase) @@ -19556,36 +19465,22 @@ 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 }. $ + _Construction._ Given a PCSF instance with graph $G=(V,E)$, nonnegative edge costs $c$, nonnegative prizes $p$, and $beta, omega >= 0$, let $V_p={v in V:p(v)>0}$, $k=|V_p|$, and $M=omega+1$. Add an artificial root $r$ and one auxiliary terminal $t_v$ for each $v in V_p$. Keep every original edge with its cost, add $(r,v)$ of cost $omega$ for every original vertex, and add $(v,t_v)$ of cost $M$ and $(r,t_v)$ of cost $M+beta p(v)$. The terminal set is ${r} union {t_v:v in V_p}$. - 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: + _Forward bound._ Given any source forest $F$, attach each component once to $r$. For each prized vertex, choose its include edge if selected and its omit edge otherwise. The auxiliary terminals are leaves; the result is a tree spanning all terminals, with cost $f(F)+k M$. Thus $"OPT"_T <= "OPT"_F+k M$. - - 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)$. + _Reverse bound._ In an optimal target tree, every auxiliary terminal is a leaf. If both edges at $t_v$ were selected, delete $(r,t_v)$ and add $(r,v)$. The latter edge cannot already be selected, since those three edges would form a cycle. The replacement reconnects the two components created by deletion and decreases cost by $M+beta p(v)-omega >= 1$, a contradiction. - _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 + Extract original selected edges, their endpoints, and each prized vertex whose include edge is selected. This is a feasible source forest. After deleting the auxiliary leaves, each original component has exactly one root attachment, by connectivity and acyclicity. Extraction may discard isolated zero-prize vertices, which cannot increase cost because $omega>=0$. Every omitted positive prize has its omit edge selected; omit edges at vertices retained by original edges only add nonnegative target cost. Therefore $f(F) <= "cost"(T)-k M$. Together with the forward bound, this proves $"OPT"_T="OPT"_F+k M$ and optimality of every extracted optimal target witness. - $ sum_(e in E_F) c(e) + omega dot kappa(F) + beta dot sum_(v in.not V_F) p(v) + 0 = f'(F). $ + _Witness extraction._ Return + $ E_F=T inter E(G), quad V_F={v:(v,t_v) in T} union {"endpoints of edges in" E_F}. $ + No source optimization is performed during extraction. - ($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. + _Overhead._ The exact counts remain $|V_H|=n+k+1$, $|E_H|=m+n+2k$, and $|T_H|=k+1$. Coefficients are computed with checked integer arithmetic in the native representation. - _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. + _Boundary cases._ With no positive prizes, only $r$ is a terminal and its zero-edge tree maps to the empty forest. The proof also covers $beta=0$ and $omega=0$, including ties. For two adjacent vertices with edge cost zero, prizes $(1,2)$, $beta=1$, and $omega=5$, the source optimum is the empty forest of cost $3$; the target optimum selects both omit edges and costs $3+2 dot 6=15$. - _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. ] #pagebreak() diff --git a/docs/src/cli-commands.md b/docs/src/cli-commands.md index 97eb52217..7134eda0a 100644 --- a/docs/src/cli-commands.md +++ b/docs/src/cli-commands.md @@ -89,14 +89,33 @@ For a problem file, JSON inspection includes `parameter_values`, the model's act ## Reduce +`pred path` searches only executable reductions. The graph also records theoretical +Turing relations requiring multiple queries; these are not executable paths. + ```bash pred path MIS QUBO --json -o paths.json python3 -c 'import json; print(json.dumps(json.load(open("paths.json"))["paths"][0]))' > path.json pred reduce problem.json --via path.json -o reduced.json -pred extract reduced.json --config '[1,0,1,0]' +pred extract reduced.json --result target-result.json +``` + +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` recovers the complete source result from an external target result: + +```json +{"status":"optimal","solution":[true,false,true,false],"evaluation":"Min(-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 example shape assumes a Boolean target solution; use the actual target's +solution representation. Use `feasible` when optimality is not established, or +`{"status":"infeasible"}` when the target solver proves infeasibility. +The command checks the target witness and computes its evaluation. `evaluation` +may be omitted; if provided, it must be a correctly formatted string matching the +computed value. Integer and Boolean values are compared exactly. Floating-point +values match when `abs(reported - computed) <= 1e-9 * max(1, abs(reported), abs(computed))`. +Malformed or mismatched values (including `null`) are errors. An `infeasible` +result must not include `evaluation`. Results with a solution always include the +model-computed evaluation in the output. This check does not prove optimality. Insufficient +candidate quality is an error, not a source NO answer. ## Solve diff --git a/docs/src/design.md b/docs/src/design.md index 54ed4f47f..ce6bd43ac 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -21,13 +21,13 @@ choosing numeric fields or implementing arithmetic in a model or reduction. ## Problem Model -Every problem implements `Problem`. The associated `Value` type is the per-configuration aggregate returned by `evaluate()`. Solvers fold these values across the configuration space, and witness-capable aggregates can also recover representative configurations. +Every problem implements `Problem`. The associated `Value` type is the per-configuration aggregate returned by `evaluate()`. The brute-force solver folds these values across the configuration space and uses its `SolutionAggregate` capability to select corresponding witnesses. Specialized solvers and ILP backends return their solutions directly; model evaluation does not require that selection capability. ```rust,ignore trait Problem: Clone { const NAME: &'static str; // e.g., "MaximumIndependentSet" type Solution; // e.g., Vec, permutation, tuple - type Value: Clone; // e.g., Max, Or, Sum + type Value: Clone; // e.g., Max, Or fn parameter_names() -> &'static [&'static str]; fn parameters(&self) -> ProblemParameters; fn evaluate(&self, solution: &Self::Solution) -> Result; @@ -37,11 +37,12 @@ trait Problem: Clone { ``` - **`Problem`** — the base trait. Every problem declares a mathematical `Solution` type, evaluates that type directly, and reports its canonical instance parameters. For example, a 4-vertex MIS uses `Vec`; `evaluate(&[true, false, true, false])` returns `Ok(Max(Some(2)))` if vertices 0 and 2 form an independent set, or `Ok(Max(None))` if they share an edge. Inherent getters such as `num_vertices()` and `num_edges()` supply the named parameters used by reduction expressions. -- **`BruteForceProblem`** — the reference-solver capability for registered variants with a finite Cartesian coordinate space. Its `dimensions()` method and the Cartesian iterator belong to the brute-force solver, not to the mathematical `Problem` contract. +- **`EvaluationValue`** — requires `Clone` and `is_valid()`, expressing whether one candidate satisfies the model constraints. `Min`, `Max`, `Or`, and `Extremum` implement it. Custom evaluation types implement this check without needing aggregation or solver capabilities. An invalid candidate does not establish that the problem is infeasible. `Problem::Value` does not require it. It is required where a candidate is labeled feasible: `SolveOutcome::optimal` and `feasible`, the source and target of a `ReductionResult`, `SolutionAggregate`, `OptimizationValue`, and `declare_variants!` registration. +- **`BruteForceProblem`** — the reference-solver capability for registered variants with a finite Cartesian coordinate space. Its fallible `num_variables()` and `dimension(variable)` methods describe coordinates without allocating their vector. These methods and the Cartesian iterator belong to the brute-force solver, not to the mathematical `Problem` contract. - **Objective problems** — typically use `Max`, `Min`, or `Extremum` as `Value`. - **Feasibility problems** — typically use `Or`. - **Solve contract** — a successful solve always returns the problem's `Solution`; a global count or statistic without a representative solution is not a `Problem` solve. -- **Common aggregate wrappers** — `Max`, `Min`, `Sum`, `Or`, `And`, `Extremum`, `ExtremumSense`. +- **Common aggregate wrappers** — `Max`, `Min`, `Sum`, `Or`, `And`, `Extremum`, `ExtremumSense`. All six values implement the `Aggregate` fold (`identity`, `combine`, `is_absorbing`). `Sum` and `And` are evaluation and fold values only: a problem may evaluate to them and fold them, but they do not implement `EvaluationValue`, so passing such a problem to a solve or recovery API, or registering it, is a compile error. ## Construction inputs @@ -93,39 +94,198 @@ SpinGlass couplings and its objective result use `i64`, while the temporary temporary calculations are also outside the contract, but numeric fields written into its target model must follow the target model's numeric format. -Weight variants are `One`, `i64`, and `f64`, with `One ⊂ i64 ⊂ f64`. -`i64 → f64` is a fallible reduction using a checked conversion in -`±(2^53-1)`, not `as f64`. +Supported weight variants are `One`, `i64`, and `f64`. + +### Responsibility boundaries + +| Layer | Contract | +|-------|----------| +| Model (`Problem`) | Defines instances, witnesses, feasibility, and objectives in its declared mathematical representation. Evaluation is independent of backend tolerances, statuses, and enumeration capacity. | +| Reduction (`ReduceTo`, `ReductionResult`) | Constructs the target and recovers a complete source result through its mandatory `recover_result`. It owns objective relations, witness mappings, and infeasibility semantics. | +| Backend adapter | Encodes the target, executes the backend, interprets statuses, decodes numerical results, and validates the returned witness against the original target model. | +| Solver orchestration | Executes registered capabilities and calls each stored reduction result in reverse order. | +| CLI / MCP | Uses public construction, evaluation, and solving APIs and presents their results. | + +Models and rules do not repair backend results, change constraints to make a +solver succeed, or independently prove a backend's global optimality. Invalid +returned witnesses and operational failures must be explicit errors. A backend's +numerical limitations do not justify a package-wide certificate system or +downgrading every successful result. + +Search-space cardinalities belong to the solver capability, not the mathematical +model. Actual model storage and witness representation constraints still apply. + +### Complete result recovery + +Every `ReductionResult` implements `recover_result(source, target_result)`. +It returns the source status, solution, and evaluation together. There is no +optional interpretation callback or separate value-only execution path. + +| Target result | Rule's obligation | +|---|---| +| `Optimal { solution, evaluation }` | Recover a source optimum, or establish source infeasibility using the rule's mathematical relation | +| `Feasible { solution, evaluation }` | Recover a feasible source solution when justified; otherwise return `InsufficientSolutionQuality` | +| `Infeasible` | Apply the rule's infeasibility relation | +| Evaluation, decoding, or execution error | Preserve the error; never turn it into `Infeasible` | + +`ProblemOutcome

` retains `P::Solution` and `P::Value` as concrete Rust types. +`SolveOutcome::optimal` and `SolveOutcome::feasible` evaluate the candidate once +and check `EvaluationValue::is_valid()`. A candidate violating the constraints +returns `EvaluationError::ConstraintViolation`, not `SolveOutcome::Infeasible`: +rejecting one candidate does not prove that the problem has no solution. +Evaluation failures propagate unchanged. `optimal` does not prove optimality; +the solver or external caller supplies that conclusion. Direct enum construction +and deserialization do not perform these checks. + +The ILP pipeline carries complete results through recovery and JSON serialization; +it does not discard and recompute the source evaluation. Backend infeasibility +becomes a mathematical result at the terminal boundary. Only the typed +`ILPSolver::solve()` outlet converts it to `ILPSolveError::Infeasible` to satisfy +that method's solution-returning contract. + +`ReductionChain::recover_result_json()` returns `(source_result, target_result)`. +It accepts target-result JSON with an optional `evaluation`, decodes and evaluates +the target once, and rejects any supplied evaluation that does not match the model. +Integer and Boolean comparisons are exact; floating-point comparisons use +`abs(reported - computed) <= 1e-9 * max(1, abs(reported), abs(computed))`. +An `infeasible` result must not contain `evaluation`. +It retains the model-computed target evaluation for output while recovering the source. CLI callers use both +returned results; they do not independently evaluate the external candidate. + +For example, an independent set of size 2 in a four-vertex graph maps to a +vertex cover of size 2. Recovery complements the solution and evaluates the +source cover. An optimal target result produces `Optimal`; a merely feasible +target result produces `Feasible`. + +For `Decision

-> P`, an optimum missing the bound establishes NO and recovers +`Infeasible`. A merely feasible candidate missing it establishes no such result +and returns `InsufficientSolutionQuality`. Penalty reductions own their analogous +energy relationships. A decoded invalid witness alone is not a general proof +that the source is infeasible. + +### Executed reduction lifecycle + +```text +source ──reduce_to──> stored result A ──reduce_to──> stored result B + target A target B + │ solve + ▼ +source result <── A.recover_result <── B.recover_result <── target result +``` + +Each step constructs one result. `Rc` shares that result across paths with a +common prefix; recovery never reconstructs the target. The original source and +intermediate targets supply borrowed source references during reverse traversal. +Rules do not need extra source-instance fields for evaluation. + +| Caller | Recovery entry point | +|---|---| +| Concrete rule | `ReductionResult::recover_result` | +| Typed chain or executed path | `recover_result::` | +| Registered ILP pipeline | The same chain's erased recovery | +| `pred solve bundle.json` | Solve the stored target, then recover the complete result | +| `pred extract bundle.json --result target-result.json` | Read the external result, validate its target witness, then use the same recovery | + +Dynamic methods convert representations and delegate to the typed rule. +External optimality claims belong to the external solver; parsing or evaluating +a configuration cannot establish optimality. `pred extract` accepts explicit +`optimal`, `feasible`, or `infeasible` status, rather than a bare configuration. + +Bound-owning `Decision

` targets serialize their `inner` instance and `bound`. +Their `evaluate()` returns `Or`. The `Decision

-> P` bridge makes these targets +usable with optimization backends. Each other rule must explicitly implement +its source-result relation, including thresholds and sentinel constructions. +Guarantees must cover every qualifying witness, including tied optima. + +`SolutionAggregate` remains a brute-force solver capability for selecting from +an enumeration. `Min`, `Max`, `Or`, `Extremum`, `Sum`, and `And` remain model +values; solving, recovery, and registration accept only the first four, which +implement `EvaluationValue`. They do not require separate reduction traits or +graph modes. Turing edges describe multiple adaptive queries and are retained only as +theoretical graph relationships, not executable reductions. The library does not +provide a Turing reduction solver. Default path search and execution exclude these +edges. Exact recovery alone does not imply approximation or counting preservation. ### Arithmetic -- Keep arithmetic in the declared type. Exact values use checked `i64` - operations; approximate values use finite `f64` operations. -- Constructors and reductions reject an arithmetic step that would overflow - `i64` when producing a stored field. They do not cap every magnitude at - `2^53-1`. `evaluate()` never widens, wraps, saturates, or silently - approximates. -- Do not promote an `i64` calculation to `i128`, `BigInt`, or `BigUint` to - accept a larger instance. +- Integer models and reductions preserve integer values in their declared + representation. Report actual arithmetic overflow explicitly; do not wrap, + saturate, or silently approximate. Reuse an existing exact representation + when the mathematical model requires it. +- Floating-point models and rules use ordinary finite `f64` arithmetic and its + rounding. Check non-finite results and do not deliberately discard nonzero + coefficients. Backend feasibility tolerances must not expand the model's + feasible set. A declared input convention, such as checking probability sums, + is distinct from accepting a solver's returned assignment. +- CVP uses integer basis and target coordinates and returns an integer coefficient + vector. It evaluates squared distance as `Min` with checked arithmetic, and + Decision bounds use the same squared-distance type. SubsetSum uses the CVP bound + equal to its item count. Exact sphere enumeration uses implementation-local exact + arithmetic and returns an optimal solution. +- `i64_to_exact_f64()` accepts integers in `[-(2^53-1), 2^53-1]` and rejects + everything outside that supported conversion range. This is a conservative + interface limit, not the set of all exactly representable f64 integers. Ordinary conversion + into a floating-point model and backend transport are separate + responsibilities. Neither a lossless scalar conversion nor `transform = exact` + proves error-free floating-point evaluation or backend optimality; the latter + describes parameter relationships only. +- Preserve real construction and witness-structure checks, including bounds + derived by the reduction and adjacency preservation in geometric mappings. + Do not add exact arithmetic solely to audit a floating-point backend or reject + a mathematical reduction because that backend may struggle to solve it. ### Boundaries -- Use `From` only for value-preserving conversions and `TryFrom` when range, - sign, or domain can change. Do not use `as` for model-derived values. +- Use value-preserving conversions where possible and checked conversions for + range/sign changes. A floating-point model's declared rounding is not a + lossless-conversion requirement. Reuse `i64_to_exact_f64` where lossless scalar + conversion is actually required; backend input acceptance belongs to the + adapter and must not narrow integer model domains. - Converting a registered parameter getter from `usize` to `u64` is an internal - invariant of `Problem::parameters()`, not a recoverable construction error. A valid - instance's registered parameters must already fit `u64`; the - implementation checks this conversion to prevent silent truncation. -- Symbolic parameter evaluation may use arbitrary-precision integers for local - intermediate arithmetic, but a materialized `ProblemParameters` must fit `u64`. -- An `i64` to `f64` conversion is explicit and fallible: it succeeds only - for `|value| ≤ 2^53-1`. Use one shared helper at weight casts, solver - adapters, and other exact-to-float hubs. -- A lattice-to-`UnitDiskGraph` reduction converts coordinates fallibly and - rejects a stored `f64` geometry that would change source adjacency. -- Rust constructors keep `i64` fields as `i64`. CLI and MCP JSON encoding - of an `i64` with `|value| > 2^53-1` errors; there is no string encoding - and no clamping. + invariant of `Problem::parameters()`, not a recoverable construction error. + Check it to prevent silent truncation. +- Symbolic parameter evaluation may use arbitrary-precision intermediates, but + materialized `ProblemParameters` must fit `u64`. +- A lattice-to-`UnitDiskGraph` reduction must reject a stored geometry that + changes source adjacency. This is a mathematical reduction requirement. +- Rust constructors retain their declared integer fields. Existing serde JSON + serialization can emit i64 integer values beyond the consecutive-integer range + of f64. Describe the actual codec and consumer representation; do not impose + a universal f64 gate on Rust models or claim one exists in CLI/MCP. + +### Validation evidence + +Model tests check definitions and direct evaluation. Reduction tests check +construction, witness mappings, objective relationships, and parameter formulas +using explicit witnesses or small exhaustive enumeration. Choose cases that can +expose a concrete defect; there is no minimum vertex, assertion, test-function, +or generated-check count that establishes correctness. + +Keep representative solver integration tests and report whether failures occur +in construction, solving, extraction, or source validation. Backend timeout or +numerical failure is not evidence that a reduction theorem is false. Test backend +decoding and transport boundaries once in their shared implementation, not in +every rule. Retain arithmetic regressions that detect actual coefficient loss or +incorrect mappings. Do not enlarge tolerances to make a failing test pass. + +### Search representation + +`BruteForceProblem::num_variables()` and `dimension(variable)` return +`Result`. The caller supplies an index below the coordinate +count. Derived counts and cardinalities use checked arithmetic. Shared +`cartesian_dimensions()` materializes these values with fallible allocation for +registered solving and inspection; models do not call it during evaluation. + +The Cartesian iterator advances coordinates until mixed-radix exhaustion. Its +complete search count need not fit `usize`, and it does not implement +`ExactSizeIterator`. An empty product has one empty candidate; any zero-sized +coordinate makes the product empty. Native masks and dense tables retain their +actual representation limits and report errors before overflowing or allocating +an unrepresentable table. These are implementation limits, not difficulty budgets. + +`TruthTable` construction and deserialization share checked row-count and shape +validation. Variable-arity constructors return `ConstructionError` for unsupported +row counts or allocation failures. Valid tables retain the same JSON format. ## Variant System @@ -239,54 +399,45 @@ The result struct holds the target problem and the logic to map solutions back: ```rust,ignore #[derive(Debug, Clone)] -pub struct ReductionISToVC { - target: MinimumVertexCover, +pub struct ReductionISToVC { + target: MinimumVertexCover, } -impl ReductionResult for ReductionISToVC { - type Source = MaximumIndependentSet; - type Target = MinimumVertexCover; +impl ReductionResult for ReductionISToVC { + type Source = MaximumIndependentSet; + type Target = MinimumVertexCover; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution( + fn recover_result( &self, - target_sol: &Vec, - ) -> crate::rules::ExtractionResult> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_sol)?; - Ok(target_sol.iter().map(|&x| !x).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> ExtractionResult> { + Ok(match target { + SolveOutcome::Optimal { solution, .. } => + SolveOutcome::optimal(source, solution.into_iter().map(|x| !x).collect())?, + SolveOutcome::Feasible { solution, .. } => + SolveOutcome::feasible(source, solution.into_iter().map(|x| !x).collect())?, + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + }) } } ``` -### Solution extraction contract - -`ReductionResult::extract_solution` accepts one complete target configuration -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. -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. +### Recovery contract -Do not truncate or pad input, substitute zero for missing data, select the -first of several invalid candidates, retry with another mapping, or panic on -caller-provided configuration data. Empty and singleton instances should flow -through the same mathematical mapping unless the reduction itself has a -genuine mathematical case distinction. +Construction returns `ReductionError`; recovery returns `ExtractionError`. +Recovery must explicitly cover each result status. Required witness quality +comes from the rule's proof, not from which caller happens to invoke it. +A rule reports `InsufficientSolutionQuality`; type-erased and chain recovery +relocate it to `InsufficientSolutionQualityAt { source_problem, target_problem }` +so the message names the failing hop. `is_insufficient_quality()` matches both. -Zero and sentinel values remain valid when the source model explicitly gives -them meaning. For example, `MaximumCommonEdgeSubgraph` includes an "unmapped" -sentinel in its source dimensions. Missing target data must never be -interpreted as that sentinel. - -Each conditional in an extractor should therefore either reject a named -invariant violation or implement a case in the reduction's mathematics. A -normal extractor has one validation phase followed by one decoding phase; it -does not accumulate compatibility or fallback branches. +The adapter checks backend output against the target model. Recovery performs +the mathematical reverse mapping and computes the source evaluation. Do not +repeat conditions already guaranteed by the target, repair malformed input, or +add a second solver to certify the supplied optimum. Mathematical sentinels +retain their model-defined meaning; missing data is an error. The `#[reduction]` attribute on the `ReduceTo` impl registers the reduction in the global registry (via `inventory`): @@ -347,10 +498,13 @@ Execute an explicitly selected path with `ReductionGraph::reduce_along_path`: ```rust,ignore let reduction = graph.reduce_along_path(rpath, &factoring_instance)?.unwrap(); let target: &SpinGlass = reduction.target_problem(); -let source_solution = reduction.extract_solution(&target_solution)?; +let target_result = SolveOutcome::optimal(target, target_solution)?; +let source_result = reduction.recover_result::>( + &factoring_instance, target_result, +)?; ``` -The returned `ReductionChain` stores each intermediate reduction and extracts the source solution by applying the inverse mappings in reverse order. Construction returns `ReductionError`; extraction returns `ExtractionError`. +The returned `ReductionChain` stores each intermediate result and recovers complete results in reverse order. Construction returns `ReductionError`; recovery returns `ExtractionError`.

Parameter contracts @@ -409,20 +563,35 @@ 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`. | - -ILP results are optimal or infeasible according to HiGHS numerical tolerances; -zero MIP gaps do not imply mathematical exactness. Integer extraction rounds -variable assignments, validates the original constraints, and recomputes the -source objective with checked integer arithmetic. Floating-point objective -comparisons in numerical regression tests use an explicit acceptance policy -in source units (absolute and relative tolerances of `1e-7` for the QUBO solver -regression), separate from the `1e-6` variable-rounding tolerance. This test -policy is not a universal bound on backend objective error. - -When an ILP target witness misses a source decision threshold, the solver -returns `ILPSolveError::UnresolvedDecision`, not infeasibility: the witness -alone cannot prove that no qualifying source solution exists. +| **ILPSolver** | Executes a problem's registered ILP pipeline. Each pipeline terminates at a native `ILP` with bool/i64 variables and i64/f64 coefficients, solved by the shared HiGHS adapter through its native Rust bindings. | + +### ILP execution boundary + +`ILPSolver::solve

() -> Result` is the typed entry +point. Adapter failures retain their classified errors. Registry lookup, +concrete-terminal dispatch, and reduction-chain execution belong to orchestration. Integer pipelines end +at native integer ILPs; they do not need a float-coefficient cast edge to execute. +Explicit coefficient-conversion rules retain their own mathematical contracts. + +The shared internal `HighsAdapter` borrows an `ILP` and returns its existing +`Vec` witness representation. It encodes the backend model, executes it, +interprets termination, checks returned integer values, and validates constraints +and objective arithmetic against the original ILP. It does not inspect source +model names, query reduction registrations, or extract source witnesses. +Unsupported transport produces `InexactTransport`; a rejected returned witness +produces `InvalidSolution`. A validation failure must not relax model constraints. + +Optimality and infeasibility are backend conclusions under HiGHS's numerical +contract, not independent mathematical certificates. An accepted optimum requires +both an optimal backend termination and successful witness validation. Timeouts, +non-optimal termination, and invalid results are errors, not infeasibility. +Variable decoding tolerances belong to the adapter; they do not define source or +target feasibility, nor a universal objective-error allowance for tests. + +After accepting a target result, orchestration invokes the stored reduction +chain's complete recovery. Every intermediate status passes through the previous +rule. Typed solving, dynamic solving, and explicit CLI bundles share this reverse +traversal; no caller performs its own source-threshold interpretation. ## JSON Serialization @@ -438,3 +607,23 @@ let restored: MaximumIndependentSet = from_json(&json)?; ## Contributing See [Call for Contributions](./open-problems.md) for the recommended issue-based workflow (no coding required). + +### QUBO coefficient storage + +QUBO stores coefficients in `sprs::CsMat` using CSR order. Construction from +linear/quadratic terms preserves last-assignment semantics; reductions accumulate +coefficients with their existing checked arithmetic before compression. Exact +zeros need no stored entry. Evaluation visits the upper triangle in row/column +order, retaining checked integer addition and floating-point summation order. + +`QUBO::from_sparse` accepts a square CSR or CSC matrix; `matrix()` returns the +CSR matrix and `get(i, j)` returns an owned coefficient, including zero for an +unstored in-bounds entry. `from_matrix` and CLI `--matrix` accept dense input. +Persisted QUBO JSON is a project-owned sparse shape, independent of `sprs` +internals: `{"num_vars": n, "entries": [[row, column, value], ...]}` lists every +stored coefficient in row-major order. Loading accepts entries in any order and +stores exactly what is listed, like `from_sparse`; it rejects an index outside +`0..num_vars` and a repeated `(row, column)`. Loading also accepts the legacy +dense shape `{"num_vars": n, "matrix": [[...], ...]}` and reads it like +`from_matrix`. A file must contain exactly one of `entries` and `matrix`. +Rules, numeric casts, and solver reductions consume sparse coefficients directly. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 872b4e900..bb7f5b8ea 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -42,13 +42,15 @@ fn main() { assert_eq!(target.num_constraints(), 2); let target_solution = ILPSolver::new().solve(target).unwrap(); - let solution = reduction.extract_solution(&target_solution).unwrap(); + let target_result = SolveOutcome::optimal(target, target_solution).unwrap(); + let result = reduction.recover_result(&problem, target_result).unwrap(); + let solution = result.into_solution().unwrap(); assert_eq!(solution, vec![true, false, true, true]); println!("{}", problem.evaluate(&solution).unwrap()); // Max(3) } ``` -The target has one binary variable per set and a constraint for each element shared by multiple sets. `extract_solution` maps a target solution back to the source solution type. `ILPSolver::new().solve(&problem)` executes the exact variant’s registered ILP pipeline and returns its source solution. +The target has one binary variable per set and a constraint for each element shared by multiple sets. `recover_result` returns the source status, solution, and evaluation together. `ILPSolver::new().solve(&problem)` executes the exact variant’s registered ILP pipeline and returns its source solution. ## Discover and run a path @@ -61,10 +63,10 @@ Search uses exact variants. This discovers a route from `Factoring` to `SpinGlas let reduction = graph.reduce_along_path(rpath, &factoring).unwrap().unwrap(); let target: &SpinGlass = reduction.target_problem(); -// Solve `target`, then call reduction.extract_solution(&target_solution). +// Solve `target`, then pass its ProblemOutcome to reduction.recover_result. ``` -`extract_solution` walks the intermediate mappings in reverse. The full [example](https://github.com/CodingThrust/problem-reductions/blob/main/examples/chained_reduction_factoring_to_spinglass.rs) also solves factoring through a direct ILP reduction and checks that the recovered factors multiply to 6: +`recover_result` walks the intermediate mappings in reverse. The full [example](https://github.com/CodingThrust/problem-reductions/blob/main/examples/chained_reduction_factoring_to_spinglass.rs) also solves factoring through a direct ILP reduction and checks that the recovered factors multiply to 6: ```bash cargo run --example chained_reduction_factoring_to_spinglass diff --git a/docs/src/static/reduction-workflow-dark.svg b/docs/src/static/reduction-workflow-dark.svg index 8e97a4ad2..b621fce67 100644 --- a/docs/src/static/reduction-workflow-dark.svg +++ b/docs/src/static/reduction-workflow-dark.svg @@ -1,430 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs/src/static/reduction-workflow.svg b/docs/src/static/reduction-workflow.svg index 334c2c182..4e3ed112d 100644 --- a/docs/src/static/reduction-workflow.svg +++ b/docs/src/static/reduction-workflow.svg @@ -1,430 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs/src/static/reduction-workflow.typ b/docs/src/static/reduction-workflow.typ index df911fb8b..dc3cf4703 100644 --- a/docs/src/static/reduction-workflow.typ +++ b/docs/src/static/reduction-workflow.typ @@ -23,12 +23,12 @@ node((0, 0), box(width: 28mm, align(center)[*Problem A*\ #text(size: 8pt)[source problem]]), fill: box-fill, corner-radius: 6pt, inset: 10pt, name: ), node((1, 0), box(width: 28mm, align(center)[*Problem B*\ #text(size: 8pt)[target problem]]), fill: box-fill, corner-radius: 6pt, inset: 10pt, name: ), node((2, 0), box(width: 28mm, align(center)[*Solution B*\ #text(size: 8pt)[solver output]]), fill: box-fill, corner-radius: 6pt, inset: 10pt, name: ), - node((1, 1), box(width: 28mm, align(center)[*Solution A*\ #text(size: 8pt)[extracted result]]), fill: success-fill, stroke: 1.5pt + success, corner-radius: 6pt, inset: 10pt, name: ), + node((1, 1), box(width: 28mm, align(center)[*Solution A*\ #text(size: 8pt)[recovered result]]), fill: success-fill, stroke: 1.5pt + success, corner-radius: 6pt, inset: 10pt, name: ), // Edges with labels edge(, , "->", stroke: 1.5pt + accent, label: text(size: 9pt)[`reduce_to`], label-sep: 5pt, label-pos: 0.5, label-side: left), - edge(, , "->", stroke: 1.5pt + accent, label: text(size: 9pt)[`find_witness`], label-sep: 5pt, label-pos: 0.5, label-side: left), - edge(, , "->", stroke: 1.5pt + success, label: text(size: 9pt)[`extract_solution`], label-sep: 2pt, label-pos: 0.5, label-side: left), + edge(, , "->", stroke: 1.5pt + accent, label: text(size: 9pt)[`solve`], label-sep: 5pt, label-pos: 0.5, label-side: left), + edge(, , "->", stroke: 1.5pt + success, label: text(size: 9pt)[`recover_result`], label-sep: 2pt, label-pos: 0.5, label-side: left), ) } diff --git a/docs/src/static/trait-hierarchy-dark.svg b/docs/src/static/trait-hierarchy-dark.svg index e932783a4..3e35702f4 100644 --- a/docs/src/static/trait-hierarchy-dark.svg +++ b/docs/src/static/trait-hierarchy-dark.svg @@ -1,765 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs/src/static/trait-hierarchy.svg b/docs/src/static/trait-hierarchy.svg index 571ca9c90..a1b82bfb0 100644 --- a/docs/src/static/trait-hierarchy.svg +++ b/docs/src/static/trait-hierarchy.svg @@ -1,765 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/docs/src/static/trait-hierarchy.typ b/docs/src/static/trait-hierarchy.typ index b6c167ffd..a9ce6343d 100644 --- a/docs/src/static/trait-hierarchy.typ +++ b/docs/src/static/trait-hierarchy.typ @@ -24,14 +24,15 @@ spacing: (8mm, 12mm), // Problem trait (top center) - node((0.6, 0), box(width: 55mm, align(left)[ + node((1, 0), box(width: 55mm, align(left)[ #strong[trait Problem]\ #text(size: 8pt, fill: secondary)[ `const NAME: &str`\ `type Solution`\ `type Value: Clone`\ - `fn size() -> ProblemParameters`\ - `fn evaluate(&solution) -> Value`\ + `fn parameters() -> ProblemParameters`\ + `fn evaluate(&solution)`\ + ` -> Result`\ `fn variant() -> Vec<(&str, &str)>` ] ]), fill: trait-fill, corner-radius: 6pt, inset: 10pt, name: ), @@ -41,7 +42,8 @@ #strong[trait Aggregate]\ #text(size: 8pt, fill: secondary)[ `fn identity() -> Self`\ - `fn combine(self, other) -> Self`\ + `fn combine(self, other)`\ + ` -> Result`\ `fn is_absorbing(&self) -> bool`\ #strong[trait SolutionAggregate: Aggregate]\ `fn contributes_to_solution(...)` @@ -49,17 +51,19 @@ ]), fill: trait-fill, corner-radius: 6pt, inset: 10pt, name: ), // Brute-force capability (bottom center) - node((0.7, 1), box(width: 48mm, align(left)[ + node((1, 1), box(width: 48mm, align(left)[ #strong[trait BruteForceProblem]\ #text(size: 8pt, fill: secondary)[ `extends Problem`\ - `fn dimensions() -> Vec`\ + `num_variables()`\ + `dimension(i: usize)`\ + `→ Result`\ #text(style: "italic")[reference solver only] ] ]), fill: trait-fill, corner-radius: 6pt, inset: 10pt, name: ), // Common value types (bottom right) - node((1.4, 1), box(width: 48mm, align(left)[ + node((0, 2), box(width: 48mm, align(left)[ #strong[Common Value Types]\ #text(size: 8pt, fill: secondary)[ `Max | Min | Extremum`\ diff --git a/docs/website/assets/site.js b/docs/website/assets/site.js index 7d09f9def..c0cd3f96c 100644 --- a/docs/website/assets/site.js +++ b/docs/website/assets/site.js @@ -284,7 +284,7 @@ target = data.nodes[edge.target]; const outgoing = edge.source === currentIndex; const other = outgoing ? target : source; - return `

${escape(variantLabel(other))}

${[edge.witness && "Witness recovery", edge.aggregate && "Aggregate value", edge.turing && "Turing reduction"].filter(Boolean).join(" · ") || "See reduction contract"}

`; + return `
${outgoing ? "→ " : "← "}${escape(nameOf(other.name))}

${escape(variantLabel(other))}

${[edge.witness && "Result recovery", edge.turing && "Turing reduction"].filter(Boolean).join(" · ") || "See reduction contract"}

`; } function demoPanel() { @@ -446,8 +446,7 @@ const vcToMis = sourceName === "MinimumVertexCover"; document.title = `${nameOf(sourceName)} → ${nameOf(targetName)} — ${baseTitle}`; const capabilities = [ - edge.witness && "Witness recovery", - edge.aggregate && "Aggregate value", + edge.witness && "Result recovery", edge.turing && "Turing reduction", ] .filter(Boolean) diff --git a/examples/chained_reduction_factoring_to_spinglass.rs b/examples/chained_reduction_factoring_to_spinglass.rs index 48067cd3a..232d4b937 100644 --- a/examples/chained_reduction_factoring_to_spinglass.rs +++ b/examples/chained_reduction_factoring_to_spinglass.rs @@ -1,3 +1,4 @@ +use problemreductions::solvers::SolveOutcome; // # Chained Reduction: Factoring -> SpinGlass // // Mirrors Julia's examples/Ising.jl — reduces a Factoring problem @@ -27,8 +28,10 @@ pub fn run() -> std::result::Result<(), Box> { ); let rpath = paths .iter() - .find(|path| path.type_names() == ["Factoring", "CircuitSAT", "SpinGlass"]) - .expect("explicit Factoring -> CircuitSAT -> SpinGlass route"); + .find(|path| { + path.type_names() == ["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"] + }) + .expect("explicit Factoring -> CircuitSAT -> DecisionSpinGlass -> SpinGlass route"); println!(" {}", rpath); // ANCHOR_END: step1 @@ -45,7 +48,14 @@ pub fn run() -> std::result::Result<(), Box> { let solver = ILPSolver::new(); let reduction = ReduceTo::>::reduce_to(&factoring).expect("reduction should succeed"); let ilp_solution = solver.solve(reduction.target_problem()).unwrap(); - let solution = reduction.extract_solution(&ilp_solution).unwrap(); + let solution = reduction + .recover_result( + &factoring, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // ANCHOR_END: step3 // ANCHOR: step4 diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml index be37a9085..76cf6c772 100644 --- a/problemreductions-cli/Cargo.toml +++ b/problemreductions-cli/Cargo.toml @@ -17,19 +17,18 @@ path = "src/bin/pred_sym.rs" [features] all = ["mcp"] -mcp = ["dep:rmcp", "dep:tokio", "dep:schemars", "dep:tracing", "dep:tracing-subscriber"] +mcp = ["dep:rmcp", "dep:tokio", "dep:schemars", "dep:tracing-subscriber"] [dependencies] problemreductions = { version = "0.6.0", path = "..", features = ["example-db"] } -clap = { version = "4", features = ["derive", "string"] } -anyhow = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -num-bigint = "0.4" -clap_complete = "4" -owo-colors = { version = "4", features = ["supports-colors"] } -rmcp = { version = "1.2", features = ["server", "macros", "transport-io"], optional = true } -tokio = { version = "1", features = ["full"], optional = true } -schemars = { version = "1.0", optional = true } -tracing = { version = "0.1", optional = true } -tracing-subscriber = { version = "0.3", optional = true } +clap = { version = "4.6.7", features = ["derive", "string"] } +anyhow = "1.0.104" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +num-bigint = "0.4.8" +clap_complete = "4.6.11" +owo-colors = { version = "4.4.0", features = ["supports-colors"] } +rmcp = { version = "3.4.0", features = ["server", "macros", "transport-io"], optional = true } +tokio = { version = "1.53.1", features = ["rt-multi-thread"], optional = true } +schemars = { version = "1.2.2", optional = true } +tracing-subscriber = { version = "0.3.23", optional = true } diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index dd1518a1b..7b075bb9f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -209,12 +209,12 @@ 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 result from a reduction bundle and an external target result #[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]' + pred extract bundle.json --result target-result.json + pred extract bundle.json --result target-result.json -o source.json + cat bundle.json | pred extract - --result target-result.json 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 @@ -222,7 +222,17 @@ 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]').")] +--result is the target solver's result as JSON (use - for stdin), e.g. + {\"status\":\"feasible\",\"solution\":[true,false,true,false]} +`solution` uses the target problem's solution encoding; `evaluation` is optional +and is checked against the computed value when present. + +Status: + optimal the solver PROVED optimality; recovery may conclude the source is infeasible + feasible a valid solution without an optimality proof (samplers, QAOA, annealers) + infeasible the solver PROVED the target has no solution; takes no `solution` +Use feasible unless optimality is proven: a false optimal claim can yield a wrong source answer. +When a source `infeasible` result rests on an optimal or infeasible claim, a note says so on stderr.")] Extract(ExtractArgs), /// Start MCP (Model Context Protocol) server for AI assistant integration #[cfg(feature = "mcp")] @@ -335,11 +345,11 @@ pub struct ReduceArgs { #[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 solver result JSON with an explicit solve status. Use - for stdin. #[arg(long)] - pub config: String, + pub result: PathBuf, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index 79294e200..6b678d6fd 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"); @@ -359,33 +359,6 @@ fn test_create_schema_driven_builds_integer_target_closest_vector_problem() { assert!(data.get("bounds").is_none()); } -#[test] -fn test_create_schema_driven_builds_real_target_closest_vector_problem() { - let cli = Cli::try_parse_from([ - "pred", - "create", - "CVP", - "--basis", - "1,0;0,1", - "--target-vec", - "0.5,1.25", - ]) - .expect("create command parses"); - - 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])); -} - #[test] fn test_create_schema_driven_builds_cdft() { let cli = Cli::try_parse_from([ diff --git a/problemreductions-cli/src/commands/evaluate.rs b/problemreductions-cli/src/commands/evaluate.rs index 7ce07cadf..32d379780 100644 --- a/problemreductions-cli/src/commands/evaluate.rs +++ b/problemreductions-cli/src/commands/evaluate.rs @@ -28,7 +28,7 @@ pub fn evaluate(input: &Path, config_str: &str, out: &OutputConfig) -> Result<() let config: serde_json::Value = serde_json::from_str(config_str).context("Config is not valid JSON")?; - let result = problem.evaluate_dyn(&config)?; + let (result, _) = problem.evaluate_dyn(&config)?; out.emit( || result.to_string(), diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index 79736a984..b9ff4c75e 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -1,61 +1,62 @@ use crate::dispatch::{read_input, BundleReplay, ReductionBundle}; use crate::output::OutputConfig; use anyhow::{Context, Result}; +use problemreductions::solvers::{SolveOutcome, SolverExecution}; +use serde::Deserialize; 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)?; - let json: serde_json::Value = - serde_json::from_str(&content).context("Input is not valid JSON")?; +const RESULT_SHAPE: &str = r#"Target result must be {"status": "optimal"|"feasible", "solution": [...]} or {"status": "infeasible"}; a bare configuration is no longer accepted, wrap it as {"status": "feasible", "solution": [...]}"#; - 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\ - Got a plain problem file; did you mean `pred evaluate`?" - ); - } - - let bundle: ReductionBundle = - serde_json::from_value(json).context("Failed to parse reduction bundle")?; +/// Status envelope of a target result; rules validate the typed solution. +#[derive(Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +#[allow(dead_code)] +enum ResultEnvelope { + Optimal { solution: serde::de::IgnoredAny }, + Feasible { solution: serde::de::IgnoredAny }, + Infeasible, +} - let target_config: serde_json::Value = - serde_json::from_str(config_str).context("Target config is not valid JSON")?; +impl ResultEnvelope { + /// The unverified external claim, if any, that a source `infeasible` result rests on. + fn infeasibility_note(&self) -> Option<&'static str> { + match self { + Self::Optimal { .. } => Some( + r#"note: source infeasibility rests on the external solver's optimality claim; pass "status":"feasible" unless optimality was proven"#, + ), + Self::Infeasible => Some( + "note: source infeasibility rests on the external solver's infeasibility claim; pred did not verify it", + ), + Self::Feasible { .. } => None, + } + } +} +/// Recover the source result from an external solver's explicit target result. +pub fn extract(input: &Path, result_path: &Path, out: &OutputConfig) -> Result<()> { + let bundle: ReductionBundle = serde_json::from_str(&read_input(input)?) + .context("pred extract requires a reduction bundle produced by pred reduce")?; + let target: serde_json::Value = + serde_json::from_str(&read_input(result_path)?).context("Target result is not JSON")?; + let claimed = ResultEnvelope::deserialize(&target).context(RESULT_SHAPE)?; let replay = BundleReplay::prepare(&bundle)?; - - let target_eval = replay.target.evaluate_dyn(&target_config)?; - - let (source_config, source_eval) = replay.extract(&target_config)?; - + let result = replay.recover_result(target, SolverExecution::External)?; out.emit( || { - format!( - "Problem: {}\nSolver: external (via {})\nSolution: {:?}\nEvaluation: {}", - replay.source_name, replay.target_name, source_config, source_eval, - ) + let mut text = format!( + "Problem: {}\nSolver: external (via {})", + result.source_name, result.target_name + ); + super::solve::append_outcome_text(&mut text, &result.source_outcome); + text }, - || { - // Schema aligned with `pred solve` on a bundle. `solver` is "external" - // because pred did not run the solver that produced the target config. - Ok(serde_json::json!({ - "problem": replay.source_name, - "solver": "external", - "reduced_to": replay.target_name, - "solution": source_config, - "evaluation": source_eval, - "intermediate": { - "problem": replay.target_name, - "solution": target_config, - "evaluation": target_eval, - }, - })) - }, - ) + || Ok(result.to_json()), + )?; + // stdout stays the machine-readable result; the caveat goes to stderr. + if matches!(result.source_outcome, SolveOutcome::Infeasible) { + if let Some(note) = claimed.infeasibility_note() { + out.info(note); + } + } + Ok(()) } diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index ce3c471de..81f2c330c 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -32,8 +32,9 @@ fn parse_input(path: &Path) -> Result { } } -fn solver_text(solver: &SolverExecution) -> String { +pub(crate) fn solver_text(solver: &SolverExecution) -> String { match solver { + SolverExecution::External => "external".to_string(), SolverExecution::Customized { implementation } => format!("customized ({implementation})"), SolverExecution::Ilp { reduction_path } => { format!("ilp ({})", reduction_path.join(" -> ")) @@ -52,7 +53,7 @@ fn solve_result_text(problem: &str, result: &SolveResult) -> String { text } -fn append_outcome_text(text: &mut String, outcome: &SolveOutcome) { +pub(crate) fn append_outcome_text(text: &mut String, outcome: &SolveOutcome) { match outcome { SolveOutcome::Optimal { solution, @@ -62,6 +63,15 @@ fn append_outcome_text(text: &mut String, outcome: &SolveOutcome) { text.push_str(&format!("\nSolution: {:?}", solution)); text.push_str(&format!("\nEvaluation: {evaluation}")); } + SolveOutcome::Feasible { + solution, + evaluation, + } => { + text.push_str("\nStatus: feasible"); + text.push_str(&format!( + "\nSolution: {solution:?}\nEvaluation: {evaluation}" + )); + } SolveOutcome::Infeasible => text.push_str("\nStatus: infeasible"), } } diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 341125bb4..66807cc81 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -3,7 +3,7 @@ use problemreductions::registry::{DynProblem, LoadedDynProblem}; use problemreductions::rules::ReductionGraph; use problemreductions::solvers::{ brute_force_dimensions, solve, solver_capabilities, ExactProblemKey, SolveOutcome, SolveResult, - SolverRequest, + SolverExecution, SolverRequest, }; use serde_json::Value; use std::any::Any; @@ -42,7 +42,7 @@ impl LoadedProblem { pub fn brute_force_num_variables(&self) -> Result> { brute_force_dimensions(&self.inner) .map(|dimensions| dimensions.map(|dimensions| dimensions.len())) - .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}")) + .map_err(|error| anyhow::anyhow!("cannot inspect brute-force coordinates: {error}")) } pub fn solve(&self, request: SolverRequest) -> Result { @@ -77,7 +77,7 @@ pub struct SolverCapabilitiesView { pub fn solver_capabilities_view(problem: &LoadedProblem) -> Result { let key = ExactProblemKey::new(problem.problem_name(), problem.variant_map()); let registered = solver_capabilities(&key) - .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}"))?; + .map_err(|error| anyhow::anyhow!("cannot build the solver capability registry: {error}"))?; let customized = registered .customized .map(|entry| CustomizedSolverCapabilityView { @@ -292,46 +292,15 @@ impl BundleReplay { }) } - /// Map a target-space configuration back to the source space and evaluate it. - pub fn extract( + /// Recover an externally supplied or internally solved target result. + pub(crate) fn recover_result( &self, - target_config: &serde_json::Value, - ) -> Result<(serde_json::Value, String)> { - let source_config = self.chain.extract_solution_json(target_config.clone())?; - let source_eval = self.source.evaluate_witness_dyn(&source_config)?.ok_or_else(|| { - problemreductions::rules::ExtractionError::invalid(format!( - "extracted solution is infeasible for {}; the reduction did not establish a source solution", - self.source_name - )) - })?; - Ok((source_config, source_eval)) - } - - /// 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), - }; - + target_outcome: serde_json::Value, + solver: SolverExecution, + ) -> Result { + let (source_outcome, target_outcome) = self + .chain + .recover_result_json(self.source.as_any(), target_outcome)?; Ok(BundleSolveResult { source_name: self.source_name.clone(), target_name: self.target_name.clone(), @@ -340,6 +309,11 @@ impl BundleReplay { target_outcome, }) } + + pub(crate) fn solve(&self, request: SolverRequest) -> Result { + let result = self.target.solve(request)?; + self.recover_result(serde_json::to_value(result.outcome)?, result.solver) + } } fn format_step(name: &str, variant: &BTreeMap) -> String { @@ -428,7 +402,70 @@ mod tests { use serde_json::json; #[test] - fn bundle_rejects_infeasible_extracted_witness() { + fn ilp_qubo_bundle_maps_optima_and_infeasibility() { + use problemreductions::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; + use problemreductions::Problem; + + for rhs in [1, -1] { + let ilp = ILP::::new( + 2, + vec![LinearConstraint::le(vec![(0, 1), (1, 1)], rhs)], + vec![(0, 3), (1, 2)], + ObjectiveSense::Maximize, + ) + .unwrap(); + let source = ProblemJson { + problem_type: "ILP".into(), + variant: ILP::::variant() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + data: serde_json::to_value(&ilp).unwrap(), + }; + let route = crate::commands::reduce::parse_path_json( + r#"{"path":[{"from":{"name":"ILP","variant":{"variable":"bool","coefficient":"i64"}},"to":{"name":"QUBO","variant":{"weight":"i64"}}}]}"#, + ).unwrap(); + let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); + let replay = BundleReplay::prepare(&bundle).unwrap(); + for backend in [SolverRequest::BruteForce, SolverRequest::Ilp] { + let result = replay.solve(backend).unwrap(); + if rhs == 1 { + let SolveOutcome::Optimal { solution, .. } = result.source_outcome else { + panic!("the ILP has an optimum"); + }; + assert_eq!(solution, json!([1, 0])); + let SolveOutcome::Optimal { + solution: target, .. + } = result.target_outcome + else { + panic!("the QUBO has an optimum"); + }; + assert_eq!(target, json!([true, false, false])); + assert_eq!( + replay + .recover_result( + json!({"status": "optimal", "solution": target}), + SolverExecution::External + ) + .unwrap() + .source_outcome + .into_solution() + .unwrap(), + solution + ); + } else { + assert_eq!(result.source_outcome, SolveOutcome::Infeasible); + assert!(matches!( + result.target_outcome, + SolveOutcome::Optimal { .. } + )); + } + } + } + } + + #[test] + fn bundle_preserves_decision_target_bound_and_infeasibility() { 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 +487,81 @@ 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"}} }]}"#, ).unwrap(); let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); let replay = BundleReplay::prepare(&bundle).unwrap(); - let result = replay.solve(SolverRequest::BruteForce); + assert_eq!(replay.target.serialize_json()["bound"], json!(5)); + let result = replay.solve(SolverRequest::BruteForce).unwrap(); + assert_eq!( + matches!(result.target_outcome, SolveOutcome::Infeasible), + !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)); + } + } + } + + #[test] + fn bundle_and_registered_pipeline_agree_on_decision_thresholds() { + for bound in [0, 1] { + let source = ProblemJson { + problem_type: "DecisionMinimumVertexCover".into(), + variant: BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]), + data: json!({ + "inner": {"graph": {"num_vertices": 2, "edges": [[0,1]]}, "weights": [1,1]}, + "bound": bound, + }), + }; + let route = crate::commands::reduce::parse_path_json( + r#"{"path":[{ + "from":{"name":"DecisionMinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}}, + "to":{"name":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i64"}} + }]}"#, + ).unwrap(); + let bundle = crate::commands::reduce::execute_route(source, route).unwrap(); + let replay = BundleReplay::prepare(&bundle).unwrap(); + if bound == 1 { + assert_eq!( + replay + .recover_result( + json!({"status": "optimal", "solution": [true, false]}), + SolverExecution::External + ) + .unwrap() + .source_outcome + .into_solution() + .unwrap(), + json!([true, false]) + ); + } + for backend in [ + SolverRequest::BruteForce, + SolverRequest::Ilp, + SolverRequest::Default, + ] { + let result = replay.solve(backend).unwrap(); + assert!(matches!( + result.target_outcome, + SolveOutcome::Optimal { .. } + )); + assert_eq!( + matches!(result.source_outcome, SolveOutcome::Infeasible), + bound == 0 + ); + let direct = replay.source.solve(backend).unwrap(); + assert_eq!( + matches!(direct.outcome, SolveOutcome::Infeasible), + bound == 0 + ); } } } @@ -596,7 +691,7 @@ mod tests { ); let err = loaded.err().unwrap(); assert!( - err.to_string().contains("expected positive integer, got 0"), + err.to_string().contains("num_processors must be positive"), "unexpected error: {err}" ); } diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 9b0b23c1c..902ef0698 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -80,7 +80,7 @@ fn main() -> anyhow::Result<()> { } Commands::Reduce(args) => commands::reduce::reduce(&args.input, &args.via, &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.input, &args.result, &out), #[cfg(feature = "mcp")] Commands::Mcp => mcp::run(), Commands::Completions { shell } => { diff --git a/problemreductions-cli/src/mcp/prompts.rs b/problemreductions-cli/src/mcp/prompts.rs index 61ac602df..82fbd18b1 100644 --- a/problemreductions-cli/src/mcp/prompts.rs +++ b/problemreductions-cli/src/mcp/prompts.rs @@ -1,4 +1,4 @@ -use rmcp::model::{GetPromptResult, Prompt, PromptArgument, PromptMessage, PromptMessageRole}; +use rmcp::model::{GetPromptResult, Prompt, PromptArgument, PromptMessage, Role}; /// Return the list of available MCP prompt templates. pub fn list_prompts() -> Vec { @@ -88,11 +88,8 @@ pub fn list_prompts() -> Vec { } fn prompt_result(description: &str, user_message: &str) -> GetPromptResult { - GetPromptResult::new(vec![PromptMessage::new_text( - PromptMessageRole::User, - user_message, - )]) - .with_description(description) + GetPromptResult::new(vec![PromptMessage::new_text(Role::User, user_message)]) + .with_description(description) } /// Return the content for the named prompt, or `None` if the name is unknown. diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 09cddd892..e1f7d4749 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -414,7 +414,7 @@ impl McpServer { let pj: ProblemJson = serde_json::from_str(problem_json)?; let problem = load_problem(&pj.problem_type, &pj.variant, pj.data)?; - let result = problem.evaluate_dyn(config)?; + let (result, _) = problem.evaluate_dyn(config)?; let json = serde_json::json!({ "problem": problem.problem_name(), "config": config, @@ -615,14 +615,14 @@ impl McpServer { #[rmcp::tool_handler] impl rmcp::ServerHandler for McpServer { - fn get_info(&self) -> rmcp::model::ServerInfo { + fn get_info(&self) -> rmcp::model::ServerConfig { let capabilities = rmcp::model::ServerCapabilities::builder() .enable_tools() .enable_prompts() .build(); let server_info = rmcp::model::Implementation::new("problemreductions", env!("CARGO_PKG_VERSION")); - rmcp::model::ServerInfo::new(capabilities) + rmcp::model::ServerConfig::new(capabilities) .with_server_info(server_info) .with_instructions( "MCP server for NP-hard problem reductions. \ @@ -647,11 +647,13 @@ impl rmcp::ServerHandler for McpServer { &self, request: rmcp::model::GetPromptRequestParams, _context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { let args = request.arguments.unwrap_or_default(); - super::prompts::get_prompt(&request.name, &args).ok_or_else(|| { - rmcp::ErrorData::invalid_params(format!("Unknown prompt: {}", request.name), None) - }) + super::prompts::get_prompt(&request.name, &args) + .map(Into::into) + .ok_or_else(|| { + rmcp::ErrorData::invalid_params(format!("Unknown prompt: {}", request.name), None) + }) } } diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index ba3d7e15d..52344257c 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -1,12 +1,12 @@ use crate::dispatch::{PathStep, ProblemJsonOutput, ReductionBundle}; -use problemreductions::models::algebraic::{ObjectiveSense, ILP}; +use problemreductions::models::algebraic::ILP; use problemreductions::registry::{ CreateInputCodec, CreateInputInfo, FieldInfo, ProblemSchemaEntry, VariantEntry, }; use problemreductions::rules::registry::{ReductionEntry, ReductionParameterDeclarations}; -use problemreductions::rules::{AggregateReductionResult, VariantReductionResult}; +use problemreductions::solvers::SolutionAggregate; use problemreductions::traits::Problem; -use problemreductions::types::{Aggregate, Extremum, Max, SolutionAggregate}; +use problemreductions::types::{Aggregate, Max}; use serde::{Deserialize, Serialize}; use std::any::Any; use std::collections::BTreeMap; @@ -67,8 +67,12 @@ impl Problem for AggregateValueSource { } impl problemreductions::solvers::BruteForceProblem for AggregateValueSource { - fn dimensions(&self) -> Vec { - vec![2; self.values.len()] + fn num_variables(&self) -> Result { + Ok(self.values.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -109,26 +113,12 @@ impl Problem for AggregateValueTarget { } impl problemreductions::solvers::BruteForceProblem for AggregateValueTarget { - fn dimensions(&self) -> Vec { - vec![2] + fn num_variables(&self) -> Result { + Ok(1usize) } -} -#[derive(Debug, Clone)] -struct AggregateValueToIlpReduction { - target: ILP, -} - -impl AggregateReductionResult for AggregateValueToIlpReduction { - type Source = AggregateValueSource; - type Target = ILP; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, _target_value: Extremum) -> Max { - Max(Some(0)) + fn dimension(&self, variable: usize) -> Result { + Ok([2][variable]) } } @@ -139,24 +129,23 @@ 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 mut current = 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()) - }) - })? + Some(vec![0; dimensions.len()]) }; - Ok((0..total).map(move |mut index| { - let mut coordinates = vec![0; dimensions.len()]; + Ok(std::iter::from_fn(move || { + let result = current.take()?; + let mut next = result.clone(); for position in (0..dimensions.len()).rev() { - coordinates[position] = index % dimensions[position]; - index /= dimensions[position]; + next[position] += 1; + if next[position] < dimensions[position] { + current = Some(next); + break; + } + next[position] = 0; } - coordinates + Some(result) })) } @@ -166,7 +155,7 @@ where P::Value: Aggregate, { let mut total = P::Value::identity(); - for indices in cartesian_indices(problem.dimensions())? { + for indices in cartesian_indices(problemreductions::solvers::cartesian_dimensions(problem)?)? { total = total.combine(problem.evaluate(&decode_bits(indices))?)?; } Ok(total) @@ -180,7 +169,7 @@ where P::Value: SolutionAggregate, { let total = solve_cartesian(problem)?; - for indices in cartesian_indices(problem.dimensions())? { + for indices in cartesian_indices(problemreductions::solvers::cartesian_dimensions(problem)?)? { let solution = decode_bits(indices); let value = problem.evaluate(&solution)?; if P::Value::contributes_to_solution(&value, &total) { @@ -199,7 +188,7 @@ where { let total = solve_cartesian(problem)?; let mut witnesses = Vec::new(); - for indices in cartesian_indices(problem.dimensions())? { + for indices in cartesian_indices(problemreductions::solvers::cartesian_dimensions(problem)?)? { let solution = decode_bits(indices); let value = problem.evaluate(&solution)?; if P::Value::contributes_to_solution(&value, &total) { @@ -331,7 +320,7 @@ problemreductions::inventory::submit! { let problem = any .downcast_ref::() .expect("AggregateValueSource brute-force dimensions type mismatch"); - problemreductions::solvers::BruteForceProblem::dimensions(problem) + problemreductions::solvers::cartesian_dimensions(problem) }, solve_fn: solve_dynamic::, solve_typed_fn: solve_typed::, @@ -379,7 +368,7 @@ problemreductions::inventory::submit! { let problem = any .downcast_ref::() .expect("AggregateValueTarget brute-force dimensions type mismatch"); - problemreductions::solvers::BruteForceProblem::dimensions(problem) + problemreductions::solvers::cartesian_dimensions(problem) }, solve_fn: solve_dynamic::, solve_typed_fn: solve_typed::, @@ -403,16 +392,6 @@ problemreductions::inventory::submit! { }, module_path: module_path!(), reduce_fn: None, - reduce_aggregate_fn: Some(|any: &dyn Any| { - let source = any - .downcast_ref::() - .expect("aggregate reduction downcast failed"); - Ok(Box::new(VariantReductionResult::::new( - AggregateValueTarget { - base: source.values.iter().sum(), - }, - ))) - }), turing: false, } } @@ -443,15 +422,6 @@ problemreductions::inventory::submit! { }, module_path: module_path!(), reduce_fn: None, - reduce_aggregate_fn: Some(|any: &dyn Any| { - let _source = any - .downcast_ref::() - .expect("aggregate ILP reduction downcast failed"); - Ok(Box::new(AggregateValueToIlpReduction { - target: ILP::new(0, vec![], vec![], ObjectiveSense::Minimize) - .expect("empty ILP is valid"), - })) - }), turing: false, } } @@ -490,3 +460,6 @@ pub(crate) fn aggregate_bundle() -> ReductionBundle { ], } } + +problemreductions::impl_dyn_problem!(AggregateValueSource); +problemreductions::impl_dyn_problem!(AggregateValueTarget); diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 90224cc29..a1f7d404e 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -116,7 +116,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")); @@ -761,6 +761,73 @@ fn test_evaluate() { std::fs::remove_file(&tmp).ok(); } +#[test] +fn test_legacy_dense_qubo_json_evaluates_and_solves() { + // QUBO files written before sparse storage persist `{num_vars, matrix: [[...]]}`. + let tmp = std::env::temp_dir().join("pred_test_legacy_dense_qubo.json"); + std::fs::write( + &tmp, + format!( + r#"{{"type":"QUBO","variant":{{"weight":"i64"}},"data":{}}}"#, + include_str!("../../tests/data/qubo_legacy_dense.json") + ), + ) + .unwrap(); + + for (args, expected) in [ + ( + vec!["evaluate", "--config", "[true,false,true]"], + serde_json::json!({"problem": "QUBO", "config": [true, false, true], "result": "Min(-3)"}), + ), + ( + vec!["solve", "--solver", "brute-force"], + serde_json::json!({ + "problem": "QUBO", + "status": "optimal", + "solution": [false, false, true], + "evaluation": "Min(-6)", + "solver": {"kind": "brute-force"}, + }), + ), + ] { + let output = pred() + .args(["--json", args[0], tmp.to_str().unwrap()]) + .args(&args[1..]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json, expected); + } + std::fs::remove_file(&tmp).ok(); +} + +#[test] +fn test_create_qubo_writes_sparse_entries() { + let output = pred() + .args(["create", "QUBO", "--matrix", "3,-5,0;0,0,7;0,0,-6"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "type": "QUBO", + "variant": {"weight": "i64"}, + "data": {"num_vars": 3, "entries": [[0, 0, 3], [0, 1, -5], [1, 2, 7], [2, 2, -6]]}, + }) + ); +} + #[test] fn test_evaluate_sat() { let problem_json = r#"{ @@ -7845,7 +7912,7 @@ fn test_evaluate_multiprocessor_scheduling_rejects_zero_processors_json() { ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("expected positive integer, got 0"), + stderr.contains("num_processors must be positive"), "stderr: {stderr}" ); @@ -9598,13 +9665,20 @@ 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 result_file = + std::env::temp_dir().join("test_extract_roundtrip_mis_to_qubo_target_result.json"); + std::fs::write( + &result_file, + format!(r#"{{"status":"optimal","solution":{}}}"#, target_cfg), + ) + .unwrap(); let extract_out = pred() .args([ "--json", "extract", bundle_file.to_str().unwrap(), - "--config", - &target_cfg, + "--result", + result_file.to_str().unwrap(), ]) .output() .unwrap(); @@ -9616,8 +9690,8 @@ fn test_extract_roundtrip_mis_to_qubo() { let stdout = String::from_utf8(extract_out.stdout).unwrap(); 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_eq!(json["intermediate"]["problem"], "QUBO"); + assert_eq!(json["solver"]["kind"], "external"); // 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"); @@ -9627,6 +9701,48 @@ fn test_extract_roundtrip_mis_to_qubo() { let expected_target: serde_json::Value = serde_json::from_str(&target_cfg).unwrap(); assert_eq!(json["intermediate"]["solution"], expected_target); + // Our own result JSON is valid input, including its evaluation and metadata. + std::fs::write(&result_file, json["intermediate"].to_string()).unwrap(); + let roundtrip = pred() + .args([ + "--json", + "extract", + bundle_file.to_str().unwrap(), + "--result", + result_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + roundtrip.status.success(), + "{}", + String::from_utf8_lossy(&roundtrip.stderr) + ); + let roundtrip: serde_json::Value = serde_json::from_slice(&roundtrip.stdout).unwrap(); + assert_eq!(roundtrip["evaluation"], json["evaluation"]); + + for evaluation in [ + serde_json::json!("Min(999)"), + serde_json::json!("garbage"), + serde_json::Value::Null, + ] { + let mut input = json["intermediate"].clone(); + input["evaluation"] = evaluation; + std::fs::write(&result_file, input.to_string()).unwrap(); + let rejected = pred() + .args([ + "--json", + "extract", + bundle_file.to_str().unwrap(), + "--result", + result_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(!rejected.status.success()); + assert!(String::from_utf8_lossy(&rejected.stderr).contains("evaluation")); + } + // Source config is over 4 MIS variables and must describe an independent set // whose size matches `expected_source_eval` (e.g. "Max(2)" -> 2 ones). let source_sol: Vec = json["solution"] @@ -9645,10 +9761,11 @@ fn test_extract_roundtrip_mis_to_qubo() { std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); + std::fs::remove_file(&result_file).unwrap(); } #[test] -fn test_extract_rejects_structurally_invalid_one_hot_config() { +fn test_extract_decodes_a_qualifying_tour() { let problem_file = std::env::temp_dir().join("pred_test_extract_tsp_in.json"); let bundle_file = std::env::temp_dir().join("pred_test_extract_tsp_bundle.json"); @@ -9684,24 +9801,38 @@ fn test_extract_rejects_structurally_invalid_one_hot_config() { String::from_utf8_lossy(&reduce_out.stderr) ); + let result_file = + std::env::temp_dir().join("test_extract_decodes_a_qualifying_tour_target_result.json"); + std::fs::write( + &result_file, + format!( + r#"{{"status":"optimal","solution":{}}}"#, + "[true,false,false,false,true,false,false,false,true]" + ), + ) + .unwrap(); let extract_out = pred() .args([ + "--json", "extract", bundle_file.to_str().unwrap(), - "--config", - "[false,false,false,false,false,false,false,false,false]", + "--result", + result_file.to_str().unwrap(), ]) .output() .unwrap(); - 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"), - "unexpected stderr: {stderr}" + extract_out.status.success(), + "{}", + String::from_utf8_lossy(&extract_out.stderr) ); + let json: serde_json::Value = serde_json::from_slice(&extract_out.stdout).unwrap(); + assert_eq!(json["solution"], serde_json::json!([true, true, true])); + assert_eq!(json["evaluation"], "Min(3)"); std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); + std::fs::remove_file(&result_file).unwrap(); } #[test] @@ -9721,23 +9852,34 @@ fn test_extract_rejects_plain_problem_file() { .unwrap(); assert!(create_out.status.success()); + let result_file = + std::env::temp_dir().join("test_extract_rejects_plain_problem_file_target_result.json"); + std::fs::write( + &result_file, + format!( + r#"{{"status":"optimal","solution":{}}}"#, + "[false,true,false]" + ), + ) + .unwrap(); let extract_out = pred() .args([ "extract", problem_file.to_str().unwrap(), - "--config", - "[false,true,false]", + "--result", + result_file.to_str().unwrap(), ]) .output() .unwrap(); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( - stderr.contains("not a reduction bundle"), + stderr.contains("requires a reduction bundle"), "unexpected stderr: {stderr}" ); std::fs::remove_file(&problem_file).ok(); + std::fs::remove_file(&result_file).unwrap(); } #[test] @@ -9770,12 +9912,19 @@ fn test_extract_rejects_wrong_config_length() { &bundle_file, ); + let result_file = + std::env::temp_dir().join("test_extract_rejects_wrong_config_length_target_result.json"); + std::fs::write( + &result_file, + format!(r#"{{"status":"optimal","solution":{}}}"#, "[false,true]"), + ) + .unwrap(); let extract_out = pred() .args([ "extract", bundle_file.to_str().unwrap(), - "--config", - "[false,true]", + "--result", + result_file.to_str().unwrap(), ]) .output() .unwrap(); @@ -9788,6 +9937,7 @@ fn test_extract_rejects_wrong_config_length() { std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); + std::fs::remove_file(&result_file).unwrap(); } #[test] @@ -9827,24 +9977,32 @@ fn test_extract_rejects_non_boolean_solution_value() { bad_cfg.as_array_mut().unwrap()[0] = serde_json::json!(9); let bad_cfg = bad_cfg.to_string(); + let result_file = std::env::temp_dir() + .join("test_extract_rejects_non_boolean_solution_value_target_result.json"); + std::fs::write( + &result_file, + format!(r#"{{"status":"optimal","solution":{}}}"#, bad_cfg), + ) + .unwrap(); let extract_out = pred() .args([ "extract", bundle_file.to_str().unwrap(), - "--config", - &bad_cfg, + "--result", + result_file.to_str().unwrap(), ]) .output() .unwrap(); assert!(!extract_out.status.success()); let stderr = String::from_utf8(extract_out.stderr).unwrap(); assert!( - stderr.contains("invalid solution JSON"), + stderr.contains("invalid target result JSON"), "unexpected stderr: {stderr}" ); std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); + std::fs::remove_file(&result_file).unwrap(); } #[test] @@ -9887,12 +10045,22 @@ 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 result_file = std::env::temp_dir() + .join("test_extract_rejects_malformed_bundle_path_source_mismatch_target_result.json"); + std::fs::write( + &result_file, + format!( + r#"{{"status":"optimal","solution":{}}}"#, + "[false,true,false]" + ), + ) + .unwrap(); let extract_out = pred() .args([ "extract", tampered_file.to_str().unwrap(), - "--config", - "[false,true,false]", + "--result", + result_file.to_str().unwrap(), ]) .output() .unwrap(); @@ -9910,6 +10078,7 @@ fn test_extract_rejects_malformed_bundle_path_source_mismatch() { std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); std::fs::remove_file(&tampered_file).ok(); + std::fs::remove_file(&result_file).unwrap(); } #[test] @@ -9950,19 +10119,26 @@ 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 result_file = + std::env::temp_dir().join("test_extract_rejects_tampered_target_data_target_result.json"); + std::fs::write( + &result_file, + format!(r#"{{"status":"optimal","solution":{}}}"#, target_cfg), + ) + .unwrap(); let extract_out = pred() .args([ "extract", tampered_file.to_str().unwrap(), - "--config", - &target_cfg, + "--result", + result_file.to_str().unwrap(), ]) .output() .unwrap(); @@ -9998,6 +10174,7 @@ fn test_extract_rejects_tampered_target_data() { std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); std::fs::remove_file(&tampered_file).ok(); + std::fs::remove_file(&result_file).unwrap(); } #[test] @@ -10035,8 +10212,21 @@ fn test_extract_reads_bundle_from_stdin() { let (target_cfg, _) = extract_test_solve_bundle(&bundle_file); let bundle_text = std::fs::read_to_string(&bundle_file).unwrap(); + let result_file = + std::env::temp_dir().join("test_extract_reads_bundle_from_stdin_target_result.json"); + std::fs::write( + &result_file, + format!(r#"{{"status":"optimal","solution":{}}}"#, target_cfg), + ) + .unwrap(); let mut child = pred() - .args(["--json", "extract", "-", "--config", &target_cfg]) + .args([ + "--json", + "extract", + "-", + "--result", + result_file.to_str().unwrap(), + ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -10057,10 +10247,390 @@ fn test_extract_reads_bundle_from_stdin() { let stdout = String::from_utf8(output.stdout).unwrap(); 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_eq!(json["intermediate"]["problem"], "QUBO"); + assert_eq!(json["solver"]["kind"], "external"); assert_eq!(json["evaluation"].as_str().unwrap(), "Max(2)"); std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); + std::fs::remove_file(&result_file).unwrap(); +} + +#[test] +fn test_create_decision_closest_vector_preserves_integer_bound() { + let bound = serde_json::json!(3); + let output = pred() + .args([ + "create", + "DecisionClosestVectorProblem", + "--basis", + "1", + "--target-vec", + "0", + "--bound", + &bound.to_string(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let instance: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(instance["type"], "DecisionClosestVectorProblem"); + assert_eq!(instance["data"]["bound"], bound); + assert_eq!(instance["data"]["inner"]["basis"], serde_json::json!([[1]])); +} + +#[test] +fn test_extract_preserves_feasible_status_and_rejects_invalid_witnesses() { + let directory = std::env::temp_dir().join("pred_extract_complete_results"); + std::fs::create_dir_all(&directory).unwrap(); + let source_file = directory.join("source.json"); + let bundle_file = directory.join("bundle.json"); + let result_file = directory.join("result.json"); + let source = problemreductions::models::graph::MaximumIndependentSet::new( + problemreductions::topology::SimpleGraph::path(3), + vec![1i64; 3], + ); + std::fs::write( + &source_file, + serde_json::json!({ + "type": "MaximumIndependentSet", "variant": {"graph":"SimpleGraph", "weight":"i64"}, + "data": source, + }) + .to_string(), + ) + .unwrap(); + let reduced = reduce_named_to_file( + &source_file, + "MIS/SimpleGraph/i64", + "MVC/SimpleGraph/i64", + &["MaximumIndependentSet", "MinimumVertexCover"], + &bundle_file, + ); + assert!( + reduced.status.success(), + "{}", + String::from_utf8_lossy(&reduced.stderr) + ); + for (solution, valid) in [ + (vec![true, true, true], true), + (vec![false, false, false], false), + ] { + std::fs::write( + &result_file, + serde_json::json!({ + "status":"feasible", "solution":solution, + }) + .to_string(), + ) + .unwrap(); + let output = pred() + .args([ + "--json", + "extract", + bundle_file.to_str().unwrap(), + "--result", + result_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert_eq!( + output.status.success(), + valid, + "{}", + String::from_utf8_lossy(&output.stderr) + ); + if valid { + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["status"], "feasible"); + assert_eq!(result["solution"], serde_json::json!([false, false, false])); + assert_eq!(result["evaluation"], "Max(0)"); + assert_eq!(result["intermediate"]["status"], "feasible"); + assert_eq!(result["intermediate"]["evaluation"], "Min(3)"); + } else { + assert!(String::from_utf8_lossy(&output.stderr) + .contains("candidate solution violates the problem constraints")); + } + } + std::fs::remove_dir_all(directory).unwrap(); +} + +/// Reduce a 4-vertex path MIS to QUBO in a fresh directory; the final +/// MaximumSetPacking -> QUBO step cannot recover from unproven candidates. +fn extract_test_mis_qubo_bundle(name: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let directory = std::env::temp_dir().join(name); + std::fs::create_dir_all(&directory).unwrap(); + let problem_file = directory.join("source.json"); + let bundle_file = directory.join("bundle.json"); + let created = pred() + .args(["-o", problem_file.to_str().unwrap()]) + .args(["create", "MIS", "--graph", "0-1,1-2,2-3"]) + .output() + .unwrap(); + assert!(created.status.success()); + let reduced = reduce_named_to_file( + &problem_file, + "MIS/SimpleGraph/One", + "QUBO/f64", + &[ + "MaximumIndependentSet", + "MaximumIndependentSet", + "MaximumSetPacking", + "MaximumSetPacking", + "QUBO", + ], + &bundle_file, + ); + assert!( + reduced.status.success(), + "{}", + String::from_utf8_lossy(&reduced.stderr) + ); + (directory, bundle_file) +} + +fn extract_test_run( + directory: &std::path::Path, + bundle_file: &std::path::Path, + result: &str, +) -> std::process::Output { + let result_file = directory.join("result.json"); + std::fs::write(&result_file, result).unwrap(); + pred() + .args(["--json", "extract", bundle_file.to_str().unwrap()]) + .args(["--result", result_file.to_str().unwrap()]) + .output() + .unwrap() +} + +#[test] +fn test_extract_rejects_bare_configuration_with_expected_shape() { + let (directory, bundle_file) = extract_test_mis_qubo_bundle("pred_extract_bare_configuration"); + for result in [ + "[0,1,0,1]", + r#"{"solution":[false,true,false,true]}"#, + r#"{"status":"optimal"}"#, + r#"{"status":"sampled","solution":[false,true,false,true]}"#, + ] { + let output = extract_test_run(&directory, &bundle_file, result); + assert!(!output.status.success(), "accepted {result}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(r#"wrap it as {"status": "feasible", "solution": [...]}"#), + "{result}: {stderr}" + ); + } + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn test_extract_infeasible_target_result() { + let (directory, bundle_file) = extract_test_mis_qubo_bundle("pred_extract_infeasible_result"); + let output = extract_test_run(&directory, &bundle_file, r#"{"status":"infeasible"}"#); + 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, + serde_json::json!({ + "problem": "MaximumIndependentSet", + "status": "infeasible", + "solver": {"kind": "external"}, + "intermediate": {"problem": "QUBO", "status": "infeasible"}, + }) + ); + + let output = extract_test_run( + &directory, + &bundle_file, + r#"{"status":"infeasible","evaluation":"Min(0)"}"#, + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("infeasible results must not contain evaluation")); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn test_extract_rejects_feasible_result_the_rule_cannot_use() { + let (directory, bundle_file) = + extract_test_mis_qubo_bundle("pred_extract_insufficient_quality"); + let solution = "[false,true,false,true]"; + let output = extract_test_run( + &directory, + &bundle_file, + &format!(r#"{{"status":"feasible","solution":{solution}}}"#), + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + // The error names the hop that rejected the incumbent, not the chain's endpoints. + assert!(String::from_utf8_lossy(&output.stderr).contains( + "MaximumSetPacking -> QUBO: the target result does not establish the conditions \ + required for source recovery" + )); + + // The same witness is usable once the solver claims optimality. + let output = extract_test_run( + &directory, + &bundle_file, + &format!(r#"{{"status":"optimal","solution":{solution}}}"#), + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + std::fs::remove_dir_all(directory).unwrap(); +} + +const EXTRACT_OPTIMALITY_NOTE: &str = + "note: source infeasibility rests on the external solver's optimality claim"; +const EXTRACT_INFEASIBILITY_NOTE: &str = + "note: source infeasibility rests on the external solver's infeasibility claim"; + +fn extract_test_partition_knapsack_bundle( + name: &str, + sizes: &str, +) -> (std::path::PathBuf, std::path::PathBuf) { + let directory = std::env::temp_dir().join(name); + std::fs::create_dir_all(&directory).unwrap(); + let problem_file = directory.join("source.json"); + let bundle_file = directory.join("bundle.json"); + let created = pred() + .args(["-o", problem_file.to_str().unwrap()]) + .args(["create", "Partition", "--sizes", sizes]) + .output() + .unwrap(); + assert!(created.status.success()); + let reduced = reduce_named_to_file( + &problem_file, + "Partition", + "Knapsack", + &["Partition", "Knapsack"], + &bundle_file, + ); + assert!( + reduced.status.success(), + "{}", + String::from_utf8_lossy(&reduced.stderr) + ); + (directory, bundle_file) +} + +#[test] +fn test_extract_notes_source_infeasibility_resting_on_an_external_claim() { + // Total 11 is odd: the Knapsack optimum 5 decodes to an unbalanced partition. + let (directory, bundle_file) = + extract_test_partition_knapsack_bundle("pred_extract_external_claim_note", "2,4,5"); + for (result, note) in [ + ( + r#"{"status":"optimal","solution":[false,false,true]}"#, + EXTRACT_OPTIMALITY_NOTE, + ), + (r#"{"status":"infeasible"}"#, EXTRACT_INFEASIBILITY_NOTE), + ] { + let output = extract_test_run(&directory, &bundle_file, result); + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(stderr.lines().count(), 1, "{stderr}"); + assert!(stderr.starts_with(note), "{stderr}"); + + // The note never reaches stdout: --quiet drops it and leaves the JSON byte-identical. + let quiet = pred() + .args(["--quiet", "--json", "extract"]) + .arg(&bundle_file) + .arg("--result") + .arg(directory.join("result.json")) + .output() + .unwrap(); + assert!(quiet.stderr.is_empty()); + assert_eq!(output.stdout, quiet.stdout); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["status"], "infeasible"); + assert!(!String::from_utf8_lossy(&output.stdout).contains("note:")); + } + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn test_extract_has_no_claim_note_when_a_source_solution_is_recovered() { + let (directory, bundle_file) = + extract_test_partition_knapsack_bundle("pred_extract_no_claim_note", "3,1,1,2,2,1"); + for status in ["feasible", "optimal"] { + let output = extract_test_run( + &directory, + &bundle_file, + &format!(r#"{{"status":"{status}","solution":[true,false,false,true,false,false]}}"#), + ); + assert!(output.status.success()); + assert!( + output.stderr.is_empty(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["status"], status); + } + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn test_cvp_i64_create_and_solve() { + use std::io::Write; + use std::process::Stdio; + let created = pred() + .args([ + "create", + "CVP/i64", + "--basis", + "2,0;1,2", + "--target-vec", + "3,2", + ]) + .output() + .unwrap(); + assert!( + created.status.success(), + "{}", + String::from_utf8_lossy(&created.stderr) + ); + let instance: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap(); + assert_eq!(instance["variant"]["coefficient"], "i64"); + let mut child = pred() + .args(["solve", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(&created.stdout) + .unwrap(); + let solved = child.wait_with_output().unwrap(); + assert!( + solved.status.success(), + "{}", + String::from_utf8_lossy(&solved.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&solved.stdout).unwrap(); + assert_eq!(result["status"], "optimal"); + assert_eq!(result["solution"], serde_json::json!([1, 1])); + let display = result["evaluation"].as_str().unwrap(); + let value: f64 = display + .strip_prefix("Min(") + .unwrap() + .strip_suffix(')') + .unwrap() + .parse() + .unwrap(); + assert!(value.abs() < 1e-12); } diff --git a/problemreductions-cli/tests/mcp_integration.rs b/problemreductions-cli/tests/mcp_integration.rs index 5542dd1f9..b233f1391 100644 --- a/problemreductions-cli/tests/mcp_integration.rs +++ b/problemreductions-cli/tests/mcp_integration.rs @@ -115,7 +115,7 @@ mod mcp_tests { } #[test] - fn test_mcp_server_initialize_and_list_tools() { + fn test_mcp_server_initialize_list_and_call_tools() { let (mut stdin, mut reader, child) = spawn_mcp(); initialize(&mut stdin, &mut reader); @@ -192,11 +192,30 @@ mod mcp_tests { ); } + send( + &mut stdin, + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "list_problems", "arguments": {}} + }), + ); + let call_resp = read_response(&mut reader); + assert_eq!(call_resp["id"], 3); + assert!(call_resp.get("error").is_none(), "{call_resp}"); + assert_ne!(call_resp["result"]["isError"], true); + assert!(call_resp["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("MaximumIndependentSet")); + assert!(call_resp["result"].get("resultType").is_none()); + shutdown(stdin, child); } #[test] - fn test_mcp_server_prompts_list() { + fn test_mcp_server_prompts_list_and_get() { let (mut stdin, mut reader, child) = spawn_mcp(); initialize(&mut stdin, &mut reader); @@ -244,6 +263,39 @@ mod mcp_tests { assert!(prompt_names.contains(&"find_reduction")); assert!(prompt_names.contains(&"overview")); + send( + &mut stdin, + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "prompts/get", + "params": {"name": "what_is", "arguments": {"problem": "QUBO"}} + }), + ); + let prompt_resp = read_response(&mut reader); + assert_eq!(prompt_resp["id"], 3); + let message = &prompt_resp["result"]["messages"][0]; + assert_eq!(message["role"], "user"); + assert_eq!(message["content"]["type"], "text"); + assert!(message["content"]["text"] + .as_str() + .unwrap() + .contains("QUBO")); + + send( + &mut stdin, + &serde_json::json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "prompts/get", + "params": {"name": "unknown"} + }), + ); + let error_resp = read_response(&mut reader); + assert_eq!(error_resp["id"], 4); + assert_eq!(error_resp["error"]["code"], -32602); + assert_eq!(error_resp["error"]["message"], "Unknown prompt: unknown"); + shutdown(stdin, child); } } diff --git a/problemreductions-expr/Cargo.toml b/problemreductions-expr/Cargo.toml index d19a1b770..09304c30f 100644 --- a/problemreductions-expr/Cargo.toml +++ b/problemreductions-expr/Cargo.toml @@ -7,11 +7,11 @@ license = "MIT" repository = "https://github.com/CodingThrust/problem-reductions" [dependencies] -num-bigint = { version = "0.4", features = ["serde"] } -num-rational = { version = "0.4", features = ["serde"] } -num-traits = "0.2" -serde = { version = "1.0", features = ["derive"] } -thiserror = "2.0" +num-bigint = { version = "0.4.8", features = ["serde"] } +num-rational = { version = "0.4.2", features = ["serde"] } +num-traits = "0.2.19" +serde = { version = "1.0.229", features = ["derive"] } +thiserror = "2.0.20" [dev-dependencies] -serde_json = "1.0" +serde_json = "1.0.151" diff --git a/problemreductions-macros/Cargo.toml b/problemreductions-macros/Cargo.toml index 16b94ead5..d20910c69 100644 --- a/problemreductions-macros/Cargo.toml +++ b/problemreductions-macros/Cargo.toml @@ -10,8 +10,8 @@ repository = "https://github.com/CodingThrust/problem-reductions" proc-macro = true [dependencies] -syn = { version = "2.0", features = ["full", "parsing"] } -quote = "1.0" -proc-macro2 = "1.0" +syn = { version = "3.0.6", features = ["full", "parsing"] } +quote = "1.0.47" +proc-macro2 = "1.0.107" problemreductions-expr = { version = "0.6.0", path = "../problemreductions-expr" } -num-traits = "0.2" +num-traits = "0.2.19" diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 9ee54082e..1b3610241 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 @@ -239,7 +237,6 @@ struct ReductionAttrs { relation: Option, fields: Option>, unavailable: Option>, - aggregate: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -255,7 +252,6 @@ impl syn::parse::Parse for ReductionAttrs { relation: None, fields: None, unavailable: None, - aggregate: false, }; while !input.is_empty() { @@ -305,16 +301,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 +387,8 @@ fn extract_type_name(ty: &Type) -> Option { Some(ident) } + // A `$ty:ty` fragment forwarded by `macro_rules!` arrives in an invisible group. + Type::Group(group) => extract_type_name(&group.elem), _ => None, } } @@ -503,7 +491,7 @@ fn generate_reduction_entry( let trait_path = impl_block .trait_ .as_ref() - .map(|(_, path, _)| path) + .map(|(path, _)| path) .ok_or_else(|| syn::Error::new_spanned(impl_block, "Expected impl ReduceTo for S"))?; // Extract target type from ReduceTo @@ -517,20 +505,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); @@ -572,14 +546,16 @@ fn generate_reduction_entry( unavailable: vec![#(#unavailable_tokens),*], }, module_path: module_path!(), - reduce_fn: Some(|src: &dyn std::any::Any| -> Result, crate::rules::ReductionError> { + reduce_fn: Some(|src: &dyn std::any::Any| -> Result { 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)) + let result = std::rc::Rc::new(result); + Ok(crate::rules::registry::ExecutedStep { + witness: result, + }) }), - reduce_aggregate_fn: #reduce_aggregate_fn, turing: false, } } @@ -787,7 +763,7 @@ pub fn register_brute_force(input: TokenStream) -> TokenStream { let problem = any .downcast_ref::<#ty>() .expect("brute-force registration received the wrong problem type"); - <#ty as crate::solvers::BruteForceProblem>::dimensions(problem) + crate::solvers::cartesian_dimensions(problem) }, solve_fn: |any| { let problem = any @@ -944,6 +920,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result").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! { @@ -1278,24 +1270,25 @@ mod tests { 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! { + transform = exact { num_vertices = "num_vertices" } + }) + .unwrap(); + let tokens = generate_reduction_entry(&attrs, &implementation) + .unwrap() + .to_string(); + assert!(tokens.contains("reduce_fn : Some")); + assert!(tokens.contains("ExecutedStep")); + for declaration in [ + quote! { aggregate = identity }, + quote! { aggregate = custom }, + quote! { aggregate = unknown }, ] { - let attrs: ReductionAttrs = syn::parse2(quote! { - #declaration transform = exact { num_vertices = "num_vertices" } + assert!(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); + .is_err()); } - assert!(syn::parse2::(quote! { - aggregate = unknown, transform = exact { num_vertices = "num_vertices" } - }) - .is_err()); } #[test] diff --git a/scripts/build_website.py b/scripts/build_website.py index 33a36f40c..3f5ab2898 100644 --- a/scripts/build_website.py +++ b/scripts/build_website.py @@ -173,8 +173,8 @@ def build(output, graph_path, schemas_path): # Rule modules are private; rustdoc publishes the shared public contracts. contract = ( "rules/enum.ReductionMode.html" if edge.get("turing") else - "rules/trait.ReduceTo.html" if edge.get("witness") else - "rules/trait.ReduceToAggregate.html" + "rules/trait.ReductionResult.html" if edge.get("witness") else + "rules/struct.ReductionEntry.html" ) site_edges.append({ **edge, diff --git a/src/example_db/specs.rs b/src/example_db/specs.rs index 75f2210f4..7dac2acf1 100644 --- a/src/example_db/specs.rs +++ b/src/example_db/specs.rs @@ -95,7 +95,7 @@ fn rule_example_via_typed_ilp(source: S) -> RuleExample where S: Problem + Serialize + ReduceTo>, V: crate::models::algebraic::VariableDomain, - C: crate::models::algebraic::ILPCoefficient + Serialize, + C: crate::models::algebraic::ILPCoefficient + Serialize + serde::de::DeserializeOwned, >>::Result: ReductionResult>, S::Solution: Serialize, @@ -105,7 +105,15 @@ where let ilp_solution = crate::solvers::ILPSolver::new() .solve(reduction.target_problem()) .expect("canonical example must be ILP-solvable"); - let source_config = reduction.extract_solution(&ilp_solution).unwrap(); + let source_config = reduction + .recover_result( + &source, + crate::solvers::SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .unwrap(); assemble_rule_example( &source, reduction.target_problem(), diff --git a/src/lib.rs b/src/lib.rs index 69381f493..e84718db2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,8 +75,8 @@ pub mod prelude { MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartialFeedbackEdgeSet, PartitionIntoCliques, PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, - ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, - UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, + ShortestWeightConstrainedPath, TravelingSalesman, UndirectedFlowLowerBounds, + UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation, @@ -101,8 +101,8 @@ pub mod prelude { // Core traits pub use crate::rules::{ReduceTo, ReductionResult}; - pub use crate::solvers::BruteForce; - pub use crate::traits::Problem; + pub use crate::solvers::{BruteForce, ProblemOutcome, SolveOutcome}; + pub use crate::traits::{EvaluationValue, Problem}; // Types pub use crate::error::{ProblemError, Result}; @@ -120,7 +120,7 @@ pub use expr::{ pub use growth::Growth; pub use registry::{ComplexityClass, ProblemInfo}; pub use solvers::BruteForce; -pub use traits::Problem; +pub use traits::{EvaluationValue, Problem}; pub use types::{ And, Extremum, ExtremumSense, Max, Min, NumericSize, One, Or, ProblemParameters, Sum, WeightElement, diff --git a/src/models/algebraic/algebraic_equations_over_gf2.rs b/src/models/algebraic/algebraic_equations_over_gf2.rs index 32649b5f2..57d09ef2d 100644 --- a/src/models/algebraic/algebraic_equations_over_gf2.rs +++ b/src/models/algebraic/algebraic_equations_over_gf2.rs @@ -204,8 +204,12 @@ impl Problem for AlgebraicEquationsOverGF2 { } impl crate::solvers::BruteForceProblem for AlgebraicEquationsOverGF2 { - fn dimensions(&self) -> Vec { - vec![2; self.num_variables] + fn num_variables(&self) -> Result { + Ok(self.num_variables) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/algebraic/bmf.rs b/src/models/algebraic/bmf.rs index 52aeb23e8..c089e640f 100644 --- a/src/models/algebraic/bmf.rs +++ b/src/models/algebraic/bmf.rs @@ -54,6 +54,7 @@ inventory::submit! { /// assert!(problem.is_exact(&witness).unwrap()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "BMFData")] pub struct BMF { /// The target matrix A (m x n). matrix: Vec>, @@ -65,6 +66,20 @@ pub struct BMF { k: usize, } +#[derive(Deserialize)] +struct BMFData { + matrix: Vec>, + k: usize, +} + +impl TryFrom for BMF { + type Error = crate::registry::ConstructionError; + + fn try_from(data: BMFData) -> Result { + Self::try_new(data.matrix, data.k) + } +} + impl BMF { /// Create a new BMF problem. /// @@ -72,15 +87,19 @@ impl BMF { /// * `matrix` - The target m x n boolean matrix /// * `k` - The factorization rank pub fn new(matrix: Vec>, k: usize) -> Self { - let m = matrix.len(); - let n = if m > 0 { matrix[0].len() } else { 0 }; + Self::try_new(matrix, k).unwrap_or_else(|error| panic!("{error}")) + } - // Validate matrix dimensions - for row in &matrix { - assert_eq!(row.len(), n, "All rows must have the same length"); + fn try_new( + matrix: Vec>, + k: usize, + ) -> Result { + let m = matrix.len(); + let n = matrix.first().map_or(0, Vec::len); + if matrix.iter().any(|row| row.len() != n) { + return Err("all matrix rows must have the same length".into()); } - - Self { matrix, m, n, k } + Ok(Self { matrix, m, n, k }) } /// Get the number of rows. @@ -247,9 +266,20 @@ impl Problem for BMF { } impl crate::solvers::BruteForceProblem for BMF { - fn dimensions(&self) -> Vec { - // B: m*k + C: k*n binary variables - vec![2; self.m * self.k + self.k * self.n] + fn num_variables(&self) -> Result { + ((self.m).checked_mul(self.k).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + })?) + .checked_add((self.k).checked_mul(self.n).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + })?) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/algebraic/closest_vector_problem.rs b/src/models/algebraic/closest_vector_problem.rs index d166b32fb..b62e21989 100644 --- a/src/models/algebraic/closest_vector_problem.rs +++ b/src/models/algebraic/closest_vector_problem.rs @@ -1,297 +1,206 @@ //! Closest Vector Problem (CVP). //! -//! Given an integer lattice basis `B` and a target vector `t`, find integer -//! coefficients `x` minimizing `||Bx - t||_2`. +//! Given a lattice basis `B` and a target vector `t`, find integer +//! 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_rational::BigRational; +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 { + /// Basis matrix as semicolon-separated column vectors. + #[create(codec = "semicolon-separated")] + basis: Vec>, + /// Target vector. + #[create(name = "target_vec", codec = "comma-separated")] + target: Vec, } -impl ClosestVectorTarget for i64 { - const NAME: &'static str = "i64"; - - fn validate(&self, _index: usize) -> Result<(), ConstructionError> { - Ok(()) - } +impl TryFrom for ClosestVectorProblem { + type Error = ConstructionError; - 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 { + ClosestVectorProblem::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, + description: "Find the closest point in a lattice to a target vector", + fields: ClosestVectorProblemCreateSpec::FIELDS, } } -/// Euclidean Closest Vector Problem over an integer lattice basis. +/// Euclidean Closest Vector Problem with integer coordinates. #[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 { - /// Construct a CVP instance with a full-column-rank integer basis. - 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!( - "basis vector {index} has length {}, expected {ambient_dimension}", - column.len() - ))); - } - } - if basis.len() > ambient_dimension { - return Err(ConstructionError::Conversion(format!( - "{} basis vectors cannot be independent in ambient dimension {ambient_dimension}", - basis.len() - ))); - } - if independent_rows(&basis, ambient_dimension)?.is_none() { +impl ClosestVectorProblem { + /// Construct a CVP instance with a full-column-rank basis. + pub fn new(basis: Vec>, target: Vec) -> Result { + let instance = Self { basis, target }; + instance.validate_dimensions()?; + let matrix = (0..instance.ambient_dimension()) + .map(|row| { + instance + .basis + .iter() + .map(|column| BigRational::from_integer(column[row].into())) + .collect() + }) + .collect(); + if independent_rows(matrix, instance.num_basis_vectors()).is_none() { return Err(ConstructionError::Conversion( "closest-vector basis columns must be linearly independent".into(), )); } - Ok(Self { basis, target }) + Ok(instance) } /// Number of basis vectors. pub fn num_basis_vectors(&self) -> usize { self.basis.len() } - /// Dimension of the ambient space. pub fn ambient_dimension(&self) -> usize { self.target.len() } - - /// Integer basis columns. + /// Basis columns in the variant's numeric domain. pub fn basis(&self) -> &[Vec] { &self.basis } - /// 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(|| { - ConstructionError::Conversion( - "closest-vector basis columns must be linearly independent".into(), - ) - }) + fn validate_dimensions(&self) -> Result<(), ConstructionError> { + for (index, column) in self.basis.iter().enumerate() { + if column.len() != self.ambient_dimension() { + return Err(ConstructionError::Conversion(format!( + "basis vector {index} has length {}, expected {}", + column.len(), + self.ambient_dimension() + ))); + } + } + if self.num_basis_vectors() > self.ambient_dimension() { + return Err(ConstructionError::Conversion( + "more basis vectors than ambient dimensions".into(), + )); + } + Ok(()) } -} -fn independent_rows( - basis: &[Vec], - ambient_dimension: usize, -) -> Result>, ConstructionError> { - let num_columns = basis.len(); - if num_columns == 0 { - return Ok(Some(Vec::new())); + fn validate_solution(&self, solution: &[i64]) -> Result<(), EvaluationError> { + if solution.len() != self.num_basis_vectors() { + return Err(EvaluationError::InvalidConfiguration(format!( + "expected {} closest-vector coefficients, got {}", + self.num_basis_vectors(), + solution.len() + ))); + } + Ok(()) } - let mut matrix = (0..ambient_dimension) - .map(|row| basis.iter().map(|column| column[row]).collect::>()) - .collect::>(); - let mut previous_pivot = 1_i64; - 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); - }; - matrix.swap(column, pivot_row); - row_indices.swap(column, pivot_row); - let pivot = matrix[column][column]; + pub(crate) fn independent_rows(&self) -> Vec { + let matrix = (0..self.ambient_dimension()) + .map(|row| { + self.basis + .iter() + .map(|column| BigRational::from_integer(column[row].into())) + .collect() + }) + .collect(); + independent_rows(matrix, self.num_basis_vectors()).expect("validated independent columns") + } - 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)?; + /// Squared distance using checked integer arithmetic. + pub fn squared_distance(&self, solution: &[i64]) -> Result { + self.validate_solution(solution)?; + 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)?; } - matrix[row][column] = 0; + 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)?; } - previous_pivot = pivot; + Ok(squared) } - row_indices.truncate(num_columns); - Ok(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>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { +impl<'de> Deserialize<'de> for ClosestVectorProblem { + fn deserialize>(deserializer: D) -> Result { #[derive(Deserialize)] - struct Raw { + struct Raw { basis: Vec>, - target: Vec, + target: Vec, } - let raw = Raw::deserialize(deserializer)?; Self::new(raw.basis, raw.target).map_err(serde::de::Error::custom) } } -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 { + Ok(Min(Some(self.squared_distance(solution)?))) + } + fn variant() -> Vec<(&'static str, &'static str)> { + vec![("coefficient", "i64")] + } +} - 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 {}", - self.num_basis_vectors(), - solution.len() - ))); - } - - 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; +fn independent_rows(mut matrix: Vec>, n: usize) -> Option> { + let ambient_dimension = matrix.len(); + let mut indices = (0..ambient_dimension).collect::>(); + for column in 0..n { + let pivot_row = (column..ambient_dimension).find(|&row| !matrix[row][column].is_zero())?; + matrix.swap(column, pivot_row); + indices.swap(column, pivot_row); + let (pivots, remaining) = matrix.split_at_mut(column + 1); + let pivot = &pivots[column]; + for row in remaining { + let ratio = &row[column] / &pivot[column]; + for (entry, value) in row.iter_mut().zip(pivot).skip(column + 1) { + *entry -= &ratio * value; } } - - 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()))) - } - - fn variant() -> Vec<(&'static str, &'static str)> { - vec![("target", T::NAME)] } + indices.truncate(n); + Some(indices) } 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 +212,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: "Decision objective bound" }, + ], + } +} +crate::declare_variants! { + default crate::models::decision::Decision => "2^(num_basis_vectors * log(num_basis_vectors))" create crate::models::decision::DecisionCreateSpec, +} +crate::register_decision_variant!(@edges ClosestVectorProblem, "DecisionClosestVectorProblem"); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_closest_vector_problem_to_closest_vector_problem", + build: || { + let source = crate::models::decision::Decision::new( + ClosestVectorProblem::new(vec![vec![2, 0], vec![1, 2]], vec![3_i64, 2]) + .expect("canonical closest-vector instance must be valid"), + 0, + ); + let witness = serde_json::json!(vec![1, 1]); + crate::example_db::specs::rule_example_with_witness::<_, ClosestVectorProblem>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/algebraic/consecutive_block_minimization.rs b/src/models/algebraic/consecutive_block_minimization.rs index b0b4d34ee..cdc67c364 100644 --- a/src/models/algebraic/consecutive_block_minimization.rs +++ b/src/models/algebraic/consecutive_block_minimization.rs @@ -222,8 +222,12 @@ impl Problem for ConsecutiveBlockMinimization { } impl crate::solvers::BruteForceProblem for ConsecutiveBlockMinimization { - fn dimensions(&self) -> Vec { - vec![self.num_cols; self.num_cols] + fn num_variables(&self) -> Result { + Ok(self.num_cols) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_cols) } } diff --git a/src/models/algebraic/consecutive_ones_matrix_augmentation.rs b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs index 0ddbe58a6..a4a186092 100644 --- a/src/models/algebraic/consecutive_ones_matrix_augmentation.rs +++ b/src/models/algebraic/consecutive_ones_matrix_augmentation.rs @@ -22,6 +22,7 @@ inventory::submit! { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ConsecutiveOnesMatrixAugmentationCreateSpec")] pub struct ConsecutiveOnesMatrixAugmentation { matrix: Vec>, bound: i64, @@ -173,8 +174,12 @@ impl Problem for ConsecutiveOnesMatrixAugmentation { } impl crate::solvers::BruteForceProblem for ConsecutiveOnesMatrixAugmentation { - fn dimensions(&self) -> Vec { - vec![self.num_cols(); self.num_cols()] + fn num_variables(&self) -> Result { + Ok(self.num_cols()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_cols()) } } diff --git a/src/models/algebraic/consecutive_ones_submatrix.rs b/src/models/algebraic/consecutive_ones_submatrix.rs index 8c8514e14..eece16118 100644 --- a/src/models/algebraic/consecutive_ones_submatrix.rs +++ b/src/models/algebraic/consecutive_ones_submatrix.rs @@ -59,11 +59,26 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ConsecutiveOnesSubmatrixData")] pub struct ConsecutiveOnesSubmatrix { matrix: Vec>, bound: i64, } +#[derive(Deserialize)] +struct ConsecutiveOnesSubmatrixData { + matrix: Vec>, + bound: i64, +} + +impl TryFrom for ConsecutiveOnesSubmatrix { + type Error = crate::registry::ConstructionError; + + fn try_from(data: ConsecutiveOnesSubmatrixData) -> Result { + Self::try_new(data.matrix, data.bound) + } +} + impl ConsecutiveOnesSubmatrix { /// Create a new ConsecutiveOnesSubmatrix instance. /// @@ -71,19 +86,21 @@ impl ConsecutiveOnesSubmatrix { /// /// Panics if `bound > n`, or if rows have inconsistent lengths. pub fn new(matrix: Vec>, bound: i64) -> Self { - let n = if matrix.is_empty() { - 0 - } else { - matrix[0].len() - }; - for row in &matrix { - assert_eq!(row.len(), n, "All rows must have the same length"); + Self::try_new(matrix, bound).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + matrix: Vec>, + bound: i64, + ) -> Result { + let n = matrix.first().map_or(0, Vec::len); + if matrix.iter().any(|row| row.len() != n) { + return Err("all matrix rows must have the same length".into()); } - assert!( - bound < 0 || usize::try_from(bound).is_ok_and(|bound| bound <= n), - "bound ({bound}) must be <= number of columns ({n})" - ); - Self { matrix, bound } + if !(bound < 0 || usize::try_from(bound).is_ok_and(|bound| bound <= n)) { + return Err(format!("bound ({bound}) must be <= number of columns ({n})").into()); + } + Ok(Self { matrix, bound }) } /// Returns the binary matrix. @@ -215,8 +232,12 @@ impl Problem for ConsecutiveOnesSubmatrix { } impl crate::solvers::BruteForceProblem for ConsecutiveOnesSubmatrix { - fn dimensions(&self) -> Vec { - vec![2; self.num_cols()] + fn num_variables(&self) -> Result { + Ok(self.num_cols()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/algebraic/equilibrium_point.rs b/src/models/algebraic/equilibrium_point.rs index 6d3392c40..e5fdfd024 100644 --- a/src/models/algebraic/equilibrium_point.rs +++ b/src/models/algebraic/equilibrium_point.rs @@ -247,8 +247,12 @@ impl Problem for EquilibriumPoint { } impl crate::solvers::BruteForceProblem for EquilibriumPoint { - fn dimensions(&self) -> Vec { - self.range_sets.iter().map(|m| m.len()).collect() + fn num_variables(&self) -> Result { + Ok(self.range_sets.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.range_sets[variable].len()) } } diff --git a/src/models/algebraic/feasible_basis_extension.rs b/src/models/algebraic/feasible_basis_extension.rs index 88396d820..18fd530fe 100644 --- a/src/models/algebraic/feasible_basis_extension.rs +++ b/src/models/algebraic/feasible_basis_extension.rs @@ -57,6 +57,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "FeasibleBasisExtensionCreateSpec")] pub struct FeasibleBasisExtension { matrix: Vec>, rhs: Vec, @@ -79,38 +80,7 @@ struct FeasibleBasisExtensionCreateSpec { impl TryFrom for FeasibleBasisExtension { type Error = crate::registry::ConstructionError; fn try_from(spec: FeasibleBasisExtensionCreateSpec) -> Result { - let m = spec.matrix.len(); - let first = spec - .matrix - .first() - .ok_or("matrix must have at least one row")?; - let n = first.len(); - if spec.matrix.iter().any(|row| row.len() != n) { - return Err("all matrix rows must have the same length".into()); - } - if m >= n { - return Err("number of rows must be less than number of columns".into()); - } - if spec.rhs.len() != m { - return Err("rhs length must equal number of rows".into()); - } - if spec.required_columns.len() >= m { - return Err("required_columns length must be less than number of rows".into()); - } - let mut seen = std::collections::HashSet::new(); - for &column in &spec.required_columns { - if column >= n { - return Err(format!("required column {column} is out of bounds").into()); - } - if !seen.insert(column) { - return Err(format!("duplicate required column {column}").into()); - } - } - Ok(Self { - matrix: spec.matrix, - rhs: spec.rhs, - required_columns: spec.required_columns, - }) + Self::try_new(spec.matrix, spec.rhs, spec.required_columns) } } @@ -127,46 +97,45 @@ impl FeasibleBasisExtension { /// - Any required column index is out of bounds /// - Required columns contain duplicates pub fn new(matrix: Vec>, rhs: Vec, required_columns: Vec) -> Self { + Self::try_new(matrix, rhs, required_columns).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + matrix: Vec>, + rhs: Vec, + required_columns: Vec, + ) -> Result { let m = matrix.len(); - assert!(m > 0, "Matrix must have at least one row"); - let n = matrix[0].len(); - for row in &matrix { - assert_eq!(row.len(), n, "All rows must have the same length"); + let first = matrix.first().ok_or("matrix must have at least one row")?; + let n = first.len(); + if matrix.iter().any(|row| row.len() != n) { + return Err("all matrix rows must have the same length".into()); + } + if m >= n { + return Err("number of rows must be less than number of columns".into()); } - assert!( - m < n, - "Number of rows ({m}) must be less than number of columns ({n})" - ); - assert_eq!( - rhs.len(), - m, - "rhs length ({}) must equal number of rows ({m})", - rhs.len() - ); - assert!( - required_columns.len() < m, - "|S| ({}) must be less than m ({m})", - required_columns.len() - ); - for &col in &required_columns { - assert!(col < n, "Required column index {col} out of bounds (n={n})"); + if rhs.len() != m { + return Err("rhs length must equal number of rows".into()); } - // Check for duplicates - let mut sorted = required_columns.clone(); - sorted.sort_unstable(); - for i in 1..sorted.len() { - assert_ne!( - sorted[i - 1], - sorted[i], - "Duplicate required column index {}", - sorted[i] + if required_columns.len() >= m { + return Err( + format!("|S| ({}) must be less than m ({m})", required_columns.len()).into(), ); } - Self { + let mut seen = std::collections::HashSet::new(); + for &column in &required_columns { + if column >= n { + return Err(format!("required column {column} is out of bounds").into()); + } + if !seen.insert(column) { + return Err(format!("Duplicate required column index {column}").into()); + } + } + Ok(Self { matrix, rhs, required_columns, - } + }) } /// Returns the matrix A. @@ -463,8 +432,12 @@ impl Problem for FeasibleBasisExtension { } impl crate::solvers::BruteForceProblem for FeasibleBasisExtension { - fn dimensions(&self) -> Vec { - vec![2; self.num_columns() - self.num_required()] + fn num_variables(&self) -> Result { + Ok(self.num_columns() - self.num_required()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/algebraic/ilp.rs b/src/models/algebraic/ilp.rs index 7f4111e35..a20f27743 100644 --- a/src/models/algebraic/ilp.rs +++ b/src/models/algebraic/ilp.rs @@ -6,9 +6,7 @@ use crate::registry::{ConstructionError, FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::traits::{EvaluationError, Problem}; -use crate::types::{ - i64_to_exact_f64, Extremum, NumericArithmeticError, NumericSize, WeightElement, -}; +use crate::types::{Extremum, NumericArithmeticError, NumericSize, WeightElement}; use serde::{Deserialize, Deserializer, Serialize}; use std::fmt::Debug; use std::marker::PhantomData; @@ -80,7 +78,7 @@ impl ILPCoefficient for f64 { const NAME: &'static str = "f64"; fn from_integer(value: i64) -> Result { - i64_to_exact_f64(value).map_err(|_| { + crate::types::i64_to_exact_f64(value).map_err(|_| { EvaluationError::InexactFloatConversion( "transporting an integer variable into an f64 ILP expression".into(), ) @@ -88,11 +86,10 @@ impl ILPCoefficient for f64 { } fn satisfies(lhs: Self, comparison: Comparison, rhs: Self) -> bool { - let tolerance = 1e-9 * lhs.abs().max(rhs.abs()).max(1.0); match comparison { - Comparison::Le => lhs <= rhs + tolerance, - Comparison::Ge => lhs >= rhs - tolerance, - Comparison::Eq => (lhs - rhs).abs() <= tolerance, + Comparison::Le => lhs <= rhs, + Comparison::Ge => lhs >= rhs, + Comparison::Eq => lhs == rhs, } } } diff --git a/src/models/algebraic/minimum_matrix_cover.rs b/src/models/algebraic/minimum_matrix_cover.rs index 8df124c06..c969d25d5 100644 --- a/src/models/algebraic/minimum_matrix_cover.rs +++ b/src/models/algebraic/minimum_matrix_cover.rs @@ -3,7 +3,7 @@ //! Given an n×n nonnegative integer matrix A, find a sign assignment //! f: {1,...,n} → {-1,+1} minimizing Σ a_ij · f(i) · f(j). -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{ConstructionError, FieldInfo, ProblemSchemaEntry}; use crate::traits::Problem; use crate::types::Min; use serde::{Deserialize, Serialize}; @@ -44,13 +44,13 @@ inventory::submit! { /// vec![3, 0, 0, 2], /// vec![1, 0, 0, 4], /// vec![0, 2, 4, 0], -/// ]); +/// ]).unwrap(); /// /// let solver = BruteForce::new(); /// let witness = solver.solve(&problem).unwrap(); /// assert!(witness.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumMatrixCover { /// The n×n nonnegative integer matrix. matrix: Vec>, @@ -59,20 +59,23 @@ pub struct MinimumMatrixCover { impl MinimumMatrixCover { /// Create a new MinimumMatrixCover instance. /// - /// # Panics - /// - /// Panics if the matrix is not square or has inconsistent row lengths. - pub fn new(matrix: Vec>) -> Self { + /// Returns an error for a nonsquare matrix or a negative entry. + pub fn 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(ConstructionError::InvalidInput(format!( + "matrix row {i} has {} columns, expected {n}", + row.len() + ))); + } + if row.iter().any(|&entry| entry < 0) { + return Err(ConstructionError::InvalidInput(format!( + "matrix row {i} contains a negative entry" + ))); + } } - Self { matrix } + Ok(Self { matrix }) } /// Returns the number of rows (= columns) of the matrix. @@ -86,6 +89,17 @@ impl MinimumMatrixCover { } } +impl<'de> Deserialize<'de> for MinimumMatrixCover { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct Data { + matrix: Vec>, + } + let data = Data::deserialize(deserializer)?; + Self::new(data.matrix).map_err(serde::de::Error::custom) + } +} + impl Problem for MinimumMatrixCover { const NAME: &'static str = "MinimumMatrixCover"; type Solution = Vec; @@ -140,8 +154,12 @@ impl Problem for MinimumMatrixCover { } impl crate::solvers::BruteForceProblem for MinimumMatrixCover { - fn dimensions(&self) -> Vec { - vec![2; self.num_rows()] + fn num_variables(&self) -> Result { + Ok(self.num_rows()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -159,12 +177,15 @@ pub(crate) fn canonical_model_example_specs() -> Vec>, @@ -61,6 +62,19 @@ pub struct MinimumMatrixDomination { ones: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct MinimumMatrixDominationData { + matrix: Vec>, +} + +impl TryFrom for MinimumMatrixDomination { + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumMatrixDominationData) -> Result { + Self::try_new(data.matrix) + } +} + impl MinimumMatrixDomination { /// Create a new MinimumMatrixDomination instance. /// @@ -68,21 +82,25 @@ impl MinimumMatrixDomination { /// /// Panics if the matrix rows have inconsistent lengths. pub fn new(matrix: Vec>) -> Self { + Self::try_new(matrix).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(matrix: Vec>) -> Result { let num_cols = matrix.first().map_or(0, Vec::len); - for row in &matrix { - assert_eq!(row.len(), num_cols, "All rows must have the same length"); + if matrix.iter().any(|row| row.len() != num_cols) { + return Err("all matrix rows must have the same length".into()); } - let ones: Vec<(usize, usize)> = matrix + let ones = matrix .iter() .enumerate() .flat_map(|(i, row)| { row.iter() .enumerate() - .filter(|(_, &v)| v) + .filter(|(_, &value)| value) .map(move |(j, _)| (i, j)) }) .collect(); - Self { matrix, ones } + Ok(Self { matrix, ones }) } /// Returns a reference to the binary matrix. @@ -174,8 +192,12 @@ impl Problem for MinimumMatrixDomination { } impl crate::solvers::BruteForceProblem for MinimumMatrixDomination { - fn dimensions(&self) -> Vec { - vec![2; self.num_ones()] + fn num_variables(&self) -> Result { + Ok(self.num_ones()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/algebraic/minimum_weight_decoding.rs b/src/models/algebraic/minimum_weight_decoding.rs index 6b17433ce..07d45585f 100644 --- a/src/models/algebraic/minimum_weight_decoding.rs +++ b/src/models/algebraic/minimum_weight_decoding.rs @@ -52,6 +52,7 @@ inventory::submit! { /// assert!(witness.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumWeightDecodingCreateSpec")] pub struct MinimumWeightDecoding { /// The n×m binary parity-check matrix H. matrix: Vec>, @@ -72,23 +73,7 @@ struct MinimumWeightDecodingCreateSpec { impl TryFrom for MinimumWeightDecoding { type Error = crate::registry::ConstructionError; fn try_from(spec: MinimumWeightDecodingCreateSpec) -> Result { - let first = spec - .matrix - .first() - .ok_or("matrix must have at least one row")?; - if first.is_empty() { - return Err("matrix must have at least one column".into()); - } - if spec.matrix.iter().any(|row| row.len() != first.len()) { - return Err("all matrix rows must have the same length".into()); - } - if spec.target.len() != spec.matrix.len() { - return Err("rhs length must equal number of rows".into()); - } - Ok(Self { - matrix: spec.matrix, - target: spec.target, - }) + Self::try_new(spec.matrix, spec.target) } } @@ -100,18 +85,24 @@ impl MinimumWeightDecoding { /// Panics if the matrix is empty, rows have inconsistent lengths, /// target length does not match the number of rows, or there are no columns. pub fn new(matrix: Vec>, target: Vec) -> Self { - assert!(!matrix.is_empty(), "Matrix must have at least one row"); - let num_cols = matrix[0].len(); - assert!(num_cols > 0, "Matrix must have at least one column"); - for row in &matrix { - assert_eq!(row.len(), num_cols, "All rows must have the same length"); + Self::try_new(matrix, target).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + matrix: Vec>, + target: Vec, + ) -> Result { + let first = matrix.first().ok_or("matrix must have at least one row")?; + if first.is_empty() { + return Err("matrix must have at least one column".into()); + } + if matrix.iter().any(|row| row.len() != first.len()) { + return Err("all matrix rows must have the same length".into()); + } + if target.len() != matrix.len() { + return Err("Target length must equal number of rows".into()); } - assert_eq!( - target.len(), - matrix.len(), - "Target length must equal number of rows" - ); - Self { matrix, target } + Ok(Self { matrix, target }) } /// Returns a reference to the parity-check matrix H. @@ -181,8 +172,12 @@ impl Problem for MinimumWeightDecoding { } impl crate::solvers::BruteForceProblem for MinimumWeightDecoding { - fn dimensions(&self) -> Vec { - vec![2; self.num_cols()] + fn num_variables(&self) -> Result { + Ok(self.num_cols()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs index 478f0b21e..b08c18d92 100644 --- a/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs +++ b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs @@ -51,6 +51,7 @@ inventory::submit! { /// assert!(witness.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumWeightSolutionCreateSpec")] pub struct MinimumWeightSolutionToLinearEquations { /// The n×m integer matrix A. matrix: Vec>, @@ -71,23 +72,7 @@ struct MinimumWeightSolutionCreateSpec { impl TryFrom for MinimumWeightSolutionToLinearEquations { type Error = crate::registry::ConstructionError; fn try_from(spec: MinimumWeightSolutionCreateSpec) -> Result { - let first = spec - .matrix - .first() - .ok_or("matrix must have at least one row")?; - if first.is_empty() { - return Err("matrix must have at least one column".into()); - } - if spec.matrix.iter().any(|row| row.len() != first.len()) { - return Err("all matrix rows must have the same length".into()); - } - if spec.rhs.len() != spec.matrix.len() { - return Err("rhs length must equal number of rows".into()); - } - Ok(Self { - matrix: spec.matrix, - rhs: spec.rhs, - }) + Self::try_new(spec.matrix, spec.rhs) } } @@ -99,18 +84,24 @@ impl MinimumWeightSolutionToLinearEquations { /// Panics if the matrix is empty, rows have inconsistent lengths, /// rhs length does not match the number of rows, or there are no columns. pub fn new(matrix: Vec>, rhs: Vec) -> Self { - assert!(!matrix.is_empty(), "Matrix must have at least one row"); - let num_cols = matrix[0].len(); - assert!(num_cols > 0, "Matrix must have at least one column"); - for row in &matrix { - assert_eq!(row.len(), num_cols, "All rows must have the same length"); + Self::try_new(matrix, rhs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + matrix: Vec>, + rhs: Vec, + ) -> Result { + let first = matrix.first().ok_or("matrix must have at least one row")?; + if first.is_empty() { + return Err("matrix must have at least one column".into()); + } + if matrix.iter().any(|row| row.len() != first.len()) { + return Err("all matrix rows must have the same length".into()); + } + if rhs.len() != matrix.len() { + return Err("RHS length must equal number of rows".into()); } - assert_eq!( - rhs.len(), - matrix.len(), - "RHS length must equal number of rows" - ); - Self { matrix, rhs } + Ok(Self { matrix, rhs }) } /// Returns a reference to the matrix A. @@ -259,8 +250,12 @@ impl Problem for MinimumWeightSolutionToLinearEquations { } impl crate::solvers::BruteForceProblem for MinimumWeightSolutionToLinearEquations { - fn dimensions(&self) -> Vec { - vec![2; self.num_variables()] + fn num_variables(&self) -> Result { + Ok(self.num_variables()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } 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 72bafd830..48a8d6be0 100644 --- a/src/models/algebraic/quadratic_assignment.rs +++ b/src/models/algebraic/quadratic_assignment.rs @@ -58,6 +58,7 @@ inventory::submit! { /// assert!(best.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "QuadraticAssignmentData")] pub struct QuadraticAssignment { /// Cost/flow matrix between facilities (n x n). cost_matrix: Vec>, @@ -65,6 +66,20 @@ pub struct QuadraticAssignment { distance_matrix: Vec>, } +#[derive(Deserialize)] +struct QuadraticAssignmentData { + cost_matrix: Vec>, + distance_matrix: Vec>, +} + +impl TryFrom for QuadraticAssignment { + type Error = crate::registry::ConstructionError; + + fn try_from(data: QuadraticAssignmentData) -> Result { + Self::try_new(data.cost_matrix, data.distance_matrix) + } +} + impl QuadraticAssignment { /// Create a new Quadratic Assignment Problem. /// @@ -75,22 +90,28 @@ impl QuadraticAssignment { /// # Panics /// Panics if either matrix is not square, or if num_facilities > num_locations. pub fn new(cost_matrix: Vec>, distance_matrix: Vec>) -> Self { + Self::try_new(cost_matrix, distance_matrix).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + cost_matrix: Vec>, + distance_matrix: Vec>, + ) -> Result { let n = cost_matrix.len(); - for row in &cost_matrix { - assert_eq!(row.len(), n, "cost_matrix must be square"); + if cost_matrix.iter().any(|row| row.len() != n) { + return Err("cost_matrix must be square".into()); } let m = distance_matrix.len(); - for row in &distance_matrix { - assert_eq!(row.len(), m, "distance_matrix must be square"); + if distance_matrix.iter().any(|row| row.len() != m) { + return Err("distance_matrix must be square".into()); } - assert!( - n <= m, - "num_facilities ({n}) must be <= num_locations ({m})" - ); - Self { + if n > m { + return Err(format!("num_facilities ({n}) must be <= num_locations ({m})").into()); + } + Ok(Self { cost_matrix, distance_matrix, - } + }) } /// Get the cost/flow matrix. @@ -186,8 +207,12 @@ impl Problem for QuadraticAssignment { } impl crate::solvers::BruteForceProblem for QuadraticAssignment { - fn dimensions(&self) -> Vec { - vec![self.num_locations(); self.num_facilities()] + fn num_variables(&self) -> Result { + Ok(self.num_facilities()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_locations()) } } @@ -225,3 +250,52 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Flow/cost matrix between facilities" }, + crate::registry::FieldInfo { name: "distance_matrix", type_name: "Vec>", description: "Distance matrix between locations" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_quadratic_assignment_to_quadratic_assignment", + build: || { + let source = crate::models::decision::Decision::new( + QuadraticAssignment::new( + vec![ + vec![0, 5, 2, 0], + vec![5, 0, 0, 3], + vec![2, 0, 0, 4], + vec![0, 3, 4, 0], + ], + vec![ + vec![0, 4, 1, 1], + vec![4, 0, 3, 4], + vec![1, 3, 0, 4], + vec![1, 4, 4, 0], + ], + ), + 56, + ); + let witness = serde_json::json!(vec![3, 0, 1, 2]); + crate::example_db::specs::rule_example_with_witness::<_, QuadraticAssignment>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/algebraic/quadratic_congruences.rs b/src/models/algebraic/quadratic_congruences.rs index 98212273c..eb7a01d1d 100644 --- a/src/models/algebraic/quadratic_congruences.rs +++ b/src/models/algebraic/quadratic_congruences.rs @@ -247,13 +247,12 @@ impl Problem for QuadraticCongruences { } impl crate::solvers::BruteForceProblem for QuadraticCongruences { - fn dimensions(&self) -> Vec { - let num_bits = self.witness_bit_length(); - if num_bits == 0 { - Vec::new() - } else { - vec![2; num_bits] - } + fn num_variables(&self) -> Result { + Ok(self.witness_bit_length()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2) } } diff --git a/src/models/algebraic/quadratic_diophantine_equations.rs b/src/models/algebraic/quadratic_diophantine_equations.rs index 6cef6e290..2115b01b6 100644 --- a/src/models/algebraic/quadratic_diophantine_equations.rs +++ b/src/models/algebraic/quadratic_diophantine_equations.rs @@ -254,13 +254,12 @@ impl Problem for QuadraticDiophantineEquations { } impl crate::solvers::BruteForceProblem for QuadraticDiophantineEquations { - fn dimensions(&self) -> Vec { - let num_bits = self.witness_bit_length(); - if num_bits == 0 { - Vec::new() - } else { - vec![2; num_bits] - } + fn num_variables(&self) -> Result { + Ok(self.witness_bit_length()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2) } } diff --git a/src/models/algebraic/qubo.rs b/src/models/algebraic/qubo.rs index 1c145bd6e..c687e773f 100644 --- a/src/models/algebraic/qubo.rs +++ b/src/models/algebraic/qubo.rs @@ -7,6 +7,8 @@ use crate::traits::Problem; use crate::types::{Min, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; +use sprs::CsMat; +use std::collections::BTreeMap; inventory::submit! { ProblemSchemaEntry { @@ -36,6 +38,10 @@ inventory::submit! { /// finite floating-point coefficients. An explicit variant reduction converts /// exactly representable integer coefficients to `f64`. /// +/// Persisted JSON is `{"num_vars": n, "entries": [[row, column, value], ...]}`, listing the +/// stored coefficients in row-major order. Loading also accepts the legacy dense +/// `{"num_vars": n, "matrix": [[...], ...]}` shape, which is read like [`QUBO::from_matrix`]. +/// /// # Example /// /// ``` @@ -55,13 +61,61 @@ inventory::submit! { /// // Optimal is x = [0, 1] with value -2 /// assert!(solutions.contains(&vec![false, true])); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] +#[serde( + try_from = "QuboData", + bound(deserialize = "W: WeightElement + Deserialize<'de>") +)] pub struct QUBO { - /// Number of variables. + matrix: CsMat, +} + +/// Persisted sparse shape: every stored coefficient as `[row, column, value]` in row-major order. +#[derive(Serialize)] +struct QuboEntries<'a, W> { 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>, + entries: Vec<(usize, usize, &'a W)>, +} + +impl Serialize for QUBO { + fn serialize(&self, serializer: S) -> Result { + QuboEntries { + num_vars: self.num_vars(), + entries: self + .matrix + .iter() + .map(|(value, (row, column))| (row, column, value)) + .collect(), + } + .serialize(serializer) + } +} + +/// Accepted JSON: sparse `entries`, or the legacy dense `matrix` written before sparse storage. +#[derive(Deserialize)] +struct QuboData { + num_vars: usize, + entries: Option>, + matrix: Option>>, +} + +impl TryFrom> for QUBO { + type Error = ConstructionError; + fn try_from(data: QuboData) -> Result { + match (data.entries, data.matrix) { + (Some(entries), None) => Self::from_entries(data.num_vars, entries), + (None, Some(matrix)) if matrix.len() == data.num_vars => Self::from_matrix(matrix), + (None, Some(matrix)) => Err(ConstructionError::Conversion(format!( + "QUBO num_vars is {}, but the dense matrix has {} rows", + data.num_vars, + matrix.len() + ))), + _ => Err(ConstructionError::Conversion( + "QUBO JSON must contain exactly one of `entries` (sparse) or `matrix` (legacy dense)" + .into(), + )), + } + } } #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -95,12 +149,117 @@ impl QUBO { "QUBO matrix row {row} has length {actual}, expected {num_vars}" ))); } - for (row, values) in matrix.iter().enumerate() { - for (column, value) in values.iter().enumerate() { - value.validate_element(&format!("QUBO coefficient at ({row}, {column})"))?; + Self::from_rows( + matrix + .into_iter() + .map(|row| row.into_iter().enumerate()) + .collect(), + ) + } + + /// Create a QUBO from a square sparse matrix. Only its upper triangle is evaluated. + pub fn from_sparse(matrix: CsMat) -> Result { + if matrix.rows() != matrix.cols() { + return Err(ConstructionError::Conversion( + "QUBO matrix must be square".into(), + )); + } + let matrix = matrix.into_csr(); + for (row, values) in matrix.outer_iterator().enumerate() { + for (column, value) in values.iter() { + value + .validate_element("QUBO coefficient") + .map_err(|error| match error { + ConstructionError::NonFiniteFloat(message) => { + ConstructionError::NonFiniteFloat(format!( + "{message} at ({row}, {column})" + )) + } + error => error, + })?; } } - Ok(Self { num_vars, matrix }) + Ok(Self { matrix }) + } + + // Rows collect assignments and checked additions before compression. No library + // duplicate summation may replace the rule's numeric operations. + pub(crate) fn from_rows( + rows: Vec>, + ) -> Result { + let n = rows.len(); + let mut offsets = Vec::with_capacity(n + 1); + let mut indices = Vec::new(); + let mut values = Vec::new(); + offsets.push(0); + for row in rows { + for (column, value) in row { + if !value.to_sum().is_zero() { + indices.push(column); + values.push(value); + } + } + offsets.push(values.len()); + } + Self::from_csr_parts(n, offsets, indices, values) + } + + // Stores exactly the listed coefficients, like `from_sparse`; explicit zeros and + // lower-triangle entries are kept. + fn from_entries( + num_vars: usize, + mut entries: Vec<(usize, usize, W)>, + ) -> Result { + if let Some(&(row, column, _)) = entries + .iter() + .find(|&&(row, column, _)| row >= num_vars || column >= num_vars) + { + return Err(ConstructionError::Conversion(format!( + "QUBO entry index ({row}, {column}) is outside 0..{num_vars}" + ))); + } + 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!( + "QUBO entry ({}, {}) is listed more than once", + pair[0].0, pair[0].1 + ))); + } + let mut offsets = Vec::new(); + num_vars + .checked_add(1) + .ok_or_else(|| ConstructionError::IntegerOverflow("counting QUBO rows".into())) + .and_then(|len| { + offsets.try_reserve_exact(len).map_err(|error| { + ConstructionError::Conversion(format!("allocating QUBO rows: {error}")) + }) + })?; + offsets.resize(num_vars + 1, 0); + for &(row, _, _) in &entries { + offsets[row + 1] += 1; + } + for row in 0..num_vars { + offsets[row + 1] += offsets[row]; + } + let (indices, values) = entries + .into_iter() + .map(|(_, column, value)| (column, value)) + .unzip(); + Self::from_csr_parts(num_vars, offsets, indices, values) + } + + fn from_csr_parts( + n: usize, + offsets: Vec, + indices: Vec, + values: Vec, + ) -> Result { + let matrix = CsMat::try_new((n, n), offsets, indices, values) + .map_err(|(_, _, _, error)| ConstructionError::Conversion(error.to_string()))?; + Self::from_sparse(matrix) } /// Create a QUBO from linear and quadratic terms. @@ -113,11 +272,11 @@ impl QUBO { quadratic: Vec<((usize, usize), W)>, ) -> Result { let num_vars = linear.len(); - let mut matrix = vec![vec![W::default(); num_vars]; num_vars]; + let mut matrix = vec![BTreeMap::new(); num_vars]; // Set diagonal (linear terms) for (i, val) in linear.into_iter().enumerate() { - matrix[i][i] = val; + matrix[i].insert(i, val); } // Set off-diagonal (quadratic terms) @@ -128,30 +287,34 @@ impl QUBO { ))); } if i < j { - matrix[i][j] = val; + matrix[i].insert(j, val); } else { - matrix[j][i] = val; + matrix[j].insert(i, val); } } - Self::from_matrix(matrix) + Self::from_rows(matrix) } } impl QUBO { /// Get the number of variables. pub fn num_vars(&self) -> usize { - self.num_vars + self.matrix.rows() } /// Get the Q matrix. - pub fn matrix(&self) -> &[Vec] { + pub fn matrix(&self) -> &CsMat { &self.matrix } - /// Get a specific matrix element `Q[i][j]`. - pub fn get(&self, i: usize, j: usize) -> Option<&W> { - self.matrix.get(i).and_then(|row| row.get(j)) + /// Get a coefficient, returning zero for an unstored entry and None outside the matrix. + pub fn get(&self, i: usize, j: usize) -> Option + where + W: Clone + Zero, + { + (i < self.num_vars() && j < self.num_vars()) + .then(|| self.matrix.get(i, j).cloned().unwrap_or_else(W::zero)) } } @@ -169,31 +332,26 @@ where &self, solution: &Self::Solution, ) -> Result, crate::traits::EvaluationError> { - if solution.len() != self.num_vars { + if solution.len() != self.num_vars() { return Err(crate::traits::EvaluationError::InvalidConfiguration( format!( "solution has {} variables, expected {}", solution.len(), - self.num_vars + self.matrix.rows() ), )); } let mut value = W::Sum::zero(); - for i in 0..self.num_vars { + for (i, row) in self.matrix.outer_iterator().enumerate() { 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)) { + for (j, coefficient) in row.iter() { + if j >= i && solution[j] { value = W::checked_add_to_sum( value, - q_ij.to_sum(), + coefficient.to_sum(), "summing selected QUBO coefficients", )?; } @@ -212,8 +370,12 @@ impl crate::solvers::BruteForceProblem for QUBO where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -242,3 +404,38 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionQUBO"); +crate::register_decision_variant!( + QUBO, "DecisionQUBO", "2^num_vars", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Algebraic, + dims: [VariantDimension::new("weight", "i64", &["i64"])], + fields: [ + crate::registry::FieldInfo { name: "matrix", type_name: "Vec>", description: "Q matrix; the number of variables is its row count." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_qubo_to_qubo", + build: || { + let source = crate::models::decision::Decision::new( + QUBO::from_matrix(vec![vec![-1, 2, 0], vec![0, -1, 2], vec![0, 0, -1]]).unwrap(), + -2, + ); + let witness = serde_json::json!(vec![true, false, true]); + crate::example_db::specs::rule_example_with_witness::<_, QUBO>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/algebraic/simultaneous_incongruences.rs b/src/models/algebraic/simultaneous_incongruences.rs index 968d8deda..36565ace0 100644 --- a/src/models/algebraic/simultaneous_incongruences.rs +++ b/src/models/algebraic/simultaneous_incongruences.rs @@ -79,11 +79,6 @@ impl SimultaneousIncongruences { .into()); } } - pairs.iter().try_fold(1i64, |lcm, &(_, modulus)| { - (lcm / gcd(lcm, modulus)) - .checked_mul(modulus) - .ok_or_else(|| "Least common multiple of moduli exceeds i64 range".to_string()) - })?; Ok(()) } @@ -105,9 +100,15 @@ impl SimultaneousIncongruences { } /// Compute the LCM of all moduli. - pub fn lcm_moduli(&self) -> i64 { - self.pairs.iter().fold(1i64, |lcm, &(_, modulus)| { - (lcm / gcd(lcm, modulus)) * modulus + pub fn lcm_moduli(&self) -> Result { + self.pairs.iter().try_fold(1i64, |lcm, &(_, modulus)| { + (lcm / gcd(lcm, modulus)) + .checked_mul(modulus) + .ok_or_else(|| { + crate::traits::EvaluationError::IntegerOverflow( + "computing the incongruence period".into(), + ) + }) }) } } @@ -141,15 +142,18 @@ 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)) }) } } impl crate::solvers::BruteForceProblem for SimultaneousIncongruences { - fn dimensions(&self) -> Vec { - let lcm = usize::try_from(self.lcm_moduli()).expect("validated positive LCM fits usize"); - vec![lcm] + fn num_variables(&self) -> Result { + Ok(1) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(usize::try_from(self.lcm_moduli()?)?) } } diff --git a/src/models/algebraic/sparse_matrix_compression.rs b/src/models/algebraic/sparse_matrix_compression.rs index 724a0aff7..d9d60380e 100644 --- a/src/models/algebraic/sparse_matrix_compression.rs +++ b/src/models/algebraic/sparse_matrix_compression.rs @@ -28,6 +28,7 @@ inventory::submit! { /// enumerating storage-vector entries directly, so brute-force search runs over /// `bound_k ^ num_rows` shift assignments. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SparseMatrixCompressionCreateSpec")] pub struct SparseMatrixCompression { matrix: Vec>, bound_k: usize, @@ -53,7 +54,7 @@ impl TryFrom for SparseMatrixCompression { .to_string() .into()); } - Ok(Self::new(spec.matrix, spec.bound_k)) + Self::try_new(spec.matrix, spec.bound_k) } } @@ -64,14 +65,23 @@ impl SparseMatrixCompression { /// /// Panics if `bound_k == 0` or if the matrix rows are ragged. pub fn new(matrix: Vec>, bound_k: usize) -> Self { - assert!(bound_k > 0, "bound_k must be positive"); + Self::try_new(matrix, bound_k).unwrap_or_else(|error| panic!("{error}")) + } - let num_cols = matrix.first().map_or(0, Vec::len); - for row in &matrix { - assert_eq!(row.len(), num_cols, "All rows must have the same length"); + fn try_new( + matrix: Vec>, + bound_k: usize, + ) -> Result { + if bound_k == 0 { + return Err("bound_k must be positive".to_string().into()); } - - Self { matrix, bound_k } + let columns = matrix.first().map_or(0, Vec::len); + if matrix.iter().any(|row| row.len() != columns) { + return Err("all matrix rows must have the same length" + .to_string() + .into()); + } + Ok(Self { matrix, bound_k }) } /// Return the binary matrix. @@ -173,8 +183,12 @@ impl Problem for SparseMatrixCompression { } impl crate::solvers::BruteForceProblem for SparseMatrixCompression { - fn dimensions(&self) -> Vec { - vec![self.bound_k; self.num_rows()] + fn num_variables(&self) -> Result { + Ok(self.num_rows()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.bound_k) } } diff --git a/src/models/decision.rs b/src/models/decision.rs index 1acb637ca..b732d4ec8 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -1,6 +1,6 @@ //! Generic decision wrapper for optimization problems. -use crate::rules::{AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionResult}; +use crate::rules::{ReduceTo, ReductionResult}; use crate::traits::Problem; use crate::types::{OptimizationValue, Or}; use serde::de::DeserializeOwned; @@ -96,24 +96,17 @@ macro_rules! register_decision_variant { >)?; let result = <$crate::models::decision::Decision<$inner> as $crate::rules::ReduceTo<$inner>>::reduce_to(source)?; - Ok(Box::new(result)) - }), - reduce_aggregate_fn: Some(|any| { - let source = any - .downcast_ref::<$crate::models::decision::Decision<$inner>>() - .ok_or_else($crate::rules::ReductionError::source_type_mismatch::< - $crate::models::decision::Decision<$inner>, - $inner, - >)?; - let result = - <$crate::models::decision::Decision<$inner> as $crate::rules::ReduceToAggregate<$inner>>::reduce_to_aggregate(source)?; - Ok(Box::new(result)) + let result = std::rc::Rc::new(result); + Ok($crate::rules::registry::ExecutedStep { + witness: result, + }) }), turing: false, } } - // Reverse edge: P → Decision

(Turing/multi-query reduction via binary search) + // Theory-only reverse edge: P → Decision

requires multiple queries. + // Retained for graph display; there is no Turing reduction executor. $crate::inventory::submit! { $crate::rules::ReductionEntry { source_name: <$inner as $crate::traits::Problem>::NAME, @@ -130,7 +123,6 @@ macro_rules! register_decision_variant { }, module_path: module_path!(), reduce_fn: None, - reduce_aggregate_fn: None, turing: true, } } @@ -202,7 +194,7 @@ where type_name: std::any::type_name::<::Inner>(), description: "Decision objective bound", required: true, - codec: crate::registry::CreateInputCodec::Scalar, + codec: crate::registry::CreateInputCodec::Json, }); inputs } @@ -317,12 +309,21 @@ where P: DecisionProblemMeta + crate::solvers::BruteForceProblem, P::Value: OptimizationValue, { - fn dimensions(&self) -> Vec { - self.inner.dimensions() + fn num_variables(&self) -> Result { + self.inner.num_variables() + } + + fn dimension(&self, variable: usize) -> Result { + self.inner.dimension(variable) } } -/// Aggregate reduction result for `Decision

-> P`. +/// Executed reduction from `Decision

` to its optimization problem. +/// +/// The target and decision bound belong to the same execution. An optimum +/// meeting the bound supplies a decision witness; an optimum missing the bound +/// recovers `Infeasible`. A feasible candidate missing the bound instead returns +/// `InsufficientSolutionQuality`, since it does not establish NO. #[derive(Debug, Clone)] pub struct DecisionToOptimizationResult

where @@ -333,19 +334,12 @@ where bound: ::Inner, } -impl

AggregateReductionResult for DecisionToOptimizationResult

+impl

DecisionToOptimizationResult

where P: DecisionProblemMeta + 'static, - P::Value: OptimizationValue + Serialize + DeserializeOwned, + P::Value: OptimizationValue, { - type Source = Decision

; - type Target = P; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, target_value: P::Value) -> Or { + fn map_value(&self, target_value: P::Value) -> Or { Or(::meets_bound( &target_value, &self.bound, @@ -353,40 +347,10 @@ where } } -impl

ReduceToAggregate

for Decision

-where - P: DecisionProblemMeta + Clone + 'static, - P::Value: OptimizationValue + Serialize + DeserializeOwned, -{ - type Result = DecisionToOptimizationResult

; - - fn reduce_to_aggregate(&self) -> Result { - Ok(DecisionToOptimizationResult { - target: self.inner.clone(), - bound: self.bound.clone(), - }) - } -} - -/// 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, - P::Value: OptimizationValue + Serialize + DeserializeOwned, + P::Value: OptimizationValue, { type Source = Decision

; type Target = P; @@ -395,27 +359,48 @@ where &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.clone()) + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + crate::solvers::SolveOutcome::Infeasible => { + Ok(crate::solvers::SolveOutcome::Infeasible) + } + crate::solvers::SolveOutcome::Optimal { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Ok(crate::solvers::SolveOutcome::Infeasible); + } + Ok(crate::solvers::SolveOutcome::optimal(source, solution)?) + } + crate::solvers::SolveOutcome::Feasible { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Err(crate::rules::ExtractionError::InsufficientSolutionQuality); + } + Ok(crate::solvers::SolveOutcome::feasible(source, solution)?) + } + } } } impl

ReduceTo

for Decision

where P: DecisionProblemMeta + Clone + 'static, - P::Solution: Clone, - P::Value: OptimizationValue + Serialize + DeserializeOwned, + P::Value: OptimizationValue, { - type Result = DecisionToOptimizationWitnessResult

; + type Result = DecisionToOptimizationResult

; fn reduce_to(&self) -> Result { - Ok(DecisionToOptimizationWitnessResult { + Ok(DecisionToOptimizationResult { target: self.inner.clone(), + bound: self.bound.clone(), }) } } diff --git a/src/models/formula/circuit.rs b/src/models/formula/circuit.rs index 2e078c074..1d20f16e2 100644 --- a/src/models/formula/circuit.rs +++ b/src/models/formula/circuit.rs @@ -358,8 +358,12 @@ impl Problem for CircuitSAT { } impl crate::solvers::BruteForceProblem for CircuitSAT { - fn dimensions(&self) -> Vec { - vec![2; self.variables.len()] + fn num_variables(&self) -> Result { + Ok(self.variables.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/formula/ksat.rs b/src/models/formula/ksat.rs index dede5be57..2c7302f44 100644 --- a/src/models/formula/ksat.rs +++ b/src/models/formula/ksat.rs @@ -267,8 +267,12 @@ impl Problem for KSatisfiability { } impl crate::solvers::BruteForceProblem for KSatisfiability { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/formula/maximum_2_satisfiability.rs b/src/models/formula/maximum_2_satisfiability.rs index 5085b21ff..ccf43b08e 100644 --- a/src/models/formula/maximum_2_satisfiability.rs +++ b/src/models/formula/maximum_2_satisfiability.rs @@ -141,8 +141,12 @@ impl Problem for Maximum2Satisfiability { } impl crate::solvers::BruteForceProblem for Maximum2Satisfiability { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -192,3 +196,50 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Collection of 2-literal clauses" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_maximum_2_satisfiability_to_maximum_2_satisfiability", + build: || { + let source = crate::models::decision::Decision::new( + Maximum2Satisfiability::new( + 4, + vec![ + CNFClause::new(vec![1, 2]), + CNFClause::new(vec![1, -2]), + CNFClause::new(vec![-1, 3]), + CNFClause::new(vec![-1, -3]), + CNFClause::new(vec![2, 4]), + CNFClause::new(vec![-3, -4]), + CNFClause::new(vec![3, 4]), + ], + ), + 6, + ); + let witness = serde_json::json!(vec![true, true, false, true]); + crate::example_db::specs::rule_example_with_witness::<_, Maximum2Satisfiability>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/formula/nae_satisfiability.rs b/src/models/formula/nae_satisfiability.rs index f65fdb717..8e38248d6 100644 --- a/src/models/formula/nae_satisfiability.rs +++ b/src/models/formula/nae_satisfiability.rs @@ -186,8 +186,12 @@ impl Problem for NAESatisfiability { } impl crate::solvers::BruteForceProblem for NAESatisfiability { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/formula/non_tautology.rs b/src/models/formula/non_tautology.rs index a45f40aab..66d8af9ca 100644 --- a/src/models/formula/non_tautology.rs +++ b/src/models/formula/non_tautology.rs @@ -177,8 +177,12 @@ impl Problem for NonTautology { } impl crate::solvers::BruteForceProblem for NonTautology { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/formula/one_in_three_satisfiability.rs b/src/models/formula/one_in_three_satisfiability.rs index f78004e32..653ca9043 100644 --- a/src/models/formula/one_in_three_satisfiability.rs +++ b/src/models/formula/one_in_three_satisfiability.rs @@ -154,8 +154,12 @@ impl Problem for OneInThreeSatisfiability { } impl crate::solvers::BruteForceProblem for OneInThreeSatisfiability { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/formula/planar_3_satisfiability.rs b/src/models/formula/planar_3_satisfiability.rs index f32349663..f1c2cf77d 100644 --- a/src/models/formula/planar_3_satisfiability.rs +++ b/src/models/formula/planar_3_satisfiability.rs @@ -150,8 +150,12 @@ impl Problem for Planar3Satisfiability { } impl crate::solvers::BruteForceProblem for Planar3Satisfiability { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/formula/qbf.rs b/src/models/formula/qbf.rs index 4aec9a7d7..fa641eb83 100644 --- a/src/models/formula/qbf.rs +++ b/src/models/formula/qbf.rs @@ -187,8 +187,12 @@ impl Problem for QuantifiedBooleanFormulas { } impl crate::solvers::BruteForceProblem for QuantifiedBooleanFormulas { - fn dimensions(&self) -> Vec { - vec![] + fn num_variables(&self) -> Result { + Ok(0) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(0) } } diff --git a/src/models/formula/sat.rs b/src/models/formula/sat.rs index fb0b6ff5f..4fdf99701 100644 --- a/src/models/formula/sat.rs +++ b/src/models/formula/sat.rs @@ -233,8 +233,12 @@ impl Problem for Satisfiability { } impl crate::solvers::BruteForceProblem for Satisfiability { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 2ed924353..10efa8c41 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -29,7 +29,7 @@ inventory::submit! { } /// Acyclic Partition (Garey & Johnson ND15). -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct AcyclicPartition { graph: DirectedGraph, vertex_weights: Vec, @@ -38,6 +38,34 @@ pub struct AcyclicPartition { cost_bound: W::Sum, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>"))] +struct AcyclicPartitionData { + graph: DirectedGraph, + vertex_weights: Vec, + arc_costs: Vec, + weight_bound: W::Sum, + cost_bound: W::Sum, +} + +impl<'de, W> Deserialize<'de> for AcyclicPartition +where + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = AcyclicPartitionData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.vertex_weights, + data.arc_costs, + data.weight_bound, + data.cost_bound, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct AcyclicPartitionCreateSpec { #[create(codec = "arc-list")] @@ -94,13 +122,13 @@ impl TryFrom for AcyclicPartition { ) .into()); } - Ok(Self::new( + Self::try_new( graph, vertex_weights, arc_costs, spec.weight_bound, spec.cost_bound, - )) + ) } } @@ -113,23 +141,26 @@ impl AcyclicPartition { weight_bound: W::Sum, cost_bound: W::Sum, ) -> Self { - assert_eq!( - vertex_weights.len(), - graph.num_vertices(), - "vertex_weights length must match graph num_vertices" - ); - assert_eq!( - arc_costs.len(), - graph.num_arcs(), - "arc_costs length must match graph num_arcs" - ); - Self { + Self::try_new(graph, vertex_weights, arc_costs, weight_bound, cost_bound) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + vertex_weights: Vec, + arc_costs: Vec, + weight_bound: W::Sum, + cost_bound: W::Sum, + ) -> Result { + Self::check_vertex_weights(&graph, &vertex_weights)?; + Self::check_arc_costs(&graph, &arc_costs)?; + Ok(Self { graph, vertex_weights, arc_costs, weight_bound, cost_bound, - } + }) } /// Get the underlying graph. @@ -157,6 +188,16 @@ impl AcyclicPartition { self.vertex_weights = vertex_weights; } + fn check_vertex_weights( + graph: &DirectedGraph, + 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()); + } + Ok(()) + } + /// Replace the arc costs. pub fn set_arc_costs(&mut self, arc_costs: Vec) { assert_eq!( @@ -167,6 +208,16 @@ impl AcyclicPartition { self.arc_costs = arc_costs; } + fn check_arc_costs( + graph: &DirectedGraph, + 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()); + } + Ok(()) + } + /// Get the per-part weight bound. pub fn weight_bound(&self) -> &W::Sum { &self.weight_bound @@ -256,8 +307,12 @@ impl crate::solvers::BruteForceProblem for AcyclicPartition where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.graph.num_vertices(); self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/balanced_complete_bipartite_subgraph.rs b/src/models/graph/balanced_complete_bipartite_subgraph.rs index 538bdbb82..d32b2d71c 100644 --- a/src/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/models/graph/balanced_complete_bipartite_subgraph.rs @@ -177,8 +177,12 @@ impl Problem for BalancedCompleteBipartiteSubgraph { } impl crate::solvers::BruteForceProblem for BalancedCompleteBipartiteSubgraph { - fn dimensions(&self) -> Vec { - vec![2; self.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/biclique_cover.rs b/src/models/graph/biclique_cover.rs index 6702a998f..d57236306 100644 --- a/src/models/graph/biclique_cover.rs +++ b/src/models/graph/biclique_cover.rs @@ -365,9 +365,14 @@ impl Problem for BicliqueCover { } impl crate::solvers::BruteForceProblem for BicliqueCover { - fn dimensions(&self) -> Vec { - // Each vertex has k binary variables (one per biclique) - vec![2; self.num_vertices() * self.k] + fn num_variables(&self) -> Result { + (self.num_vertices()).checked_mul(self.k).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/biconnectivity_augmentation.rs b/src/models/graph/biconnectivity_augmentation.rs index 1e026104c..db6e04f0b 100644 --- a/src/models/graph/biconnectivity_augmentation.rs +++ b/src/models/graph/biconnectivity_augmentation.rs @@ -9,6 +9,8 @@ use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::WeightElement; use num_traits::Zero; +use petgraph::algo::{articulation_points::articulation_points, connected_components}; +use petgraph::graph::{NodeIndex, UnGraph}; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; @@ -34,7 +36,7 @@ inventory::submit! { /// determine whether there exists a subset of potential edges `E'` such that: /// - `sum_{e in E'} w(e) <= B` /// - `(V, E union E')` is biconnected -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[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>" @@ -51,6 +53,29 @@ where budget: W::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct BiconnectivityAugmentationData { + graph: G, + potential_weights: Vec<(usize, usize, W)>, + budget: W::Sum, +} + +impl<'de, G, W> Deserialize<'de> for BiconnectivityAugmentation +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = BiconnectivityAugmentationData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.potential_weights, data.budget) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BiconnectivityAugmentationCreateSpec { #[create(codec = "edge-list")] @@ -118,37 +143,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. @@ -189,7 +224,7 @@ impl BiconnectivityAugmentation { fn augmented_graph( &self, config: &[bool], - ) -> Result, crate::traits::EvaluationError> { + ) -> Result>, crate::traits::EvaluationError> { if config.len() != self.num_potential_edges() { return Ok(None); } @@ -215,10 +250,14 @@ impl BiconnectivityAugmentation { return Ok(None); } - Ok(Some(SimpleGraph::new( - self.num_vertices(), - edges.into_iter().collect(), - ))) + let mut graph = UnGraph::new_undirected(); + for _ in 0..self.num_vertices() { + graph.add_node(()); + } + for (u, v) in edges { + graph.add_edge(NodeIndex::new(u), NodeIndex::new(v), ()); + } + Ok(Some(graph)) } } @@ -264,8 +303,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.num_potential_edges()] + fn num_variables(&self) -> Result { + Ok(self.num_potential_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -277,67 +320,9 @@ fn normalize_edge(u: usize, v: usize) -> (usize, usize) { } } -struct DfsState { - visited: Vec, - discovery_time: Vec, - low: Vec, - parent: Vec>, - time: usize, - has_articulation_point: bool, -} - -fn dfs_articulation_points(graph: &G, vertex: usize, state: &mut DfsState) { - if state.has_articulation_point { - return; - } - - state.visited[vertex] = true; - state.time += 1; - state.discovery_time[vertex] = state.time; - state.low[vertex] = state.time; - - let mut child_count = 0; - for neighbor in graph.neighbors(vertex) { - if !state.visited[neighbor] { - child_count += 1; - state.parent[neighbor] = Some(vertex); - dfs_articulation_points(graph, neighbor, state); - state.low[vertex] = state.low[vertex].min(state.low[neighbor]); - - if state.parent[vertex].is_none() && child_count > 1 { - state.has_articulation_point = true; - return; - } - - if state.parent[vertex].is_some() && state.low[neighbor] >= state.discovery_time[vertex] - { - state.has_articulation_point = true; - return; - } - } else if state.parent[vertex] != Some(neighbor) { - state.low[vertex] = state.low[vertex].min(state.discovery_time[neighbor]); - } - } -} - -fn is_biconnected(graph: &G) -> bool { - let num_vertices = graph.num_vertices(); - if num_vertices <= 1 { - return true; - } - - let mut state = DfsState { - visited: vec![false; num_vertices], - discovery_time: vec![0; num_vertices], - low: vec![0; num_vertices], - parent: vec![None; num_vertices], - time: 0, - has_articulation_point: false, - }; - - dfs_articulation_points(graph, 0, &mut state); - - !state.has_articulation_point && state.visited.into_iter().all(|seen| seen) +fn is_biconnected(graph: &UnGraph<(), ()>) -> bool { + graph.node_count() <= 1 + || (connected_components(graph) == 1 && articulation_points(graph).is_empty()) } crate::declare_variants! { diff --git a/src/models/graph/bottleneck_traveling_salesman.rs b/src/models/graph/bottleneck_traveling_salesman.rs index fd5aaac2c..5005e52ce 100644 --- a/src/models/graph/bottleneck_traveling_salesman.rs +++ b/src/models/graph/bottleneck_traveling_salesman.rs @@ -24,11 +24,25 @@ inventory::submit! { /// The Bottleneck Traveling Salesman problem on a simple weighted graph. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "BottleneckTravelingSalesmanData")] pub struct BottleneckTravelingSalesman { graph: SimpleGraph, edge_weights: Vec, } +#[derive(Deserialize)] +struct BottleneckTravelingSalesmanData { + graph: SimpleGraph, + edge_weights: Vec, +} + +impl TryFrom for BottleneckTravelingSalesman { + type Error = crate::registry::ConstructionError; + fn try_from(data: BottleneckTravelingSalesmanData) -> Result { + Self::try_new(data.graph, data.edge_weights) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BottleneckTravelingSalesmanCreateSpec { #[create(codec = "edge-list")] @@ -54,7 +68,7 @@ impl TryFrom for BottleneckTravelingSales ) .into()); } - Ok(Self::new(graph, edge_weights)) + Self::try_new(graph, edge_weights) } } @@ -92,15 +106,18 @@ fn simple_graph_from_create( impl BottleneckTravelingSalesman { /// Create a BottleneckTravelingSalesman problem from a graph with edge weights. pub fn new(graph: SimpleGraph, 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: SimpleGraph, + edge_weights: Vec, + ) -> Result { + Self::check_weights(&graph, &edge_weights)?; + Ok(Self { graph, edge_weights, - } + }) } /// Get a reference to the underlying graph. @@ -118,6 +135,15 @@ impl BottleneckTravelingSalesman { assert_eq!(weights.len(), self.graph.num_edges()); self.edge_weights = weights; } + fn check_weights( + graph: &SimpleGraph, + weights: &[i64], + ) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + Ok(()) + } /// Get all edges with their weights. pub fn edges(&self) -> Vec<(usize, usize, i64)> { @@ -192,8 +218,12 @@ impl Problem for BottleneckTravelingSalesman { } impl crate::solvers::BruteForceProblem for BottleneckTravelingSalesman { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/bounded_component_spanning_forest.rs b/src/models/graph/bounded_component_spanning_forest.rs index 155532cbc..302e6c5c8 100644 --- a/src/models/graph/bounded_component_spanning_forest.rs +++ b/src/models/graph/bounded_component_spanning_forest.rs @@ -34,7 +34,7 @@ inventory::submit! { /// integer `K`, and a bound `B`, determine whether the vertices can be /// partitioned into at most `K` non-empty sets such that every set induces a /// connected subgraph and the total weight of each set is at most `B`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct BoundedComponentSpanningForest { /// The underlying graph. graph: G, @@ -46,6 +46,35 @@ pub struct BoundedComponentSpanningForest { max_weight: W::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct BoundedComponentSpanningForestData { + graph: G, + weights: Vec, + max_components: usize, + max_weight: W::Sum, +} + +impl<'de, G, W> Deserialize<'de> for BoundedComponentSpanningForest +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = BoundedComponentSpanningForestData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.weights, + data.max_components, + data.max_weight, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BoundedComponentSpanningForestCreateSpec { /// The underlying graph G=(V,E). @@ -81,32 +110,44 @@ impl TryFrom if spec.max_weight <= 0 { return Err("max_weight must be positive".to_string().into()); } - Ok(Self::new(spec.graph, spec.weights, spec.k, spec.max_weight)) + Self::try_new(spec.graph, spec.weights, spec.k, spec.max_weight) } } impl BoundedComponentSpanningForest { /// Create a new bounded-component spanning forest instance. pub fn new(graph: G, weights: Vec, max_components: usize, max_weight: W::Sum) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - assert!( - weights - .iter() - .all(|weight| weight.to_sum() >= W::Sum::zero()), - "weights must be nonnegative" - ); - assert!(max_components >= 1, "max_components must be at least 1"); - assert!(max_weight > W::Sum::zero(), "max_weight must be positive"); - Self { + Self::try_new(graph, weights, max_components, max_weight) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + weights: Vec, + max_components: usize, + max_weight: W::Sum, + ) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + if !(weights + .iter() + .all(|weight| weight.to_sum() >= W::Sum::zero())) + { + return Err("weights must be nonnegative".into()); + } + if max_components == 0 { + return Err("max_components must be at least 1".into()); + } + if max_weight.partial_cmp(&W::Sum::zero()) != Some(std::cmp::Ordering::Greater) { + return Err("max_weight must be positive".into()); + } + Ok(Self { graph, weights, max_components, max_weight, - } + }) } /// Get a reference to the underlying graph. @@ -258,8 +299,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.max_components; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.max_components) } } diff --git a/src/models/graph/bounded_diameter_spanning_tree.rs b/src/models/graph/bounded_diameter_spanning_tree.rs index e156f4525..449ea2df0 100644 --- a/src/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/models/graph/bounded_diameter_spanning_tree.rs @@ -59,7 +59,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound( deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>, W::Sum: serde::Deserialize<'de>" ))] @@ -76,6 +76,35 @@ pub struct BoundedDiameterSpanningTree { edge_list: Vec<(usize, usize)>, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct BoundedDiameterSpanningTreeData { + graph: G, + edge_weights: Vec, + weight_bound: W::Sum, + diameter_bound: usize, +} + +impl<'de, G, W> Deserialize<'de> for BoundedDiameterSpanningTree +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = BoundedDiameterSpanningTreeData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_weights, + data.weight_bound, + data.diameter_bound, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BoundedDiameterSpanningTreeCreateSpec { #[create(codec = "edge-list")] @@ -114,12 +143,7 @@ impl TryFrom if spec.diameter_bound == 0 { return Err("diameter_bound must be at least 1".to_string().into()); } - Ok(Self::new( - graph, - edge_weights, - spec.weight_bound, - spec.diameter_bound, - )) + Self::try_new(graph, edge_weights, spec.weight_bound, spec.diameter_bound) } } @@ -163,26 +187,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. @@ -210,6 +240,19 @@ impl BoundedDiameterSpanningTree { self.edge_weights = edge_weights; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + if !weights + .iter() + .all(|weight| weight.to_sum() > W::Sum::zero()) + { + return Err("edge_weights must be positive (> 0)".into()); + } + Ok(()) + } + /// Get the weight bound B. pub fn weight_bound(&self) -> &W::Sum { &self.weight_bound @@ -365,8 +408,12 @@ where G: Graph + VariantParam, W: WeightElement + VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.edge_list.len()] + fn num_variables(&self) -> Result { + Ok(self.edge_list.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/degree_constrained_spanning_tree.rs b/src/models/graph/degree_constrained_spanning_tree.rs index 005289d25..145143ce9 100644 --- a/src/models/graph/degree_constrained_spanning_tree.rs +++ b/src/models/graph/degree_constrained_spanning_tree.rs @@ -55,7 +55,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct DegreeConstrainedSpanningTree { /// The underlying graph. @@ -66,19 +66,42 @@ pub struct DegreeConstrainedSpanningTree { edge_list: Vec<(usize, usize)>, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct DegreeConstrainedSpanningTreeData { + graph: G, + max_degree: usize, +} + +impl<'de, G> Deserialize<'de> for DegreeConstrainedSpanningTree +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = DegreeConstrainedSpanningTreeData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.max_degree).map_err(serde::de::Error::custom) + } +} + impl DegreeConstrainedSpanningTree { /// Create a new Degree-Constrained Spanning Tree instance. /// /// # Panics /// Panics if `max_degree` is zero. pub fn new(graph: G, max_degree: usize) -> Self { - assert!(max_degree >= 1, "max_degree must be at least 1"); + Self::try_new(graph, max_degree).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, max_degree: usize) -> Result { + if max_degree == 0 { + return Err("max_degree must be at least 1".into()); + } let edge_list = graph.edges(); - Self { + Ok(Self { graph, max_degree, edge_list, - } + }) } /// Get a reference to the underlying graph. @@ -191,8 +214,12 @@ impl crate::solvers::BruteForceProblem for DegreeConstrainedSpanningTree where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.edge_list.len()] + fn num_variables(&self) -> Result { + Ok(self.edge_list.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/directed_hamiltonian_path.rs b/src/models/graph/directed_hamiltonian_path.rs index 261e04250..5bec03d32 100644 --- a/src/models/graph/directed_hamiltonian_path.rs +++ b/src/models/graph/directed_hamiltonian_path.rs @@ -32,7 +32,7 @@ inventory::submit! { /// # Representation /// /// A configuration encodes a permutation using the Lehmer code: -/// `dims() = [n, n-1, ..., 2, 1]`, yielding `n!` reachable configurations. +/// `coordinate cardinalities = [n, n-1, ..., 2, 1]`, yielding `n!` reachable configurations. /// Each configuration is decoded to a permutation of `0..n`, and a solution is /// valid when every consecutive pair `(path[i], path[i+1])` is an arc in the /// directed graph. @@ -118,14 +118,13 @@ impl Problem for DirectedHamiltonianPath { } impl crate::solvers::BruteForceProblem for DirectedHamiltonianPath { - fn dimensions(&self) -> Vec { - lehmer_dims(self.graph.num_vertices()) + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) } -} -/// Returns the Lehmer code dimension vector for `n` items: `[n, n-1, ..., 2, 1]`. -pub(crate) fn lehmer_dims(n: usize) -> Vec { - (1..=n).rev().collect() + fn dimension(&self, variable: usize) -> Result { + Ok(self.graph.num_vertices() - variable) + } } /// Decode a Lehmer code into a permutation. diff --git a/src/models/graph/directed_two_commodity_integral_flow.rs b/src/models/graph/directed_two_commodity_integral_flow.rs index 11d7e53ef..192124b1d 100644 --- a/src/models/graph/directed_two_commodity_integral_flow.rs +++ b/src/models/graph/directed_two_commodity_integral_flow.rs @@ -68,6 +68,7 @@ inventory::submit! { /// assert!(solver.solve(&problem).unwrap().is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "DirectedTwoCommodityIntegralFlowData")] pub struct DirectedTwoCommodityIntegralFlow { /// The directed graph G = (V, A). graph: DirectedGraph, @@ -87,6 +88,34 @@ pub struct DirectedTwoCommodityIntegralFlow { requirement_2: i64, } +#[derive(Deserialize)] +struct DirectedTwoCommodityIntegralFlowData { + graph: DirectedGraph, + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: i64, + requirement_2: i64, +} + +impl TryFrom for DirectedTwoCommodityIntegralFlow { + type Error = crate::registry::ConstructionError; + fn try_from(data: DirectedTwoCommodityIntegralFlowData) -> Result { + Self::try_new( + data.graph, + data.capacities, + data.source_1, + data.sink_1, + data.source_2, + data.sink_2, + data.requirement_1, + data.requirement_2, + ) + } +} + impl DirectedTwoCommodityIntegralFlow { /// Create a new Directed Two-Commodity Integral Flow problem. /// @@ -106,25 +135,7 @@ impl DirectedTwoCommodityIntegralFlow { requirement_1: i64, requirement_2: i64, ) -> Self { - let n = graph.num_vertices(); - assert_eq!( - capacities.len(), - graph.num_arcs(), - "capacities length must match graph num_arcs" - ); - assert!( - capacities.iter().all(|&capacity| capacity >= 0), - "capacities must be nonnegative" - ); - assert!( - requirement_1 >= 0 && requirement_2 >= 0, - "flow requirements must be nonnegative" - ); - assert!(source_1 < n, "source_1 ({source_1}) >= num_vertices ({n})"); - assert!(sink_1 < n, "sink_1 ({sink_1}) >= num_vertices ({n})"); - assert!(source_2 < n, "source_2 ({source_2}) >= num_vertices ({n})"); - assert!(sink_2 < n, "sink_2 ({sink_2}) >= num_vertices ({n})"); - Self { + Self::try_new( graph, capacities, source_1, @@ -133,7 +144,53 @@ impl DirectedTwoCommodityIntegralFlow { sink_2, requirement_1, requirement_2, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + #[allow(clippy::too_many_arguments)] + fn try_new( + graph: DirectedGraph, + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: i64, + requirement_2: i64, + ) -> Result { + let n = graph.num_vertices(); + if capacities.len() != graph.num_arcs() { + return Err("capacities length must match graph num_arcs".into()); + } + if !(capacities.iter().all(|&capacity| capacity >= 0)) { + return Err("capacities must be nonnegative".into()); + } + if !(requirement_1 >= 0 && requirement_2 >= 0) { + return Err("flow requirements must be nonnegative".into()); + } + if !(source_1 < n) { + return Err(format!("source_1 ({source_1}) >= num_vertices ({n})").into()); + } + if !(sink_1 < n) { + return Err(format!("sink_1 ({sink_1}) >= num_vertices ({n})").into()); + } + if !(source_2 < n) { + return Err(format!("source_2 ({source_2}) >= num_vertices ({n})").into()); + } + if !(sink_2 < n) { + return Err(format!("sink_2 ({sink_2}) >= num_vertices ({n})").into()); } + Ok(Self { + graph, + capacities, + source_1, + sink_1, + source_2, + sink_2, + requirement_1, + requirement_2, + }) } /// Get a reference to the underlying directed graph. @@ -319,12 +376,14 @@ impl Problem for DirectedTwoCommodityIntegralFlow { } impl crate::solvers::BruteForceProblem for DirectedTwoCommodityIntegralFlow { - fn dimensions(&self) -> Vec { - self.capacities - .iter() - .chain(self.capacities.iter()) - .map(|&c| (c as usize) + 1) - .collect() + fn num_variables(&self) -> Result { + Ok(2 * self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from( + i128::from(self.capacities[variable % self.capacities.len()]) + 1, + )?) } } diff --git a/src/models/graph/disjoint_connecting_paths.rs b/src/models/graph/disjoint_connecting_paths.rs index 551e97e0f..049aeeb2b 100644 --- a/src/models/graph/disjoint_connecting_paths.rs +++ b/src/models/graph/disjoint_connecting_paths.rs @@ -30,13 +30,30 @@ inventory::submit! { /// A configuration uses one binary variable per edge in the graph's canonical /// sorted edge list. A valid solution selects exactly the edges of one simple /// path for each terminal pair, with all such paths pairwise vertex-disjoint. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::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<'de, G> Deserialize<'de> for DisjointConnectingPaths +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = DisjointConnectingPathsData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.terminal_pairs).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct DisjointConnectingPathsCreateSpec { #[create(codec = "edge-list")] @@ -101,33 +118,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. @@ -201,8 +228,12 @@ impl crate::solvers::BruteForceProblem for DisjointConnectingPaths where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/eulerian_path.rs b/src/models/graph/eulerian_path.rs index 661ebd04a..46bda0c9d 100644 --- a/src/models/graph/eulerian_path.rs +++ b/src/models/graph/eulerian_path.rs @@ -44,7 +44,7 @@ inventory::submit! { /// A configuration is an arc-ordering `pi`: position `t` carries the index of /// the arc occurrence used as the `t`-th arc of the trail. /// -/// `dims() = vec![m; m]` where `m = num_arcs()`. A configuration is feasible +/// `coordinate cardinalities = vec![m; m]` where `m = num_arcs()`. A configuration is feasible /// when: /// 1. it is a permutation of `0..m` (all values distinct, each in range), and /// 2. for every consecutive pair `(pi[t], pi[t+1])`, the target vertex of arc @@ -135,9 +135,12 @@ impl Problem for EulerianPath { } impl crate::solvers::BruteForceProblem for EulerianPath { - fn dimensions(&self) -> Vec { - let m = self.graph.num_arcs(); - vec![m; m] + fn num_variables(&self) -> Result { + Ok(self.graph.num_arcs()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_arcs()) } } diff --git a/src/models/graph/generalized_hex.rs b/src/models/graph/generalized_hex.rs index 7e91bc9b9..ac383d7d4 100644 --- a/src/models/graph/generalized_hex.rs +++ b/src/models/graph/generalized_hex.rs @@ -32,7 +32,7 @@ inventory::submit! { /// The problem is represented as a zero-variable decision problem: the graph /// instance fully determines the question, so `evaluate([])` runs a memoized /// game-tree search from the initial empty board. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct GeneralizedHex { graph: G, @@ -40,6 +40,24 @@ pub struct GeneralizedHex { target: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct GeneralizedHexData { + graph: G, + source: usize, + target: usize, +} + +impl<'de, G> Deserialize<'de> for GeneralizedHex +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = GeneralizedHexData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.source, data.target).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct GeneralizedHexCreateSpec { /// The underlying graph G=(V,E). @@ -72,7 +90,7 @@ impl TryFrom for GeneralizedHex { if spec.source == spec.sink { return Err("source and sink must be distinct".to_string().into()); } - Ok(Self::new(spec.graph, spec.source, spec.sink)) + Self::try_new(spec.graph, spec.source, spec.sink) } } @@ -86,15 +104,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. @@ -305,8 +337,12 @@ impl crate::solvers::BruteForceProblem for GeneralizedHex where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![] + fn num_variables(&self) -> Result { + Ok(0) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(0) } } diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs index 6a59a6e48..79af630ba 100644 --- a/src/models/graph/graph_partitioning.rs +++ b/src/models/graph/graph_partitioning.rs @@ -138,8 +138,12 @@ impl crate::solvers::BruteForceProblem for GraphPartitioning where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/hamiltonian_circuit.rs b/src/models/graph/hamiltonian_circuit.rs index 35ea2621c..f26c3105d 100644 --- a/src/models/graph/hamiltonian_circuit.rs +++ b/src/models/graph/hamiltonian_circuit.rs @@ -128,9 +128,12 @@ impl crate::solvers::BruteForceProblem for HamiltonianCircuit where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/hamiltonian_path.rs b/src/models/graph/hamiltonian_path.rs index b31d6d2ff..01afc8349 100644 --- a/src/models/graph/hamiltonian_path.rs +++ b/src/models/graph/hamiltonian_path.rs @@ -38,7 +38,7 @@ inventory::submit! { /// vertex visited at step `i`. A valid solution must be a permutation of /// `0..n` where consecutive entries are adjacent in the graph. /// -/// The search space has `dims() = [n; n]` (each position can hold any of `n` +/// The search space has `coordinate cardinalities = [n; n]` (each position can hold any of `n` /// vertices), so brute-force enumerates `n^n` configurations. Only `n!` /// permutations can satisfy the constraints, but the encoding avoids complex /// variable-domain schemes and matches the problem's natural formulation. @@ -135,9 +135,12 @@ impl crate::solvers::BruteForceProblem for HamiltonianPath where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/hamiltonian_path_between_two_vertices.rs b/src/models/graph/hamiltonian_path_between_two_vertices.rs index 7ae5bbbcd..cec1931d3 100644 --- a/src/models/graph/hamiltonian_path_between_two_vertices.rs +++ b/src/models/graph/hamiltonian_path_between_two_vertices.rs @@ -46,7 +46,7 @@ inventory::submit! { /// - The last element equals `target_vertex` /// - Consecutive entries are adjacent in the graph /// -/// The search space has `dims() = [n; n]` (each position can hold any of `n` +/// The search space has `coordinate cardinalities = [n; n]` (each position can hold any of `n` /// vertices), so brute-force enumerates `n^n` configurations. /// /// # Type Parameters @@ -68,7 +68,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct HamiltonianPathBetweenTwoVertices { graph: G, @@ -76,6 +76,25 @@ pub struct HamiltonianPathBetweenTwoVertices { target_vertex: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct HamiltonianPathBetweenTwoVerticesData { + graph: G, + source_vertex: usize, + target_vertex: usize, +} + +impl<'de, G> Deserialize<'de> for HamiltonianPathBetweenTwoVertices +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = HamiltonianPathBetweenTwoVerticesData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.source_vertex, data.target_vertex) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct HamiltonianPathBetweenTwoVerticesRandomSpec { /// Number of graph vertices. @@ -97,24 +116,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. @@ -192,9 +222,12 @@ impl crate::solvers::BruteForceProblem for HamiltonianPathBetweenTwoVertices< where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/highly_connected_deletion.rs b/src/models/graph/highly_connected_deletion.rs index d1f7fca64..55c067132 100644 --- a/src/models/graph/highly_connected_deletion.rs +++ b/src/models/graph/highly_connected_deletion.rs @@ -148,8 +148,12 @@ impl crate::solvers::BruteForceProblem for HighlyConnectedDeletion where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/integral_flow_bundles.rs b/src/models/graph/integral_flow_bundles.rs index a9f99f931..c5e161dc0 100644 --- a/src/models/graph/integral_flow_bundles.rs +++ b/src/models/graph/integral_flow_bundles.rs @@ -24,6 +24,7 @@ inventory::submit! { /// Integral Flow with Bundles (Garey & Johnson ND36). #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "IntegralFlowBundlesData")] pub struct IntegralFlowBundles { graph: DirectedGraph, source: usize, @@ -33,6 +34,30 @@ pub struct IntegralFlowBundles { requirement: i64, } +#[derive(Deserialize)] +struct IntegralFlowBundlesData { + graph: DirectedGraph, + source: usize, + sink: usize, + bundles: Vec>, + bundle_capacities: Vec, + requirement: i64, +} + +impl TryFrom for IntegralFlowBundles { + type Error = crate::registry::ConstructionError; + fn try_from(data: IntegralFlowBundlesData) -> Result { + Self::try_new( + data.graph, + data.source, + data.sink, + data.bundles, + data.bundle_capacities, + data.requirement, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct IntegralFlowBundlesCreateSpec { #[create(codec = "arc-list")] @@ -67,57 +92,14 @@ impl TryFrom for IntegralFlowBundles { if count < inferred { return Err("num_vertices is too small".into()); } - if spec.source >= count || spec.sink >= count { - return Err("source and sink must be valid vertices".into()); - } - if spec.source == spec.sink { - return Err("source and sink must be distinct".into()); - } - if spec.bundles.len() != spec.bundle_capacities.len() { - return Err("bundles length must match bundle_capacities length".into()); - } - if spec.requirement == 0 { - return Err("requirement must be positive".into()); - } - let mut covered = vec![false; spec.arcs.len()]; - let mut upper = vec![i64::MAX; spec.arcs.len()]; - for (i, (bundle, &capacity)) in spec.bundles.iter().zip(&spec.bundle_capacities).enumerate() - { - if capacity == 0 { - return Err(format!("bundle capacity {i} must be positive").into()); - } - let mut seen = BTreeSet::new(); - for &arc in bundle { - if arc >= spec.arcs.len() { - return Err(format!("bundle {i} arc is out of range").into()); - } - if !seen.insert(arc) { - return Err(format!("bundle {i} contains duplicate arc").into()); - } - covered[arc] = true; - upper[arc] = upper[arc].min(capacity); - } - } - for (arc, &is_covered) in covered.iter().enumerate() { - if !is_covered { - return Err(format!("arc {arc} must belong to a bundle").into()); - } - if usize::try_from(upper[arc]) - .ok() - .and_then(|v| v.checked_add(1)) - .is_none() - { - return Err(format!("arc {arc} upper bound is too large").into()); - } - } - Ok(Self { - graph: DirectedGraph::new(count, spec.arcs), - source: spec.source, - sink: spec.sink, - bundles: spec.bundles, - bundle_capacities: spec.bundle_capacities, - requirement: spec.requirement, - }) + Self::try_new( + DirectedGraph::new(count, spec.arcs), + spec.source, + spec.sink, + spec.bundles, + spec.bundle_capacities, + spec.requirement, + ) } } @@ -131,73 +113,77 @@ impl IntegralFlowBundles { bundle_capacities: Vec, requirement: i64, ) -> Self { + Self::try_new(graph, source, sink, bundles, bundle_capacities, requirement) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + source: usize, + sink: usize, + bundles: Vec>, + bundle_capacities: Vec, + requirement: i64, + ) -> Result { let num_vertices = graph.num_vertices(); let num_arcs = graph.num_arcs(); - assert!( - source < num_vertices, - "source ({source}) >= num_vertices ({num_vertices})" - ); - assert!( - sink < num_vertices, - "sink ({sink}) >= num_vertices ({num_vertices})" - ); - assert!(source != sink, "source and sink must be distinct"); - assert_eq!( - bundles.len(), - bundle_capacities.len(), - "bundles length must match bundle_capacities length" - ); - assert!(requirement > 0, "requirement must be positive"); + if !(source < num_vertices) { + return Err(format!("source ({source}) >= num_vertices ({num_vertices})").into()); + } + if !(sink < num_vertices) { + return Err(format!("sink ({sink}) >= num_vertices ({num_vertices})").into()); + } + if source == sink { + 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()); + } + if requirement <= 0 { + return Err("requirement must be positive".into()); + } let mut arc_covered = vec![false; num_arcs]; - let mut arc_upper_bounds = vec![i64::MAX; num_arcs]; for (bundle_index, (bundle, &capacity)) in bundles.iter().zip(&bundle_capacities).enumerate() { - assert!( - capacity > 0, - "bundle capacity at index {bundle_index} must be positive" - ); + if !(capacity > 0) { + return Err( + format!("bundle capacity at index {bundle_index} must be positive").into(), + ); + } let mut seen = BTreeSet::new(); for &arc_index in bundle { - assert!( - arc_index < num_arcs, - "bundle {bundle_index} references arc {arc_index}, but num_arcs is {num_arcs}" - ); - assert!( - seen.insert(arc_index), - "bundle {bundle_index} contains duplicate arc index {arc_index}" - ); + if !(arc_index < num_arcs) { + return Err(format!("bundle {bundle_index} arc is out of range: index {arc_index}, num_arcs {num_arcs}").into()); + } + if !(seen.insert(arc_index)) { + return Err(format!( + "bundle {bundle_index} contains duplicate arc index {arc_index}" + ) + .into()); + } arc_covered[arc_index] = true; - arc_upper_bounds[arc_index] = arc_upper_bounds[arc_index].min(capacity); } } for (arc_index, covered) in arc_covered.iter().copied().enumerate() { - assert!( - covered, - "arc {arc_index} must belong to at least one bundle" - ); - let domain = usize::try_from(arc_upper_bounds[arc_index]) - .ok() - .and_then(|bound| bound.checked_add(1)); - assert!( - domain.is_some(), - "bundle-derived upper bound for arc {arc_index} must fit into usize for dims()" - ); + if !(covered) { + return Err(format!("arc {arc_index} must belong to at least one bundle").into()); + } } - Self { + Ok(Self { graph, source, sink, bundles, bundle_capacities, requirement, - } + }) } /// Get the underlying directed graph. @@ -369,16 +355,20 @@ impl Problem for IntegralFlowBundles { } impl crate::solvers::BruteForceProblem for IntegralFlowBundles { - fn dimensions(&self) -> Vec { - self.arc_upper_bounds() - .into_iter() - .map(|bound| { - usize::try_from(bound) - .ok() - .and_then(|bound| bound.checked_add(1)) - .expect("bundle-derived arc upper bounds are validated in the constructor") - }) - .collect() + fn num_variables(&self) -> Result { + Ok(self.num_arcs()) + } + + fn dimension(&self, variable: usize) -> Result { + let bound = self + .bundles + .iter() + .zip(&self.bundle_capacities) + .filter(|(bundle, _)| bundle.contains(&variable)) + .map(|(_, &capacity)| capacity) + .min() + .expect("each arc belongs to at least one bundle"); + Ok(usize::try_from(i128::from(bound) + 1)?) } } diff --git a/src/models/graph/integral_flow_homologous_arcs.rs b/src/models/graph/integral_flow_homologous_arcs.rs index 0ea45a9e2..6f175c49b 100644 --- a/src/models/graph/integral_flow_homologous_arcs.rs +++ b/src/models/graph/integral_flow_homologous_arcs.rs @@ -29,6 +29,7 @@ inventory::submit! { /// capacities, flow conservation at non-terminal vertices, every homologous-pair /// equality constraint, and the required net inflow at the sink. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "IntegralFlowHomologousArcsData")] pub struct IntegralFlowHomologousArcs { graph: DirectedGraph, capacities: Vec, @@ -38,6 +39,30 @@ pub struct IntegralFlowHomologousArcs { homologous_pairs: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct IntegralFlowHomologousArcsData { + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + requirement: i64, + homologous_pairs: Vec<(usize, usize)>, +} + +impl TryFrom for IntegralFlowHomologousArcs { + type Error = crate::registry::ConstructionError; + fn try_from(data: IntegralFlowHomologousArcsData) -> Result { + Self::try_new( + data.graph, + data.capacities, + data.source, + data.sink, + data.requirement, + data.homologous_pairs, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct IntegralFlowHomologousArcsCreateSpec { #[create(codec = "arc-list")] @@ -73,34 +98,14 @@ impl TryFrom for IntegralFlowHomologousArc return Err("num_vertices is too small".into()); } let capacities = spec.capacities.unwrap_or_else(|| vec![1; spec.arcs.len()]); - if capacities.len() != spec.arcs.len() { - return Err("capacities length must match arcs length".into()); - } - if spec.source >= count || spec.sink >= count { - return Err("source and sink must be valid vertices".into()); - } - for &(a, b) in &spec.homologous_pairs { - if a >= spec.arcs.len() || b >= spec.arcs.len() { - return Err("homologous pair arc index is out of range".into()); - } - } - for &c in &capacities { - if usize::try_from(c) - .ok() - .and_then(|v| v.checked_add(1)) - .is_none() - { - return Err("capacity is too large".into()); - } - } - Ok(Self { - graph: DirectedGraph::new(count, spec.arcs), + Self::try_new( + DirectedGraph::new(count, spec.arcs), capacities, - source: spec.source, - sink: spec.sink, - requirement: spec.requirement, - homologous_pairs: spec.homologous_pairs, - }) + spec.source, + spec.sink, + spec.requirement, + spec.homologous_pairs, + ) } } @@ -113,46 +118,64 @@ impl IntegralFlowHomologousArcs { requirement: i64, homologous_pairs: Vec<(usize, usize)>, ) -> Self { + Self::try_new( + graph, + capacities, + source, + sink, + requirement, + homologous_pairs, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + requirement: i64, + homologous_pairs: Vec<(usize, usize)>, + ) -> Result { let num_vertices = graph.num_vertices(); let num_arcs = graph.num_arcs(); - assert_eq!( - capacities.len(), - num_arcs, - "capacities length must match graph.num_arcs()" - ); - assert!( - source < num_vertices, - "source ({source}) must be less than num_vertices ({num_vertices})" - ); - assert!( - sink < num_vertices, - "sink ({sink}) must be less than num_vertices ({num_vertices})" - ); + if capacities.len() != num_arcs { + return Err("capacities length must match graph.num_arcs()".into()); + } + if !(source < num_vertices) { + return Err(format!( + "source ({source}) must be less than num_vertices ({num_vertices})" + ) + .into()); + } + if !(sink < num_vertices) { + return Err( + format!("sink ({sink}) must be less than num_vertices ({num_vertices})").into(), + ); + } for &(a, b) in &homologous_pairs { - assert!(a < num_arcs, "homologous arc index {a} out of range"); - assert!(b < num_arcs, "homologous arc index {b} out of range"); + if !(a < num_arcs) { + return Err(format!("homologous arc index {a} out of range").into()); + } + if !(b < num_arcs) { + return Err(format!("homologous arc index {b} out of range").into()); + } } - for &capacity in &capacities { - assert!( - usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some(), - "capacities must fit into usize for dims()" - ); + if !(capacities.iter().all(|&capacity| capacity >= 0)) { + return Err("capacities must be nonnegative".into()); } - Self { + Ok(Self { graph, capacities, source, sink, requirement, homologous_pairs, - } + }) } pub fn graph(&self) -> &DirectedGraph { @@ -248,13 +271,6 @@ impl IntegralFlowHomologousArcs { Ok(crate::types::Or(balances[self.sink] >= self.requirement)) } - - fn domain_size(capacity: i64) -> usize { - usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .expect("capacity already validated to fit into usize") - } } impl Problem for IntegralFlowHomologousArcs { @@ -281,11 +297,12 @@ impl Problem for IntegralFlowHomologousArcs { } impl crate::solvers::BruteForceProblem for IntegralFlowHomologousArcs { - fn dimensions(&self) -> Vec { - self.capacities - .iter() - .map(|&capacity| Self::domain_size(capacity)) - .collect() + fn num_variables(&self) -> Result { + Ok(self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.capacities[variable]) + 1)?) } } diff --git a/src/models/graph/integral_flow_with_multipliers.rs b/src/models/graph/integral_flow_with_multipliers.rs index d5730c110..cbbcb3531 100644 --- a/src/models/graph/integral_flow_with_multipliers.rs +++ b/src/models/graph/integral_flow_with_multipliers.rs @@ -23,6 +23,7 @@ inventory::submit! { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "IntegralFlowWithMultipliersData")] pub struct IntegralFlowWithMultipliers { graph: DirectedGraph, source: usize, @@ -32,6 +33,30 @@ pub struct IntegralFlowWithMultipliers { requirement: i64, } +#[derive(Deserialize)] +struct IntegralFlowWithMultipliersData { + graph: DirectedGraph, + source: usize, + sink: usize, + multipliers: Vec, + capacities: Vec, + requirement: i64, +} + +impl TryFrom for IntegralFlowWithMultipliers { + type Error = crate::registry::ConstructionError; + fn try_from(data: IntegralFlowWithMultipliersData) -> Result { + Self::try_new( + data.graph, + data.source, + data.sink, + data.multipliers, + data.capacities, + data.requirement, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct IntegralFlowWithMultipliersCreateSpec { #[create(codec = "arc-list")] @@ -66,40 +91,14 @@ impl TryFrom for IntegralFlowWithMultipli if count < inferred { return Err("num_vertices is too small".into()); } - if spec.capacities.len() != spec.arcs.len() { - return Err("capacities length must match arcs length".into()); - } - if spec.multipliers.len() != count { - return Err("multipliers length must match num_vertices".into()); - } - if spec.source >= count || spec.sink >= count { - return Err("source and sink must be valid vertices".into()); - } - if spec.source == spec.sink { - return Err("source and sink must be distinct".into()); - } - for (v, &m) in spec.multipliers.iter().enumerate() { - if v != spec.source && v != spec.sink && m == 0 { - return Err("non-terminal multipliers must be positive".into()); - } - } - for &c in &spec.capacities { - if usize::try_from(c) - .ok() - .and_then(|v| v.checked_add(1)) - .is_none() - { - return Err("capacity is too large".into()); - } - } - Ok(Self { - graph: DirectedGraph::new(count, spec.arcs), - source: spec.source, - sink: spec.sink, - multipliers: spec.multipliers, - capacities: spec.capacities, - requirement: spec.requirement, - }) + Self::try_new( + DirectedGraph::new(count, spec.arcs), + spec.source, + spec.sink, + spec.multipliers, + spec.capacities, + spec.requirement, + ) } } @@ -112,52 +111,59 @@ impl IntegralFlowWithMultipliers { capacities: Vec, requirement: i64, ) -> Self { - assert_eq!( - capacities.len(), - graph.num_arcs(), - "capacities length must match graph num_arcs" - ); - assert_eq!( - multipliers.len(), - graph.num_vertices(), - "multipliers length must match graph num_vertices" - ); + Self::try_new(graph, source, sink, multipliers, capacities, requirement) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + source: usize, + sink: usize, + multipliers: Vec, + capacities: Vec, + requirement: i64, + ) -> Result { + if capacities.len() != graph.num_arcs() { + return Err("capacities length must match graph num_arcs".into()); + } + if multipliers.len() != graph.num_vertices() { + return Err("multipliers length must match num_vertices".into()); + } let num_vertices = graph.num_vertices(); - assert!( - source < num_vertices, - "source ({source}) must be less than num_vertices ({num_vertices})" - ); - assert!( - sink < num_vertices, - "sink ({sink}) must be less than num_vertices ({num_vertices})" - ); - assert_ne!(source, sink, "source and sink must be distinct"); + if !(source < num_vertices) { + return Err(format!( + "source ({source}) must be less than num_vertices ({num_vertices})" + ) + .into()); + } + if !(sink < num_vertices) { + return Err( + format!("sink ({sink}) must be less than num_vertices ({num_vertices})").into(), + ); + } + if source == sink { + return Err("source and sink must be distinct".into()); + } for (vertex, &multiplier) in multipliers.iter().enumerate() { - if vertex != source && vertex != sink { - assert!(multiplier > 0, "non-terminal multipliers must be positive"); + if vertex != source && vertex != sink && !(multiplier > 0) { + return Err("non-terminal multipliers must be positive".into()); } } - for &capacity in &capacities { - let domain = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)); - assert!( - domain.is_some(), - "arc capacities must fit into usize for dims()" - ); + if !(capacities.iter().all(|&capacity| capacity >= 0)) { + return Err("capacities must be nonnegative".into()); } - Self { + Ok(Self { graph, source, sink, multipliers, capacities, requirement, - } + }) } pub fn graph(&self) -> &DirectedGraph { @@ -196,13 +202,6 @@ impl IntegralFlowWithMultipliers { self.capacities.iter().copied().max().unwrap_or(0) } - fn domain_size(capacity: i64) -> usize { - usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .expect("capacity already validated to fit into usize") - } - pub fn is_feasible(&self, config: &[usize]) -> Result { if config.len() != self.num_arcs() { return Ok(false); @@ -297,11 +296,12 @@ impl Problem for IntegralFlowWithMultipliers { } impl crate::solvers::BruteForceProblem for IntegralFlowWithMultipliers { - fn dimensions(&self) -> Vec { - self.capacities - .iter() - .map(|&capacity| Self::domain_size(capacity)) - .collect() + fn num_variables(&self) -> Result { + Ok(self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.capacities[variable]) + 1)?) } } diff --git a/src/models/graph/isomorphic_spanning_tree.rs b/src/models/graph/isomorphic_spanning_tree.rs index ad2877684..2e18c821a 100644 --- a/src/models/graph/isomorphic_spanning_tree.rs +++ b/src/models/graph/isomorphic_spanning_tree.rs @@ -172,8 +172,12 @@ impl crate::solvers::BruteForceProblem for IsomorphicSpanningTree where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.graph.num_vertices(); self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/kclique.rs b/src/models/graph/kclique.rs index 07bca75e7..336900979 100644 --- a/src/models/graph/kclique.rs +++ b/src/models/graph/kclique.rs @@ -26,12 +26,29 @@ inventory::submit! { /// Given a graph `G = (V, E)` and a positive integer `k`, determine whether /// there exists a subset `K ⊆ V` of size at least `k` such that every pair of /// distinct vertices in `K` is adjacent. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct KClique { graph: G, k: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct KCliqueData { + graph: G, + k: usize, +} + +impl<'de, G> Deserialize<'de> for KClique +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = KCliqueData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.k).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct KCliqueCreateSpec { #[create(codec = "edge-list")] @@ -79,9 +96,17 @@ impl TryFrom for KClique { impl KClique { /// Create a new k-Clique problem instance. pub fn new(graph: G, k: usize) -> Self { - assert!(k > 0, "k must be positive"); - assert!(k <= graph.num_vertices(), "k must be <= graph num_vertices"); - Self { graph, k } + Self::try_new(graph, k).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, k: usize) -> Result { + if k == 0 { + return Err("k must be positive".into()); + } + if !(k <= graph.num_vertices()) { + return Err("k must be <= graph num_vertices".into()); + } + Ok(Self { graph, k }) } /// Get a reference to the underlying graph. @@ -163,8 +188,12 @@ impl crate::solvers::BruteForceProblem for KClique where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs index 9f1662123..a054c42ea 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!(), @@ -273,8 +273,12 @@ impl crate::solvers::BruteForceProblem for KColoring where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.num_colors; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_colors) } } @@ -333,32 +337,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/kernel.rs b/src/models/graph/kernel.rs index 359c5ad42..b0610bd22 100644 --- a/src/models/graph/kernel.rs +++ b/src/models/graph/kernel.rs @@ -136,8 +136,12 @@ impl Problem for Kernel { } impl crate::solvers::BruteForceProblem for Kernel { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/kth_best_spanning_tree.rs b/src/models/graph/kth_best_spanning_tree.rs index e841da212..af7ac0597 100644 --- a/src/models/graph/kth_best_spanning_tree.rs +++ b/src/models/graph/kth_best_spanning_tree.rs @@ -34,7 +34,7 @@ inventory::submit! { /// /// A configuration is `k` consecutive binary blocks of length `|E|`. /// Each block selects the edges of one candidate spanning tree. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct KthBestSpanningTree { graph: SimpleGraph, weights: Vec, @@ -42,6 +42,27 @@ pub struct KthBestSpanningTree { bound: W::Sum, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>"))] +struct KthBestSpanningTreeData { + graph: SimpleGraph, + weights: Vec, + k: usize, + bound: W::Sum, +} + +impl<'de, W> Deserialize<'de> for KthBestSpanningTree +where + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = KthBestSpanningTreeData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights, data.k, data.bound) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct KthBestSpanningTreeCreateSpec { #[create(codec = "edge-list")] @@ -72,7 +93,7 @@ impl TryFrom for KthBestSpanningTree { if spec.k == 0 { return Err("k must be positive".to_string().into()); } - Ok(Self::new(graph, weights, spec.k, spec.bound)) + Self::try_new(graph, weights, spec.k, spec.bound) } } @@ -112,19 +133,28 @@ impl KthBestSpanningTree { /// Panics if the number of weights does not match the number of edges, or /// if `k` is zero. pub fn new(graph: SimpleGraph, weights: Vec, k: usize, bound: W::Sum) -> Self { - assert_eq!( - weights.len(), - graph.num_edges(), - "weights length must match graph num_edges" - ); - assert!(k > 0, "k must be positive"); - - Self { + Self::try_new(graph, weights, k, bound).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: SimpleGraph, + weights: Vec, + k: usize, + bound: W::Sum, + ) -> Result { + if weights.len() != graph.num_edges() { + return Err("weights length must match graph num_edges".into()); + } + if k == 0 { + return Err("k must be positive".into()); + } + + Ok(Self { graph, weights, k, bound, - } + }) } /// Get the underlying graph. @@ -303,8 +333,14 @@ impl crate::solvers::BruteForceProblem for KthBestSpanningTree where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.k * self.graph.num_edges()] + fn num_variables(&self) -> Result { + (self.k).checked_mul(self.graph.num_edges()).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/length_bounded_disjoint_paths.rs b/src/models/graph/length_bounded_disjoint_paths.rs index a12a08172..59d2d3405 100644 --- a/src/models/graph/length_bounded_disjoint_paths.rs +++ b/src/models/graph/length_bounded_disjoint_paths.rs @@ -33,7 +33,7 @@ inventory::submit! { /// vertices of different slots must be disjoint. Empty slots (all zeros) are /// unused and do not count toward the objective. The objective is to maximize /// the number of non-empty valid path slots. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct LengthBoundedDisjointPaths { graph: G, @@ -43,6 +43,35 @@ pub struct LengthBoundedDisjointPaths { max_length: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct LengthBoundedDisjointPathsData { + graph: G, + source: usize, + sink: usize, + max_paths: usize, + max_length: usize, +} + +impl<'de, G> Deserialize<'de> for LengthBoundedDisjointPaths +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = LengthBoundedDisjointPathsData::::deserialize(deserializer)?; + let max_paths = data.max_paths; + let instance = Self::try_new(data.graph, data.source, data.sink, data.max_length) + .map_err(serde::de::Error::custom)?; + if max_paths != instance.max_paths { + return Err(serde::de::Error::custom(format!( + "max_paths must equal min(deg(source), deg(sink)): expected {}, got {max_paths}", + instance.max_paths + ))); + } + Ok(instance) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct LengthBoundedDisjointPathsCreateSpec { /// Undirected graph edges. @@ -140,26 +169,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. @@ -243,8 +283,16 @@ impl crate::solvers::BruteForceProblem for LengthBoundedDisjointPaths where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.max_paths * self.graph.num_edges()] + fn num_variables(&self) -> Result { + (self.max_paths) + .checked_mul(self.graph.num_edges()) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs index f60e24bff..07827bbbb 100644 --- a/src/models/graph/longest_circuit.rs +++ b/src/models/graph/longest_circuit.rs @@ -40,12 +40,30 @@ inventory::submit! { /// /// A valid configuration must select edges that form exactly one connected /// simple circuit using only edges from `graph`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct LongestCircuit { graph: G, edge_lengths: Vec, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct LongestCircuitData { + graph: G, + edge_lengths: Vec, +} + +impl<'de, G, W> Deserialize<'de> for LongestCircuit +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = LongestCircuitData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_lengths).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct LongestCircuitCreateSpec { #[create(codec = "edge-list")] @@ -74,7 +92,7 @@ impl TryFrom for LongestCircuit { if edge_lengths.iter().any(|&length| length <= 0) { return Err("edge_weights must be positive".to_string().into()); } - Ok(Self::new(graph, edge_lengths)) + Self::try_new(graph, edge_lengths) } } @@ -117,22 +135,15 @@ impl LongestCircuit { /// Panics if the number of edge lengths does not match the graph's edge /// count, or if any edge length is non-positive. pub fn new(graph: G, edge_lengths: Vec) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - let zero = W::Sum::zero(); - assert!( - edge_lengths - .iter() - .all(|length| length.to_sum() > zero.clone()), - "All edge lengths must be positive (> 0)" - ); - Self { + Self::try_new(graph, edge_lengths).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, edge_lengths: Vec) -> Result { + Self::check_weights(&graph, &edge_lengths)?; + Ok(Self { graph, edge_lengths, - } + }) } /// Get a reference to the underlying graph. @@ -162,6 +173,19 @@ impl LongestCircuit { self.edge_lengths = edge_lengths; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + if !weights + .iter() + .all(|weight| weight.to_sum() > W::Sum::zero()) + { + return Err("All edge lengths must be positive (> 0)".into()); + } + Ok(()) + } + /// Replace the edge lengths via the generic weight-management naming. pub fn set_weights(&mut self, weights: Vec) { self.set_lengths(weights); @@ -241,8 +265,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -358,3 +386,62 @@ crate::register_brute_force! { #[cfg(test)] #[path = "../../unit_tests/models/graph/longest_circuit.rs"] mod tests; + +crate::decision_problem_meta!(LongestCircuit, "DecisionLongestCircuit"); +crate::register_decision_variant!( + LongestCircuit, "DecisionLongestCircuit", "2^num_vertices * num_vertices^2", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_longest_circuit_to_longest_circuit", + build: || { + let source = crate::models::decision::Decision::new( + LongestCircuit::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + (2, 5), + (3, 5), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 1, 2], + ), + 18, + ); + let witness = serde_json::json!(vec![ + true, false, true, false, true, false, true, true, true, false + ]); + crate::example_db::specs::rule_example_with_witness::<_, LongestCircuit>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/longest_path.rs b/src/models/graph/longest_path.rs index 2423f24fb..527534cfc 100644 --- a/src/models/graph/longest_path.rs +++ b/src/models/graph/longest_path.rs @@ -40,7 +40,7 @@ inventory::submit! { /// /// A valid configuration must select exactly the edges of one simple /// undirected path from `source_vertex` to `target_vertex`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct LongestPath { graph: G, edge_lengths: Vec, @@ -48,6 +48,32 @@ pub struct LongestPath { target_vertex: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct LongestPathData { + graph: G, + edge_lengths: Vec, + source_vertex: usize, + target_vertex: usize, +} + +impl<'de, G, W> Deserialize<'de> for LongestPath +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = LongestPathData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_lengths, + data.source_vertex, + data.target_vertex, + ) + .map_err(serde::de::Error::custom) + } +} + macro_rules! longest_path_create_spec { (@lengths $spec:ident, $lengths:ident) => { $spec.$lengths }; (@lengths $spec:ident) => { vec![One; $spec.graph.len()] }; @@ -109,42 +135,41 @@ longest_path_create_spec!(LongestPathI64CreateSpec, i64, edge_lengths); longest_path_create_spec!(LongestPathOneCreateSpec, One); impl LongestPath { - fn assert_positive_edge_lengths(edge_lengths: &[W]) { - let zero = W::Sum::zero(); - assert!( - edge_lengths - .iter() - .all(|length| length.to_sum() > zero.clone()), - "All edge lengths must be positive (> 0)" - ); - } - /// Create a new LongestPath instance. pub fn new(graph: G, edge_lengths: Vec, source_vertex: usize, target_vertex: usize) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - Self::assert_positive_edge_lengths(&edge_lengths); - assert!( - source_vertex < graph.num_vertices(), - "source_vertex {} out of bounds (graph has {} vertices)", - source_vertex, - graph.num_vertices() - ); - assert!( - target_vertex < graph.num_vertices(), - "target_vertex {} out of bounds (graph has {} vertices)", - target_vertex, - graph.num_vertices() - ); - Self { + Self::try_new(graph, edge_lengths, source_vertex, target_vertex) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_lengths: Vec, + source_vertex: usize, + target_vertex: usize, + ) -> Result { + Self::check_weights(&graph, &edge_lengths)?; + if !(source_vertex < graph.num_vertices()) { + return Err(format!( + "source_vertex {} out of bounds (graph has {} vertices)", + source_vertex, + graph.num_vertices() + ) + .into()); + } + if !(target_vertex < graph.num_vertices()) { + return Err(format!( + "target_vertex {} out of bounds (graph has {} vertices)", + target_vertex, + graph.num_vertices() + ) + .into()); + } + Ok(Self { graph, edge_lengths, source_vertex, target_vertex, - } + }) } /// Get a reference to the underlying graph. @@ -168,6 +193,19 @@ impl LongestPath { self.edge_lengths = edge_lengths; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + if !weights + .iter() + .all(|weight| weight.to_sum() > W::Sum::zero()) + { + return Err("All edge lengths must be positive (> 0)".into()); + } + Ok(()) + } + /// Get the source vertex. pub fn source_vertex(&self) -> usize { self.source_vertex @@ -203,6 +241,16 @@ impl LongestPath { config, ) } + + 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)" + ); + } } impl Problem for LongestPath @@ -254,8 +302,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -303,3 +355,44 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionLongestPath"); +crate::register_decision_variant!( + LongestPath, "DecisionLongestPath", "num_vertices * 2^num_vertices", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "One", &["One"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "source_vertex", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "target_vertex", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_longest_path_to_longest_path", + build: || { + let source = crate::models::decision::Decision::new( + LongestPath::new(SimpleGraph::path(3), vec![crate::types::One; 2], 0, 2), + 2, + ); + let witness = serde_json::json!(vec![true, true]); + crate::example_db::specs::rule_example_with_witness::<_, LongestPath>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/max_cut.rs b/src/models/graph/max_cut.rs index 0d414cfac..de1d015ac 100644 --- a/src/models/graph/max_cut.rs +++ b/src/models/graph/max_cut.rs @@ -67,7 +67,7 @@ inventory::submit! { /// assert_eq!(size, Max(Some(2))); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaxCut { /// The underlying graph structure. graph: G, @@ -75,6 +75,23 @@ pub struct MaxCut { edge_weights: Vec, } +#[derive(Deserialize)] +struct MaxCutData { + graph: G, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaxCut +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaxCutData::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + } +} + macro_rules! max_cut_create_spec { ($name:ident, $weight:ty, $one:expr $(, $edge_weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -102,7 +119,7 @@ macro_rules! max_cut_create_spec { ) .into()); } - Ok(Self::new(graph, edge_weights)) + Self::try_new(graph, edge_weights) } } }; @@ -146,15 +163,17 @@ impl MaxCut { /// * `graph` - The underlying graph /// * `edge_weights` - Weights for each edge (must match graph.num_edges()) pub fn new(graph: G, edge_weights: Vec) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self { + Self::try_new(graph, edge_weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, edge_weights: Vec) -> Result { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match graph num_edges".into()); + } + Ok(Self { graph, edge_weights, - } + }) } /// Create a MaxCut problem with unit weights. @@ -262,8 +281,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -337,3 +360,46 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMaxCut"); +crate::register_decision_variant!( + MaxCut, "DecisionMaxCut", "2^(2.372 * num_vertices / 3)", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_max_cut_to_max_cut", + build: || { + let source = crate::models::decision::Decision::new( + MaxCut::<_, i64>::unweighted(SimpleGraph::new( + 5, + vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)], + )), + 5, + ); + let witness = serde_json::json!(vec![true, false, false, true, false]); + crate::example_db::specs::rule_example_with_witness::<_, MaxCut>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/maximal_is.rs b/src/models/graph/maximal_is.rs index 079e0752d..8813d884c 100644 --- a/src/models/graph/maximal_is.rs +++ b/src/models/graph/maximal_is.rs @@ -53,7 +53,7 @@ inventory::submit! { /// assert!(problem.evaluate(sol).unwrap().is_valid()); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximalIS { /// The underlying graph. graph: G, @@ -61,6 +61,23 @@ pub struct MaximalIS { weights: Vec, } +#[derive(Deserialize)] +struct MaximalISData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximalIS +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximalISData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximalISCreateSpec { /// The underlying graph G=(V,E). @@ -80,19 +97,21 @@ impl TryFrom for MaximalIS { ) .into()); } - Ok(Self::new(spec.graph, spec.weights)) + Self::try_new(spec.graph, spec.weights) } } impl MaximalIS { /// Create a Maximal Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -215,8 +234,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/maximum_achromatic_number.rs b/src/models/graph/maximum_achromatic_number.rs index 6046f97e1..f5fc3d2e4 100644 --- a/src/models/graph/maximum_achromatic_number.rs +++ b/src/models/graph/maximum_achromatic_number.rs @@ -172,8 +172,12 @@ impl crate::solvers::BruteForceProblem for MaximumAchromaticNumber where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.graph.num_vertices(); self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/maximum_clique.rs b/src/models/graph/maximum_clique.rs index 423242ba1..01b8021aa 100644 --- a/src/models/graph/maximum_clique.rs +++ b/src/models/graph/maximum_clique.rs @@ -56,7 +56,7 @@ inventory::submit! { /// // Maximum clique in a triangle (K3) is size 3 /// assert!(solutions.iter().all(|s| s.iter().filter(|&&selected| selected).count() == 3)); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximumClique { /// The underlying graph. graph: G, @@ -64,6 +64,23 @@ pub struct MaximumClique { weights: Vec, } +#[derive(Deserialize)] +struct MaximumCliqueData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximumClique +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumCliqueData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumCliqueCreateSpec { /// The underlying graph G=(V,E). @@ -83,19 +100,21 @@ impl TryFrom> for MaximumClique MaximumClique { /// Create a MaximumClique problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -182,8 +201,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -225,7 +248,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..73d401d89 100644 --- a/src/models/graph/maximum_co_k_plex.rs +++ b/src/models/graph/maximum_co_k_plex.rs @@ -63,8 +63,7 @@ inventory::submit! { /// MaximumCoKPlex::<_, One, KN>::with_k(graph, vec![One; 5], 2); /// assert_eq!(problem.bound_k(), 2); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(bound(deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>"))] +#[derive(Debug, Clone, Serialize)] pub struct MaximumCoKPlex { /// The underlying graph. graph: G, @@ -81,6 +80,25 @@ pub struct MaximumCoKPlex { _phantom: std::marker::PhantomData, } +#[derive(Deserialize)] +struct MaximumCoKPlexData { + graph: G, + weights: Vec, + bound_k: usize, +} + +impl<'de, G, W, K> Deserialize<'de> for MaximumCoKPlex +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, + K: KValue, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumCoKPlexData::deserialize(deserializer)?; + Self::try_with_k(data.graph, data.weights, data.bound_k).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumCoKPlexCreateSpec { /// The underlying graph G=(V,E). @@ -108,7 +126,7 @@ impl TryFrom> if spec.k == 0 { return Err("k must be at least 1".to_string().into()); } - Ok(Self::with_k(spec.graph, spec.weights, spec.k)) + Self::try_with_k(spec.graph, spec.weights, spec.k) } } @@ -120,24 +138,31 @@ impl MaximumCoKPlex { /// `bound_k == 0`, or if `K` declares a fixed value that disagrees with /// `bound_k`. pub fn with_k(graph: G, weights: Vec, bound_k: usize) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - assert!(bound_k >= 1, "co-k-plex parameter k must be at least 1"); + Self::try_with_k(graph, weights, bound_k).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_with_k( + graph: G, + weights: Vec, + bound_k: usize, + ) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + if bound_k == 0 { + return Err("co-k-plex parameter k must be at least 1".into()); + } if let Some(fixed) = K::K { - assert_eq!( - fixed, bound_k, - "fixed K type disagrees with runtime bound_k" - ); + if fixed != bound_k { + return Err("fixed K type disagrees with runtime bound_k".into()); + } } - Self { + Ok(Self { graph, weights, bound_k, _phantom: std::marker::PhantomData, - } + }) } /// Create a new instance using the compile-time `K`. @@ -146,8 +171,12 @@ impl MaximumCoKPlex { /// Panics if `K` is [`KN`] (use [`MaximumCoKPlex::with_k`] instead) or if /// `weights.len()` does not match `graph.num_vertices()`. pub fn new(graph: G, weights: Vec) -> Self { - let k = K::K.expect("KN requires with_k"); - Self::with_k(graph, weights, k) + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + let k = K::K.ok_or("KN requires with_k")?; + Self::try_with_k(graph, weights, k) } /// Get a reference to the underlying graph. @@ -241,8 +270,12 @@ where W: WeightElement + VariantParam, K: KValue, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -282,7 +315,7 @@ impl TryFrom for MaximumCoKPlex, } +#[derive(Deserialize)] +struct LabelledDigraphData { + num_vertices: usize, + arcs: Vec, +} + +impl TryFrom for LabelledDigraph { + type Error = crate::registry::ConstructionError; + fn try_from(data: LabelledDigraphData) -> Result { + Self::try_new(data.num_vertices, data.arcs) + } +} + impl LabelledDigraph { /// Construct a new labelled digraph. /// /// # Panics /// Panics if any arc references a vertex index outside `0..num_vertices`. pub fn new(num_vertices: usize, arcs: Vec) -> Self { + Self::try_new(num_vertices, arcs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_vertices: usize, + arcs: Vec, + ) -> Result { for arc in &arcs { - assert!( - arc.src < num_vertices, - "labelled arc source {} out of range for num_vertices = {}", - arc.src, - num_vertices - ); - assert!( - arc.dst < num_vertices, - "labelled arc destination {} out of range for num_vertices = {}", - arc.dst, - num_vertices - ); + if !(arc.src < num_vertices) { + return Err(format!( + "labelled arc source {} out of range for num_vertices = {}", + arc.src, num_vertices + ) + .into()); + }; + if !(arc.dst < num_vertices) { + return Err(format!( + "labelled arc destination {} out of range for num_vertices = {}", + arc.dst, num_vertices + ) + .into()); + }; } // Deduplicate while preserving order so set semantics hold. let mut seen = std::collections::HashSet::new(); @@ -104,10 +127,10 @@ impl LabelledDigraph { deduped.push(arc); } } - Self { + Ok(Self { num_vertices, arcs: deduped, - } + }) } /// Number of vertices `|V|`. @@ -136,7 +159,7 @@ impl LabelledDigraph { /// /// # Configuration encoding /// -/// `dims()` returns `vec![graph_2.num_vertices + 1; graph_1.num_vertices]`. +/// The coordinate cardinalities are `vec![graph_2.num_vertices + 1; graph_1.num_vertices]`. /// For each source vertex `u in V1`, `config[u]` is either an index in /// `0..graph_2.num_vertices` (the matched target vertex) or the sentinel /// value `graph_2.num_vertices` denoting `bottom` (unmatched). Feasibility @@ -293,8 +316,18 @@ impl Problem for MaximumCommonEdgeSubgraph { } impl crate::solvers::BruteForceProblem for MaximumCommonEdgeSubgraph { - fn dimensions(&self) -> Vec { - vec![self.graph_2.num_vertices() + 1; self.graph_1.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph_1.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.graph_2.num_vertices()) + .checked_add(1usize) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a coordinate cardinality".into(), + ) + }) } } diff --git a/src/models/graph/maximum_contact_map_overlap.rs b/src/models/graph/maximum_contact_map_overlap.rs index c7f340220..29189ff11 100644 --- a/src/models/graph/maximum_contact_map_overlap.rs +++ b/src/models/graph/maximum_contact_map_overlap.rs @@ -64,12 +64,13 @@ inventory::submit! { /// /// # Configuration encoding /// -/// `dims()` returns `vec![|V_2| + 1; |V_1|]`. For each source vertex `i`, +/// The coordinate cardinalities are `vec![|V_2| + 1; |V_1|]`. For each source vertex `i`, /// `config[i] = 0` denotes `bot` (unmatched) and `config[i] = j + 1` denotes /// "matched to vertex `j in V_2`". Feasibility requires that the nonzero /// entries are pairwise distinct (injectivity) and strictly increasing along /// the index order of `V_1` (order-preserving). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "MaximumContactMapOverlapData")] pub struct MaximumContactMapOverlap { num_vertices_1: usize, contacts_1: Vec<(usize, usize)>, @@ -77,51 +78,91 @@ pub struct MaximumContactMapOverlap { contacts_2: Vec<(usize, usize)>, } -/// Canonicalize a contact set: each pair is normalized to `(min, max)`, no -/// self-loops are allowed, all endpoints must be in range, and duplicates -/// (after normalization) cause a panic. +#[derive(Deserialize)] +struct MaximumContactMapOverlapData { + num_vertices_1: usize, + contacts_1: Vec<(usize, usize)>, + num_vertices_2: usize, + contacts_2: Vec<(usize, usize)>, +} + +impl TryFrom for MaximumContactMapOverlap { + type Error = crate::registry::ConstructionError; + fn try_from(data: MaximumContactMapOverlapData) -> Result { + Self::try_new( + data.num_vertices_1, + data.contacts_1, + data.num_vertices_2, + data.contacts_2, + ) + } +} + +/// Canonicalize a contact set: each pair is normalized to `(min, max)`. +/// Self-loops, out-of-range endpoints, and duplicates (after normalization) +/// are rejected. fn canonicalize_contacts( raw: Vec<(usize, usize)>, num_vertices: usize, side: &str, -) -> Vec<(usize, usize)> { +) -> Result, crate::registry::ConstructionError> { let mut seen: HashSet<(usize, usize)> = HashSet::new(); let mut out = Vec::with_capacity(raw.len()); for (u, v) in raw { - assert!( - u < num_vertices && v < num_vertices, - "{side} contact endpoint out of range for num_vertices = {num_vertices}: ({u}, {v})" - ); - assert!(u != v, "{side} contact has self-loop: ({u}, {v})"); + if u >= num_vertices || v >= num_vertices { + return Err(format!( + "{side} contact endpoint out of range for num_vertices = {num_vertices}: ({u}, {v})" + ) + .into()); + } + if u == v { + return Err(format!("{side} contact has self-loop: ({u}, {v})").into()); + } let (a, b) = if u < v { (u, v) } else { (v, u) }; - assert!( - seen.insert((a, b)), - "{side} has duplicate contact after normalization: ({a}, {b})" - ); + if !seen.insert((a, b)) { + return Err( + format!("{side} has duplicate contact after normalization: ({a}, {b})").into(), + ); + } out.push((a, b)); } - out + Ok(out) } impl MaximumContactMapOverlap { /// Construct a new instance from two ordered contact maps. /// - /// Contacts are canonicalized to `(min, max)` pairs. Self-loops, duplicate - /// contacts (after normalization), and out-of-range endpoints panic. + /// Contacts are canonicalized to `(min, max)` pairs. + /// + /// # Panics + /// + /// Panics on self-loops, duplicate contacts (after normalization), and + /// out-of-range endpoints. pub fn new( num_vertices_1: usize, contacts_1: Vec<(usize, usize)>, num_vertices_2: usize, contacts_2: Vec<(usize, usize)>, ) -> Self { - let contacts_1 = canonicalize_contacts(contacts_1, num_vertices_1, "G_1"); - let contacts_2 = canonicalize_contacts(contacts_2, num_vertices_2, "G_2"); - Self { + Self::try_new(num_vertices_1, contacts_1, num_vertices_2, contacts_2) + .unwrap_or_else(|error| panic!("{error}")) + } + + /// Create an instance, returning validation errors instead of panicking. + pub fn try_new( + num_vertices_1: usize, + contacts_1: Vec<(usize, usize)>, + num_vertices_2: usize, + contacts_2: Vec<(usize, usize)>, + ) -> Result { + let contacts_1 = canonicalize_contacts(contacts_1, num_vertices_1, "G_1")?; + let contacts_2 = canonicalize_contacts(contacts_2, num_vertices_2, "G_2")?; + Ok(Self { num_vertices_1, contacts_1, num_vertices_2, contacts_2, - } + }) } /// Number of ordered residues/vertices in `G_1`. @@ -261,8 +302,14 @@ impl Problem for MaximumContactMapOverlap { } impl crate::solvers::BruteForceProblem for MaximumContactMapOverlap { - fn dimensions(&self) -> Vec { - vec![self.num_vertices_2 + 1; self.num_vertices_1] + fn num_variables(&self) -> Result { + Ok(self.num_vertices_1) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.num_vertices_2).checked_add(1usize).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/graph/maximum_domatic_number.rs b/src/models/graph/maximum_domatic_number.rs index fce59b0e1..add7a727a 100644 --- a/src/models/graph/maximum_domatic_number.rs +++ b/src/models/graph/maximum_domatic_number.rs @@ -177,9 +177,12 @@ impl crate::solvers::BruteForceProblem for MaximumDomaticNumber where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/maximum_edge_weighted_k_clique.rs b/src/models/graph/maximum_edge_weighted_k_clique.rs index 9ba384908..0cbf9d585 100644 --- a/src/models/graph/maximum_edge_weighted_k_clique.rs +++ b/src/models/graph/maximum_edge_weighted_k_clique.rs @@ -220,8 +220,12 @@ impl crate::solvers::BruteForceProblem for MaximumEdgeWeightedKClique where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 55723eb26..533561c65 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -58,7 +58,7 @@ inventory::submit! { /// // Maximum independent set in a triangle has size 1 /// assert!(solutions.iter().all(|s| s.iter().filter(|&&selected| selected).count() == 1)); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximumIndependentSet { /// The underlying graph. graph: G, @@ -66,6 +66,23 @@ pub struct MaximumIndependentSet { weights: Vec, } +#[derive(Deserialize)] +struct MaximumIndependentSetData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximumIndependentSet +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumIndependentSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + macro_rules! simple_mis_spec { ($name:ident,$weight:ty,$one:expr $(, $weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -213,12 +230,14 @@ unit_disk_mis_spec!( impl MaximumIndependentSet { /// Create an Independent Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -309,8 +328,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/maximum_leaf_spanning_tree.rs b/src/models/graph/maximum_leaf_spanning_tree.rs index 9a8c8d3bd..7f792c2b5 100644 --- a/src/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/models/graph/maximum_leaf_spanning_tree.rs @@ -43,22 +43,41 @@ inventory::submit! { /// # Type Parameters /// /// * `G` - The graph type (e.g., `SimpleGraph`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximumLeafSpanningTree { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct MaximumLeafSpanningTreeData { + graph: G, +} + +impl<'de, G> Deserialize<'de> for MaximumLeafSpanningTree +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumLeafSpanningTreeData::::deserialize(deserializer)?; + Self::try_new(data.graph).map_err(serde::de::Error::custom) + } +} + impl MaximumLeafSpanningTree { /// Create a MaximumLeafSpanningTree problem from a graph. /// /// The graph must have at least 2 vertices. pub fn new(graph: G) -> Self { - assert!( - graph.num_vertices() >= 2, - "graph must have at least 2 vertices" - ); - Self { graph } + Self::try_new(graph).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G) -> Result { + if !(graph.num_vertices() >= 2) { + return Err("graph must have at least 2 vertices".into()); + } + Ok(Self { graph }) } /// Get a reference to the underlying graph. @@ -183,8 +202,12 @@ impl crate::solvers::BruteForceProblem for MaximumLeafSpanningTree where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/maximum_matching.rs b/src/models/graph/maximum_matching.rs index 6e68acfd5..2a1403c5e 100644 --- a/src/models/graph/maximum_matching.rs +++ b/src/models/graph/maximum_matching.rs @@ -56,7 +56,7 @@ inventory::submit! { /// assert_eq!(sol.iter().filter(|&&selected| selected).count(), 1); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MaximumMatching { /// The underlying graph. graph: G, @@ -64,6 +64,23 @@ pub struct MaximumMatching { edge_weights: Vec, } +#[derive(Deserialize)] +struct MaximumMatchingData { + graph: G, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MaximumMatching +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MaximumMatchingData::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MaximumMatchingCreateSpec { #[create(codec = "edge-list")] @@ -89,7 +106,7 @@ impl TryFrom for MaximumMatching { ) .into()); } - Ok(Self::new(graph, edge_weights)) + Self::try_new(graph, edge_weights) } } @@ -131,15 +148,15 @@ impl MaximumMatching { /// * `graph` - The graph /// * `edge_weights` - Weight for each edge (in graph.edges() order) pub fn new(graph: G, edge_weights: Vec) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self { + Self::try_new(graph, edge_weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, edge_weights: Vec) -> Result { + Self::check_weights(&graph, &edge_weights)?; + Ok(Self { graph, edge_weights, - } + }) } /// Create a MaximumMatching problem with unit weights. @@ -213,6 +230,16 @@ impl MaximumMatching { self.edge_weights = weights; } + fn check_weights( + graph: &G, + edge_weights: &[W], + ) -> Result<(), crate::registry::ConstructionError> { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match graph num_edges".into()); + } + Ok(()) + } + /// Get the weights for the problem. pub fn weights(&self) -> Vec { self.edge_weights.clone() @@ -289,8 +316,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/min_max_multicenter.rs b/src/models/graph/min_max_multicenter.rs index bcc697aff..6057a4d07 100644 --- a/src/models/graph/min_max_multicenter.rs +++ b/src/models/graph/min_max_multicenter.rs @@ -53,7 +53,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinMaxMulticenter { /// The underlying graph. graph: G, @@ -65,6 +65,27 @@ pub struct MinMaxMulticenter { k: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct MinMaxMulticenterData { + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, +} + +impl<'de, G, W> Deserialize<'de> for MinMaxMulticenter +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinMaxMulticenterData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) + .map_err(serde::de::Error::custom) + } +} + macro_rules! min_max_multicenter_create_spec { ($name:ident, $weight:ty, $one:expr $(, $weights:ident, $edge_weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -122,7 +143,7 @@ macro_rules! min_max_multicenter_create_spec { if spec.k == 0 || spec.k > graph.num_vertices() { return Err(format!("k must be between 1 and {}", graph.num_vertices()).into()); } - Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + Self::try_new(graph, vertex_weights, edge_lengths, spec.k) } } }; @@ -174,37 +195,47 @@ impl MinMaxMulticenter { /// - If any vertex weight or edge length is negative /// - If `k == 0` or `k > graph.num_vertices()` pub fn new(graph: G, vertex_weights: Vec, edge_lengths: Vec, k: usize) -> Self { - assert_eq!( - vertex_weights.len(), - graph.num_vertices(), - "vertex_weights length must match num_vertices" - ); - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); + Self::try_new(graph, vertex_weights, edge_lengths, k) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, + ) -> Result { + if vertex_weights.len() != graph.num_vertices() { + return Err("vertex_weights length must match num_vertices".into()); + } + if edge_lengths.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } let zero = W::Sum::zero(); - assert!( - vertex_weights - .iter() - .all(|weight| weight.to_sum() >= zero.clone()), - "vertex_weights must be non-negative" - ); - assert!( - edge_lengths - .iter() - .all(|length| length.to_sum() >= zero.clone()), - "edge_lengths must be non-negative" - ); - assert!(k > 0, "k must be positive"); - assert!(k <= graph.num_vertices(), "k must not exceed num_vertices"); - Self { + if !(vertex_weights + .iter() + .all(|weight| weight.to_sum() >= zero.clone())) + { + return Err("vertex_weights must be non-negative".into()); + } + if !(edge_lengths + .iter() + .all(|length| length.to_sum() >= zero.clone())) + { + return Err("edge_lengths must be non-negative".into()); + } + if k == 0 { + return Err("k must be positive".into()); + } + if !(k <= graph.num_vertices()) { + return Err("k must not exceed num_vertices".into()); + } + Ok(Self { graph, vertex_weights, edge_lengths, k, - } + }) } /// Get a reference to the underlying graph. @@ -381,8 +412,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -417,3 +452,54 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMinMaxMulticenter"); +crate::register_decision_variant!( + MinMaxMulticenter, "DecisionMinMaxMulticenter", "1.4969^num_vertices", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "One", &["One"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "k", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_min_max_multicenter_to_min_max_multicenter", + build: || { + let source = crate::models::decision::Decision::new( + MinMaxMulticenter::new( + SimpleGraph::new( + 6, + vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5), (1, 4)], + ), + vec![crate::types::One; 6], + vec![crate::types::One; 7], + 2, + ), + 1, + ); + let witness = serde_json::json!(vec![false, true, false, false, true, false]); + crate::example_db::specs::rule_example_with_witness::< + _, + MinMaxMulticenter, + >( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/minimum_capacitated_spanning_tree.rs b/src/models/graph/minimum_capacitated_spanning_tree.rs index 631a960b6..5a1bd8995 100644 --- a/src/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/models/graph/minimum_capacitated_spanning_tree.rs @@ -48,7 +48,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `W` - The weight type for edges and requirements (e.g., `i64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumCapacitatedSpanningTree { /// The underlying graph. graph: G, @@ -62,6 +62,37 @@ pub struct MinimumCapacitatedSpanningTree { capacity: W::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>, W::Sum: Deserialize<'de>" +))] +struct MinimumCapacitatedSpanningTreeData { + graph: G, + weights: Vec, + root: usize, + requirements: Vec, + capacity: W::Sum, +} + +impl<'de, G, W> Deserialize<'de> for MinimumCapacitatedSpanningTree +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumCapacitatedSpanningTreeData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.weights, + data.root, + data.requirements, + data.capacity, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumCapacitatedSpanningTreeCreateSpec { /// The underlying graph. @@ -99,13 +130,13 @@ impl TryFrom if spec.root >= vertices { return Err("root is outside the graph".to_string().into()); } - Ok(Self::new( + Self::try_new( spec.graph, weights, spec.root, spec.requirements, spec.capacity, - )) + ) } } @@ -124,32 +155,38 @@ impl MinimumCapacitatedSpanningTree { requirements: Vec, capacity: W::Sum, ) -> Self { - assert_eq!( - weights.len(), - graph.num_edges(), - "weights length must match num_edges" - ); - assert_eq!( - requirements.len(), - graph.num_vertices(), - "requirements length must match num_vertices" - ); - assert!( - root < graph.num_vertices(), - "root {root} out of range (num_vertices = {})", - graph.num_vertices() - ); - assert!( - graph.num_vertices() >= 2, - "graph must have at least 2 vertices" - ); - Self { + Self::try_new(graph, weights, root, requirements, capacity) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + weights: Vec, + root: usize, + requirements: Vec, + capacity: W::Sum, + ) -> Result { + Self::check_weights(&graph, &weights)?; + if requirements.len() != graph.num_vertices() { + return Err("requirements length must match num_vertices".into()); + } + if !(root < graph.num_vertices()) { + return Err(format!( + "root {root} out of range (num_vertices = {})", + graph.num_vertices() + ) + .into()); + } + if !(graph.num_vertices() >= 2) { + return Err("graph must have at least 2 vertices".into()); + } + Ok(Self { graph, weights, root, requirements, capacity, - } + }) } /// Get a reference to the underlying graph. @@ -168,6 +205,13 @@ impl MinimumCapacitatedSpanningTree { self.weights = weights; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("weights length must match num_edges".into()); + } + Ok(()) + } + /// Check if the problem uses a non-unit weight type. pub fn is_weighted(&self) -> bool { !W::IS_UNIT @@ -389,8 +433,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/minimum_cost_circulation.rs b/src/models/graph/minimum_cost_circulation.rs index a61cea112..86fe2de01 100644 --- a/src/models/graph/minimum_cost_circulation.rs +++ b/src/models/graph/minimum_cost_circulation.rs @@ -85,6 +85,7 @@ inventory::submit! { /// assert_eq!(problem.total_cost(&witness).unwrap(), -5); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumCostCirculationData")] pub struct MinimumCostCirculation { /// The directed multigraph G = (V, A). graph: DirectedGraph, @@ -94,6 +95,20 @@ pub struct MinimumCostCirculation { costs: Vec, } +#[derive(Deserialize)] +struct MinimumCostCirculationData { + graph: DirectedGraph, + capacities: Vec, + costs: Vec, +} + +impl TryFrom for MinimumCostCirculation { + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumCostCirculationData) -> Result { + Self::try_new(data.graph, data.capacities, data.costs) + } +} + impl MinimumCostCirculation { /// Create a new Minimum-Cost Circulation problem. /// @@ -106,27 +121,35 @@ impl MinimumCostCirculation { /// /// Note: costs are signed and **may be negative**. pub fn new(graph: DirectedGraph, capacities: Vec, costs: Vec) -> Self { + Self::try_new(graph, capacities, costs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + capacities: Vec, + costs: Vec, + ) -> Result { let m = graph.num_arcs(); - assert_eq!( - capacities.len(), - m, - "capacities length ({}) must match num_arcs ({m})", - capacities.len() - ); - assert_eq!( - costs.len(), - m, - "costs length ({}) must match num_arcs ({m})", - costs.len() - ); + if capacities.len() != m { + return Err(format!( + "capacities length ({}) must match num_arcs ({m})", + capacities.len() + ) + .into()); + } + if costs.len() != m { + return Err(format!("costs length ({}) must match num_arcs ({m})", costs.len()).into()); + } for (i, &c) in capacities.iter().enumerate() { - assert!(c >= 0, "capacity[{i}] = {c} is negative"); + if !(c >= 0) { + return Err(format!("capacity[{i}] = {c} is negative").into()); + } } - Self { + Ok(Self { graph, capacities, costs, - } + }) } /// Get a reference to the underlying directed graph. @@ -255,8 +278,12 @@ impl Problem for MinimumCostCirculation { } impl crate::solvers::BruteForceProblem for MinimumCostCirculation { - fn dimensions(&self) -> Vec { - self.capacities.iter().map(|&c| (c as usize) + 1).collect() + fn num_variables(&self) -> Result { + Ok(self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.capacities[variable]) + 1)?) } } diff --git a/src/models/graph/minimum_cost_maximum_flow.rs b/src/models/graph/minimum_cost_maximum_flow.rs index 78cf0e271..8915d9287 100644 --- a/src/models/graph/minimum_cost_maximum_flow.rs +++ b/src/models/graph/minimum_cost_maximum_flow.rs @@ -436,8 +436,12 @@ impl Problem for MinimumCostMaximumFlow { } impl crate::solvers::BruteForceProblem for MinimumCostMaximumFlow { - fn dimensions(&self) -> Vec { - self.capacities.iter().map(|&c| (c as usize) + 1).collect() + fn num_variables(&self) -> Result { + Ok(self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.capacities[variable]) + 1)?) } } diff --git a/src/models/graph/minimum_covering_by_cliques.rs b/src/models/graph/minimum_covering_by_cliques.rs index 1f59f2bae..6c943fbf0 100644 --- a/src/models/graph/minimum_covering_by_cliques.rs +++ b/src/models/graph/minimum_covering_by_cliques.rs @@ -170,8 +170,12 @@ impl crate::solvers::BruteForceProblem for MinimumCoveringByCliques where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.graph.num_edges(); self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_edges()) } } @@ -223,3 +227,59 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + "DecisionMinimumCoveringByCliques" +); +crate::register_decision_variant!( + MinimumCoveringByCliques, "DecisionMinimumCoveringByCliques", "2^num_edges", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], + fields: [ +crate::registry::FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, +crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, +], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_minimum_covering_by_cliques_to_minimum_covering_by_cliques", + build: || { + let source = crate::models::decision::Decision::new( + MinimumCoveringByCliques::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (0, 2), + (4, 0), + (4, 1), + (5, 2), + (5, 3), + ], + )), + 4, + ); + let witness = serde_json::json!(vec![0, 0, 1, 1, 0, 2, 2, 3, 3]); + crate::example_db::specs::rule_example_with_witness::< + _, + MinimumCoveringByCliques, + >( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/minimum_cut_into_bounded_sets.rs b/src/models/graph/minimum_cut_into_bounded_sets.rs index 2f5167a65..ebae83c1b 100644 --- a/src/models/graph/minimum_cut_into_bounded_sets.rs +++ b/src/models/graph/minimum_cut_into_bounded_sets.rs @@ -56,7 +56,7 @@ inventory::submit! { /// let val = problem.evaluate(&vec![false, false, true, true]).unwrap(); /// assert_eq!(val, problemreductions::types::Min(Some(1))); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumCutIntoBoundedSets { /// The underlying graph structure. graph: G, @@ -70,6 +70,34 @@ pub struct MinimumCutIntoBoundedSets { size_bound: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct MinimumCutIntoBoundedSetsData { + graph: G, + edge_weights: Vec, + source: usize, + sink: usize, + size_bound: usize, +} + +impl<'de, G, W> Deserialize<'de> for MinimumCutIntoBoundedSets +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumCutIntoBoundedSetsData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_weights, + data.source, + data.sink, + data.size_bound, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumCutIntoBoundedSetsCreateSpec { /// The undirected graph. @@ -101,13 +129,13 @@ impl TryFrom for MinimumCutIntoBoundedSets< .to_string() .into()); } - Ok(Self::new( + Self::try_new( spec.graph, edge_weights, spec.source, spec.sink, spec.size_bound, - )) + ) } } @@ -131,21 +159,36 @@ impl MinimumCutIntoBoundedSets { sink: usize, size_bound: usize, ) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - assert!(source < graph.num_vertices(), "source vertex out of bounds"); - assert!(sink < graph.num_vertices(), "sink vertex out of bounds"); - assert_ne!(source, sink, "source and sink must be different vertices"); - Self { + Self::try_new(graph, edge_weights, source, sink, size_bound) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_weights: Vec, + source: usize, + sink: usize, + size_bound: usize, + ) -> Result { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + if !(source < graph.num_vertices()) { + return Err("source vertex out of bounds".into()); + } + if !(sink < graph.num_vertices()) { + return Err("sink vertex out of bounds".into()); + } + if source == sink { + return Err("source and sink must be different vertices".into()); + } + Ok(Self { graph, edge_weights, source, sink, size_bound, - } + }) } /// Get a reference to the underlying graph. @@ -245,8 +288,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index b8c85a1a4..bd06c3580 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -52,7 +52,7 @@ inventory::submit! { /// // Minimum dominating set is just the center vertex /// assert!(solutions.contains(&vec![true, false, false, false])); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumDominatingSet { /// The underlying graph. graph: G, @@ -60,6 +60,23 @@ pub struct MinimumDominatingSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumDominatingSetData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MinimumDominatingSet +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumDominatingSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumDominatingSetCreateSpec { /// The underlying graph G=(V,E). @@ -81,19 +98,21 @@ impl TryFrom> ) .into()); } - Ok(Self::new(spec.graph, spec.weights)) + Self::try_new(spec.graph, spec.weights) } } impl MinimumDominatingSet { /// Create a Dominating Set problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -205,8 +224,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -227,7 +250,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) } } @@ -360,7 +383,7 @@ pub(crate) fn decision_canonical_rule_example_specs( build: || { use crate::example_db::specs::assemble_rule_example; use crate::export::SolutionPair; - use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + use crate::rules::{ReduceTo, ReductionResult}; let source = crate::models::decision::Decision::new( MinimumDominatingSet::new( @@ -369,8 +392,7 @@ pub(crate) fn decision_canonical_rule_example_specs( ), 2, ); - let result = source - .reduce_to_aggregate() + let result = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); let target = result.target_problem(); let config = vec![false, false, true, true, false]; @@ -390,7 +412,7 @@ pub(crate) fn decision_canonical_rule_example_specs( build: || { use crate::example_db::specs::assemble_rule_example; use crate::export::SolutionPair; - use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + use crate::rules::{ReduceTo, ReductionResult}; let source = crate::models::decision::Decision::new( MinimumDominatingSet::new( @@ -399,8 +421,7 @@ pub(crate) fn decision_canonical_rule_example_specs( ), 2, ); - let result = source - .reduce_to_aggregate() + let result = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); let target = result.target_problem(); let config = vec![false, false, true, true, false]; diff --git a/src/models/graph/minimum_dummy_activities_pert.rs b/src/models/graph/minimum_dummy_activities_pert.rs index c272044d6..277ffa5b7 100644 --- a/src/models/graph/minimum_dummy_activities_pert.rs +++ b/src/models/graph/minimum_dummy_activities_pert.rs @@ -11,6 +11,7 @@ use crate::registry::{CreateSpec, ProblemSchemaEntry}; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; +use petgraph::unionfind::UnionFind; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -161,7 +162,7 @@ impl MinimumDummyActivitiesPert { } let roots: Vec = (0..2 * num_tasks) - .map(|endpoint| uf.find(endpoint)) + .map(|endpoint| uf.find_mut(endpoint)) .collect(); let mut root_to_dense = BTreeMap::new(); for &root in &roots { @@ -240,8 +241,12 @@ impl Problem for MinimumDummyActivitiesPert { } impl crate::solvers::BruteForceProblem for MinimumDummyActivitiesPert { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_arcs()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_arcs()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -288,35 +293,6 @@ struct CandidatePertNetwork { num_dummy_arcs: usize, } -#[derive(Debug)] -struct UnionFind { - parent: Vec, -} - -impl UnionFind { - fn new(size: usize) -> Self { - Self { - parent: (0..size).collect(), - } - } - - fn find(&mut self, x: usize) -> usize { - if self.parent[x] != x { - let root = self.find(self.parent[x]); - self.parent[x] = root; - } - self.parent[x] - } - - fn union(&mut self, a: usize, b: usize) { - let root_a = self.find(a); - let root_b = self.find(b); - if root_a != root_b { - self.parent[root_b] = root_a; - } - } -} - fn start_endpoint(task: usize) -> usize { 2 * task } diff --git a/src/models/graph/minimum_edge_cost_flow.rs b/src/models/graph/minimum_edge_cost_flow.rs index 79e0ab8dd..5da5a783e 100644 --- a/src/models/graph/minimum_edge_cost_flow.rs +++ b/src/models/graph/minimum_edge_cost_flow.rs @@ -66,6 +66,7 @@ inventory::submit! { /// assert_eq!(problem.evaluate(&witness).unwrap(), problemreductions::types::Min(Some(3))); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumEdgeCostFlowData")] pub struct MinimumEdgeCostFlow { /// The directed graph G = (V, A). graph: DirectedGraph, @@ -81,6 +82,30 @@ pub struct MinimumEdgeCostFlow { required_flow: i64, } +#[derive(Deserialize)] +struct MinimumEdgeCostFlowData { + graph: DirectedGraph, + prices: Vec, + capacities: Vec, + source: usize, + sink: usize, + required_flow: i64, +} + +impl TryFrom for MinimumEdgeCostFlow { + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumEdgeCostFlowData) -> Result { + Self::try_new( + data.graph, + data.prices, + data.capacities, + data.source, + data.sink, + data.required_flow, + ) + } +} + impl MinimumEdgeCostFlow { /// Create a new Minimum Edge-Cost Flow problem. /// @@ -110,34 +135,54 @@ impl MinimumEdgeCostFlow { sink: usize, required_flow: i64, ) -> Self { + Self::try_new(graph, prices, capacities, source, sink, required_flow) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + prices: Vec, + capacities: Vec, + source: usize, + sink: usize, + required_flow: i64, + ) -> Result { let n = graph.num_vertices(); let m = graph.num_arcs(); - assert_eq!( - prices.len(), - m, - "prices length ({}) must match num_arcs ({m})", - prices.len() - ); - assert_eq!( - capacities.len(), - m, - "capacities length ({}) must match num_arcs ({m})", - capacities.len() - ); - assert!(source < n, "source ({source}) >= num_vertices ({n})"); - assert!(sink < n, "sink ({sink}) >= num_vertices ({n})"); - assert_ne!(source, sink, "source and sink must be distinct"); + if prices.len() != m { + return Err( + format!("prices length ({}) must match num_arcs ({m})", prices.len()).into(), + ); + } + if capacities.len() != m { + return Err(format!( + "capacities length ({}) must match num_arcs ({m})", + capacities.len() + ) + .into()); + } + if !(source < n) { + return Err(format!("source ({source}) >= num_vertices ({n})").into()); + } + if !(sink < n) { + return Err(format!("sink ({sink}) >= num_vertices ({n})").into()); + } + if source == sink { + return Err("source and sink must be distinct".into()); + } for (i, &c) in capacities.iter().enumerate() { - assert!(c >= 0, "capacity[{i}] = {c} is negative"); + if !(c >= 0) { + return Err(format!("capacity[{i}] = {c} is negative").into()); + } } - Self { + Ok(Self { graph, prices, capacities, source, sink, required_flow, - } + }) } /// Get a reference to the underlying directed graph. @@ -302,8 +347,12 @@ impl Problem for MinimumEdgeCostFlow { } impl crate::solvers::BruteForceProblem for MinimumEdgeCostFlow { - fn dimensions(&self) -> Vec { - self.capacities.iter().map(|&c| (c as usize) + 1).collect() + fn num_variables(&self) -> Result { + Ok(self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.capacities[variable]) + 1)?) } } diff --git a/src/models/graph/minimum_feedback_arc_set.rs b/src/models/graph/minimum_feedback_arc_set.rs index 7c337a1d5..c188159ca 100644 --- a/src/models/graph/minimum_feedback_arc_set.rs +++ b/src/models/graph/minimum_feedback_arc_set.rs @@ -55,7 +55,7 @@ inventory::submit! { /// // Minimum FAS has size 1 (remove any single arc to break the cycle) /// assert_eq!(solution.iter().filter(|&&selected| selected).count(), 1); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumFeedbackArcSet { /// The directed graph. graph: DirectedGraph, @@ -63,6 +63,22 @@ pub struct MinimumFeedbackArcSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumFeedbackArcSetData { + graph: DirectedGraph, + weights: Vec, +} + +impl<'de, W> Deserialize<'de> for MinimumFeedbackArcSet +where + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumFeedbackArcSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumFeedbackArcSetCreateSpec { /// The directed graph. @@ -78,19 +94,22 @@ impl TryFrom for MinimumFeedbackArcSet { if weights.len() != count { return Err(format!("weights has {} entries, expected {count}", weights.len()).into()); } - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } impl MinimumFeedbackArcSet { /// Create a Minimum Feedback Arc Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_arcs(), - "weights length must match graph num_arcs" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + weights: Vec, + ) -> Result { + Self::check_weights(&graph, &weights)?; + Ok(Self { graph, weights }) } /// Get a reference to the underlying directed graph. @@ -113,6 +132,16 @@ impl MinimumFeedbackArcSet { self.weights = weights; } + fn check_weights( + graph: &DirectedGraph, + weights: &[W], + ) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_arcs() { + return Err("weights length must match graph num_arcs".into()); + } + Ok(()) + } + /// Check if a configuration is a valid feedback arc set. /// /// A configuration is valid if removing the selected arcs makes the graph acyclic. @@ -184,8 +213,12 @@ impl crate::solvers::BruteForceProblem for MinimumFeedbackArcSet where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_arcs()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_arcs()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/minimum_feedback_vertex_set.rs b/src/models/graph/minimum_feedback_vertex_set.rs index 762efe3da..59478685d 100644 --- a/src/models/graph/minimum_feedback_vertex_set.rs +++ b/src/models/graph/minimum_feedback_vertex_set.rs @@ -49,7 +49,7 @@ inventory::submit! { /// // Any single vertex breaks the cycle /// assert_eq!(solutions.len(), 3); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumFeedbackVertexSet { /// The underlying directed graph. graph: DirectedGraph, @@ -57,6 +57,22 @@ pub struct MinimumFeedbackVertexSet { weights: Vec, } +#[derive(Deserialize)] +struct MinimumFeedbackVertexSetData { + graph: DirectedGraph, + weights: Vec, +} + +impl<'de, W> Deserialize<'de> for MinimumFeedbackVertexSet +where + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumFeedbackVertexSetData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumFeedbackVertexSetCreateSpec { /// The directed graph. @@ -74,19 +90,22 @@ impl TryFrom> if weights.len() != count { return Err(format!("weights has {} entries, expected {count}", weights.len()).into()); } - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } impl MinimumFeedbackVertexSet { /// Create a Feedback Vertex Set problem from a directed graph with given weights. pub fn new(graph: DirectedGraph, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: DirectedGraph, + weights: Vec, + ) -> Result { + Self::check_weights(&graph, &weights)?; + Ok(Self { graph, weights }) } /// Get a reference to the underlying directed graph. @@ -109,6 +128,16 @@ impl MinimumFeedbackVertexSet { self.weights = weights; } + fn check_weights( + graph: &DirectedGraph, + weights: &[W], + ) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(()) + } + /// Check if a configuration is a valid feedback vertex set. pub fn is_valid_solution(&self, config: &[usize]) -> bool { if config.len() != self.graph.num_vertices() { @@ -185,8 +214,12 @@ impl crate::solvers::BruteForceProblem for MinimumFeedbackVertexSet where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -200,7 +233,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_geometric_connected_dominating_set.rs b/src/models/graph/minimum_geometric_connected_dominating_set.rs index 393b20b6c..cdf97e4c9 100644 --- a/src/models/graph/minimum_geometric_connected_dominating_set.rs +++ b/src/models/graph/minimum_geometric_connected_dominating_set.rs @@ -242,8 +242,12 @@ impl Problem for MinimumGeometricConnectedDominatingSet { } impl crate::solvers::BruteForceProblem for MinimumGeometricConnectedDominatingSet { - fn dimensions(&self) -> Vec { - vec![2; self.num_points()] + fn num_variables(&self) -> Result { + Ok(self.num_points()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/minimum_graph_bandwidth.rs b/src/models/graph/minimum_graph_bandwidth.rs index d214d03e8..4da4735f0 100644 --- a/src/models/graph/minimum_graph_bandwidth.rs +++ b/src/models/graph/minimum_graph_bandwidth.rs @@ -170,9 +170,12 @@ impl crate::solvers::BruteForceProblem for MinimumGraphBandwidth where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/minimum_intersection_graph_basis.rs b/src/models/graph/minimum_intersection_graph_basis.rs index 2f26294c4..9780abdbd 100644 --- a/src/models/graph/minimum_intersection_graph_basis.rs +++ b/src/models/graph/minimum_intersection_graph_basis.rs @@ -162,14 +162,18 @@ impl crate::solvers::BruteForceProblem for MinimumIntersectionGraphBasis where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - let m = self.graph.num_edges(); - if m == 0 { - // No edges: no variables needed; empty assignment is trivially valid. - return vec![]; - } - vec![2; n * m] + fn num_variables(&self) -> Result { + (self.graph.num_vertices()) + .checked_mul(self.graph.num_edges()) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a search coordinate size".into(), + ) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2) } } diff --git a/src/models/graph/minimum_maximal_matching.rs b/src/models/graph/minimum_maximal_matching.rs index d559fb9c1..a6c66caac 100644 --- a/src/models/graph/minimum_maximal_matching.rs +++ b/src/models/graph/minimum_maximal_matching.rs @@ -160,8 +160,12 @@ impl crate::solvers::BruteForceProblem for MinimumMaximalMatching where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/minimum_metric_dimension.rs b/src/models/graph/minimum_metric_dimension.rs index 42261c5c3..911bc2b24 100644 --- a/src/models/graph/minimum_metric_dimension.rs +++ b/src/models/graph/minimum_metric_dimension.rs @@ -32,7 +32,7 @@ inventory::submit! { /// /// Returns a vector where `dist[v]` is the shortest-path distance from /// `source` to `v`, or `usize::MAX` if `v` is unreachable. -pub fn bfs_distances(graph: &G, source: usize) -> Vec { +pub(crate) fn bfs_distances(graph: &G, source: usize) -> Vec { let n = graph.num_vertices(); let mut dist = vec![usize::MAX; n]; dist[source] = 0; @@ -190,8 +190,12 @@ impl crate::solvers::BruteForceProblem for MinimumMetricDimension where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/minimum_multiway_cut.rs b/src/models/graph/minimum_multiway_cut.rs index 6b1b66cec..ef9692216 100644 --- a/src/models/graph/minimum_multiway_cut.rs +++ b/src/models/graph/minimum_multiway_cut.rs @@ -42,13 +42,33 @@ inventory::submit! { /// /// A configuration is feasible if removing the cut edges disconnects all /// terminal pairs. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumMultiwayCut { graph: G, terminals: Vec, edge_weights: Vec, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct MinimumMultiwayCutData { + graph: G, + terminals: Vec, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MinimumMultiwayCut +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumMultiwayCutData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.terminals, data.edge_weights) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumMultiwayCutCreateSpec { /// The undirected graph G=(V,E). @@ -90,7 +110,7 @@ impl TryFrom for MinimumMultiwayCut MinimumMultiwayCut { /// - If any terminal index is out of bounds /// - If there are duplicate terminal indices pub fn new(graph: G, terminals: Vec, edge_weights: Vec) -> Self { - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - assert!(terminals.len() >= 2, "need at least 2 terminals"); + Self::try_new(graph, terminals, edge_weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + terminals: Vec, + edge_weights: Vec, + ) -> Result { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match num_edges".into()); + } + if !(terminals.len() >= 2) { + return Err("need at least 2 terminals".into()); + } let mut sorted = terminals.clone(); sorted.sort(); sorted.dedup(); - assert_eq!(sorted.len(), terminals.len(), "duplicate terminal indices"); + if sorted.len() != terminals.len() { + return Err("duplicate terminal indices".into()); + } for &t in &terminals { - assert!(t < graph.num_vertices(), "terminal index out of bounds"); + if !(t < graph.num_vertices()) { + return Err("terminal index out of bounds".into()); + } } - Self { + Ok(Self { graph, terminals, edge_weights, - } + }) } /// Get a reference to the underlying graph. @@ -251,8 +283,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/minimum_sum_multicenter.rs b/src/models/graph/minimum_sum_multicenter.rs index 2ba567460..33f5e097e 100644 --- a/src/models/graph/minimum_sum_multicenter.rs +++ b/src/models/graph/minimum_sum_multicenter.rs @@ -54,7 +54,7 @@ inventory::submit! { /// // Center at vertex 1 gives total distance 0+1+1 = 2 (optimal) /// assert_eq!(solution, vec![false, true, false]); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumSumMulticenter { /// The underlying graph. graph: G, @@ -66,6 +66,27 @@ pub struct MinimumSumMulticenter { k: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct MinimumSumMulticenterData { + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, +} + +impl<'de, G, W> Deserialize<'de> for MinimumSumMulticenter +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumSumMulticenterData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.vertex_weights, data.edge_lengths, data.k) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumSumMulticenterCreateSpec { #[create(codec = "edge-list")] @@ -120,7 +141,7 @@ impl TryFrom for MinimumSumMulticenter graph.num_vertices() { return Err(format!("k must be between 1 and {}", graph.num_vertices()).into()); } - Ok(Self::new(graph, vertex_weights, edge_lengths, spec.k)) + Self::try_new(graph, vertex_weights, edge_lengths, spec.k) } } @@ -160,24 +181,34 @@ impl MinimumSumMulticenter { /// - If `edge_lengths.len() != graph.num_edges()` /// - If `k == 0` or `k > graph.num_vertices()` pub fn new(graph: G, vertex_weights: Vec, edge_lengths: Vec, k: usize) -> Self { - assert_eq!( - vertex_weights.len(), - graph.num_vertices(), - "vertex_weights length must match num_vertices" - ); - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - assert!(k > 0, "k must be positive"); - assert!(k <= graph.num_vertices(), "k must not exceed num_vertices"); - Self { + Self::try_new(graph, vertex_weights, edge_lengths, k) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + vertex_weights: Vec, + edge_lengths: Vec, + k: usize, + ) -> Result { + if vertex_weights.len() != graph.num_vertices() { + return Err("vertex_weights length must match num_vertices".into()); + } + if edge_lengths.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + if k == 0 { + return Err("k must be positive".into()); + } + if !(k <= graph.num_vertices()) { + return Err("k must not exceed num_vertices".into()); + } + Ok(Self { graph, vertex_weights, edge_lengths, k, - } + }) } /// Get a reference to the underlying graph. @@ -352,8 +383,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -409,3 +444,65 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionMinimumSumMulticenter"); +crate::register_decision_variant!( + MinimumSumMulticenter, "DecisionMinimumSumMulticenter", "2^num_vertices", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "k", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| crate::config::config_to_bits(&indices) +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_minimum_sum_multicenter_to_minimum_sum_multicenter", + build: || { + let source = crate::models::decision::Decision::new( + MinimumSumMulticenter::new( + SimpleGraph::new( + 7, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (0, 6), + (2, 5), + ], + ), + vec![1i64; 7], + vec![1i64; 8], + 2, + ), + 6, + ); + let witness = serde_json::json!(vec![false, false, true, false, false, true, false]); + crate::example_db::specs::rule_example_with_witness::< + _, + MinimumSumMulticenter, + >( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 5f511118b..cd9df87f6 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -52,7 +52,7 @@ inventory::submit! { /// // Minimum vertex cover is just vertex 1 /// assert!(solutions.contains(&vec![false, true, false])); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumVertexCover { /// The underlying graph. graph: G, @@ -60,6 +60,23 @@ pub struct MinimumVertexCover { weights: Vec, } +#[derive(Deserialize)] +struct MinimumVertexCoverData { + graph: G, + weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for MinimumVertexCover +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MinimumVertexCoverData::deserialize(deserializer)?; + Self::try_new(data.graph, data.weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumVertexCoverCreateSpec { /// The underlying graph G=(V,E). @@ -84,19 +101,21 @@ impl TryFrom> ) .into()); } - Ok(Self::new(spec.graph, weights)) + Self::try_new(spec.graph, weights) } } impl MinimumVertexCover { /// Create a Vertex Covering problem from a graph with given weights. pub fn new(graph: G, weights: Vec) -> Self { - assert_eq!( - weights.len(), - graph.num_vertices(), - "weights length must match graph num_vertices" - ); - Self { graph, weights } + Self::try_new(graph, weights).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, weights: Vec) -> Result { + if weights.len() != graph.num_vertices() { + return Err("weights length must match graph num_vertices".into()); + } + Ok(Self { graph, weights }) } /// Get a reference to the underlying graph. @@ -183,8 +202,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -217,7 +240,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) } } @@ -240,7 +263,11 @@ where const DECISION_NAME: &'static str = "DecisionMinimumVertexCover"; } -impl Decision> { +impl Decision> +where + W: WeightElement + crate::variant::VariantParam, + W::Sum: std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, +{ /// Number of vertices in the underlying graph. pub fn num_vertices(&self) -> usize { self.inner().num_vertices() @@ -250,11 +277,6 @@ impl Decision> { pub fn num_edges(&self) -> usize { self.inner().num_edges() } - - /// Decision bound as a nonnegative integer. - pub fn k(&self) -> usize { - (*self.bound()).try_into().unwrap_or(0) - } } #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -298,13 +320,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 ); @@ -325,52 +348,73 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec { - vec![crate::example_db::specs::ModelExampleSpec { - id: "decision_minimum_vertex_cover_simplegraph", - instance: Box::new(crate::models::decision::Decision::new( - MinimumVertexCover::new( - SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), - vec![1i64; 4], - ), - 2, - )), - optimal_config: serde_json::json!(vec![true, false, true, false]), - optimal_value: serde_json::json!(true), - }] -} - -#[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( + vec![ + crate::example_db::specs::ModelExampleSpec { + id: "decision_minimum_vertex_cover_simplegraph", + instance: Box::new(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), - }], - ) + )), + optimal_config: serde_json::json!(vec![true, false, true, false]), + optimal_value: serde_json::json!(true), }, - }] + crate::example_db::specs::ModelExampleSpec { + id: "decision_minimum_vertex_cover_unit", + 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), + }, + ] +} + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + 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: || { + let source = Decision::new( + MinimumVertexCover::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), + vec![1i64; 4], + ), + 2, + ); + rule_example_with_witness::<_, MinimumVertexCover>( + source, + 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_unit_to_minimum_vertex_cover", + build: || { + let source = Decision::new( + MinimumVertexCover::new(SimpleGraph::path(3), vec![One; 3]), + 1, + ); + rule_example_with_witness::<_, MinimumVertexCover>( + source, + 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 8bd02cb1e..847ca70f4 100644 --- a/src/models/graph/mixed_chinese_postman.rs +++ b/src/models/graph/mixed_chinese_postman.rs @@ -35,13 +35,32 @@ inventory::submit! { /// edge. The minimum-cost closed walk is then computed via the directed Chinese /// Postman subproblem, using all available arcs (including both directions of /// every undirected edge) for degree-balancing detours. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MixedChinesePostman> { graph: MixedGraph, arc_weights: Vec, edge_weights: Vec, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "W: WeightElement + Deserialize<'de>"))] +struct MixedChinesePostmanData> { + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, +} + +impl<'de, W> Deserialize<'de> for MixedChinesePostman +where + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MixedChinesePostmanData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.arc_weights, data.edge_weights) + .map_err(serde::de::Error::custom) + } +} + macro_rules! mixed_chinese_postman_create_spec { ($name:ident, $weight:ty, $one:expr $(, $arc_weights:ident, $edge_weights:ident)?) => { #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -349,8 +368,12 @@ impl crate::solvers::BruteForceProblem for MixedChinesePostman where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7835dd74f..720ca1dc6 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -54,7 +54,6 @@ //! - [`PartitionIntoPathsOfLength2`]: Partition vertices into triples with at least two edges each //! - [`PrizeCollectingSteinerForest`]: Forest minimizing omitted-prize plus edge-cost plus omega times the number of tree components //! - [`BicliqueCover`]: Biclique cover on bipartite graphs -//! - [`SteinerTreeInGraphs`]: Minimum weight Steiner tree connecting terminal vertices //! - [`BalancedCompleteBipartiteSubgraph`]: Balanced biclique decision problem //! - [`BiconnectivityAugmentation`]: Biconnectivity augmentation with weighted potential edges //! - [`BoundedComponentSpanningForest`]: Partition vertices into bounded-weight connected components @@ -161,7 +160,6 @@ pub(crate) mod rural_postman; pub(crate) mod shortest_weight_constrained_path; pub(crate) mod spin_glass; pub(crate) mod steiner_tree; -pub(crate) mod steiner_tree_in_graphs; pub(crate) mod strong_connectivity_augmentation; pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; @@ -245,7 +243,6 @@ pub use rural_postman::RuralPostman; pub use shortest_weight_constrained_path::ShortestWeightConstrainedPath; pub use spin_glass::SpinGlass; pub use steiner_tree::SteinerTree; -pub use steiner_tree_in_graphs::SteinerTreeInGraphs; pub use strong_connectivity_augmentation::StrongConnectivityAugmentation; pub use subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; @@ -322,7 +319,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec { /// The underlying graph. graph: G, @@ -68,6 +67,23 @@ pub struct MonochromaticTriangle { edge_list: Vec<(usize, usize)>, } +// The persisted triangle and edge lists are derived data; loading rebuilds them. +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct MonochromaticTriangleData { + graph: G, +} + +impl<'de, G> Deserialize<'de> for MonochromaticTriangle +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = MonochromaticTriangleData::::deserialize(deserializer)?; + Ok(Self::new(data.graph)) + } +} + impl MonochromaticTriangle { /// Create a new Monochromatic Triangle instance. pub fn new(graph: G) -> Self { @@ -188,8 +204,12 @@ impl crate::solvers::BruteForceProblem for MonochromaticTriangle where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.edge_list.len()] + fn num_variables(&self) -> Result { + Ok(self.edge_list.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/multiple_choice_branching.rs b/src/models/graph/multiple_choice_branching.rs index b04671ee2..5c1bb5677 100644 --- a/src/models/graph/multiple_choice_branching.rs +++ b/src/models/graph/multiple_choice_branching.rs @@ -268,8 +268,12 @@ impl crate::solvers::BruteForceProblem for MultipleChoiceBranching where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_arcs()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_arcs()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/multiple_copy_file_allocation.rs b/src/models/graph/multiple_copy_file_allocation.rs index 0813b0113..2475804e1 100644 --- a/src/models/graph/multiple_copy_file_allocation.rs +++ b/src/models/graph/multiple_copy_file_allocation.rs @@ -33,12 +33,27 @@ inventory::submit! { /// /// where d(v, V') is the shortest-path distance from v to the nearest copy in V'. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MultipleCopyFileAllocationData")] pub struct MultipleCopyFileAllocation { graph: SimpleGraph, usage: Vec, storage: Vec, } +#[derive(Deserialize)] +struct MultipleCopyFileAllocationData { + graph: SimpleGraph, + usage: Vec, + storage: Vec, +} + +impl TryFrom for MultipleCopyFileAllocation { + type Error = crate::registry::ConstructionError; + fn try_from(data: MultipleCopyFileAllocationData) -> Result { + Self::try_new(data.graph, data.usage, data.storage) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MultipleCopyFileAllocationCreateSpec { /// Network graph edges. @@ -94,21 +109,25 @@ impl TryFrom for MultipleCopyFileAllocatio impl MultipleCopyFileAllocation { /// Create a new Multiple Copy File Allocation instance. pub fn new(graph: SimpleGraph, usage: Vec, storage: Vec) -> Self { - assert_eq!( - usage.len(), - graph.num_vertices(), - "usage length must match graph num_vertices" - ); - assert_eq!( - storage.len(), - graph.num_vertices(), - "storage length must match graph num_vertices" - ); - Self { + Self::try_new(graph, usage, storage).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: SimpleGraph, + usage: Vec, + storage: Vec, + ) -> Result { + if usage.len() != graph.num_vertices() { + return Err("usage length must match graph num_vertices".into()); + } + if storage.len() != graph.num_vertices() { + return Err("storage length must match graph num_vertices".into()); + } + Ok(Self { graph, usage, storage, - } + }) } /// Get a reference to the underlying graph. @@ -270,8 +289,12 @@ impl Problem for MultipleCopyFileAllocation { } impl crate::solvers::BruteForceProblem for MultipleCopyFileAllocation { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/optimal_linear_arrangement.rs b/src/models/graph/optimal_linear_arrangement.rs index c50552603..0531df50c 100644 --- a/src/models/graph/optimal_linear_arrangement.rs +++ b/src/models/graph/optimal_linear_arrangement.rs @@ -184,9 +184,12 @@ impl crate::solvers::BruteForceProblem for OptimalLinearArrangement where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } @@ -285,7 +288,7 @@ pub(crate) fn decision_canonical_rule_example_specs( build: || { use crate::example_db::specs::assemble_rule_example; use crate::export::SolutionPair; - use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + use crate::rules::{ReduceTo, ReductionResult}; use crate::topology::SimpleGraph; // Path P_4 (0-1-2-3): optimal arrangement has cost 3; bound 3 is YES. @@ -293,8 +296,7 @@ pub(crate) fn decision_canonical_rule_example_specs( OptimalLinearArrangement::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)])), 3, ); - let result = source - .reduce_to_aggregate() + let result = ReduceTo::>::reduce_to(&source) .expect("reduction should succeed"); let target = result.target_problem(); let config = vec![0, 1, 2, 3]; diff --git a/src/models/graph/partial_feedback_edge_set.rs b/src/models/graph/partial_feedback_edge_set.rs index c62ad3c72..4ed0a737d 100644 --- a/src/models/graph/partial_feedback_edge_set.rs +++ b/src/models/graph/partial_feedback_edge_set.rs @@ -147,8 +147,12 @@ impl crate::solvers::BruteForceProblem for PartialFeedbackEdgeSet where G: Graph + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/partition_into_cliques.rs b/src/models/graph/partition_into_cliques.rs index 5bb87de80..fbb7545b3 100644 --- a/src/models/graph/partition_into_cliques.rs +++ b/src/models/graph/partition_into_cliques.rs @@ -53,7 +53,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct PartitionIntoCliques { /// The underlying graph. @@ -62,18 +62,40 @@ pub struct PartitionIntoCliques { num_cliques: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoCliquesData { + graph: G, + num_cliques: usize, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoCliques +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoCliquesData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.num_cliques).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoCliques { /// Create a new Partition Into Cliques instance. /// /// # Panics /// Panics if `num_cliques` is zero or greater than `graph.num_vertices()`. pub fn new(graph: G, num_cliques: usize) -> Self { - assert!(num_cliques >= 1, "num_cliques must be at least 1"); - assert!( - num_cliques <= graph.num_vertices(), - "num_cliques must be at most num_vertices" - ); - Self { graph, num_cliques } + Self::try_new(graph, num_cliques).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, num_cliques: usize) -> Result { + if num_cliques == 0 { + return Err("num_cliques must be at least 1".into()); + } + if !(num_cliques <= graph.num_vertices()) { + return Err("num_cliques must be at most num_vertices".into()); + } + Ok(Self { graph, num_cliques }) } /// Get a reference to the underlying graph. @@ -139,8 +161,12 @@ impl crate::solvers::BruteForceProblem for PartitionIntoCliques where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.num_cliques; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_cliques) } } diff --git a/src/models/graph/partition_into_forests.rs b/src/models/graph/partition_into_forests.rs index 09fe3a156..92f116f9d 100644 --- a/src/models/graph/partition_into_forests.rs +++ b/src/models/graph/partition_into_forests.rs @@ -8,6 +8,7 @@ use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; use crate::variant::VariantParam; +use petgraph::unionfind::UnionFind; use serde::{Deserialize, Serialize}; inventory::submit! { @@ -53,7 +54,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct PartitionIntoForests { /// The underlying graph. @@ -62,14 +63,37 @@ pub struct PartitionIntoForests { num_forests: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoForestsData { + graph: G, + num_forests: usize, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoForests +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoForestsData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.num_forests).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoForests { /// Create a new Partition Into Forests instance. /// /// # Panics /// Panics if `num_forests` is zero. pub fn new(graph: G, num_forests: usize) -> Self { - assert!(num_forests >= 1, "num_forests must be at least 1"); - Self { graph, num_forests } + Self::try_new(graph, num_forests).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, num_forests: usize) -> Result { + if num_forests == 0 { + return Err("num_forests must be at least 1".into()); + } + Ok(Self { graph, num_forests }) } /// Get a reference to the underlying graph. @@ -139,8 +163,12 @@ impl crate::solvers::BruteForceProblem for PartitionIntoForests where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.num_forests; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_forests) } } @@ -159,14 +187,7 @@ fn is_valid_forest_partition(graph: &G, num_forests: usize, config: &[ // For each forest class, verify the induced subgraph is acyclic using union-find. // An undirected graph is acyclic iff union-find never sees an edge (u, v) where // u and v already share a component. - let mut parent: Vec = (0..n).collect(); - - fn find(parent: &mut Vec, x: usize) -> usize { - if parent[x] != x { - parent[x] = find(parent, parent[x]); - } - parent[x] - } + let mut components = UnionFind::::new(n); for (u, v) in graph.edges() { if config[u] != config[v] { @@ -174,12 +195,9 @@ fn is_valid_forest_partition(graph: &G, num_forests: usize, config: &[ continue; } // Both u and v are in the same class; check for cycle - let ru = find(&mut parent, u); - let rv = find(&mut parent, v); - if ru == rv { + if !components.union(u, v) { return false; // Cycle detected } - parent[ru] = rv; // Union } true 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..a00375c87 100644 --- a/src/models/graph/partition_into_paths_of_length_2.rs +++ b/src/models/graph/partition_into_paths_of_length_2.rs @@ -58,26 +58,47 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct PartitionIntoPathsOfLength2 { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoPathsOfLength2Data { + graph: G, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoPathsOfLength2 +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoPathsOfLength2Data::::deserialize(deserializer)?; + Self::try_new(data.graph).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoPathsOfLength2 { /// Create a new PartitionIntoPathsOfLength2 problem from a graph. /// /// # Panics /// Panics if `graph.num_vertices()` is not divisible by 3. pub fn new(graph: G) -> Self { - assert_eq!( - graph.num_vertices() % 3, - 0, - "Number of vertices ({}) must be divisible by 3", - graph.num_vertices() - ); - Self { graph } + Self::try_new(graph).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G) -> Result { + if !graph.num_vertices().is_multiple_of(3) { + return Err(format!( + "Number of vertices ({}) must be divisible by 3", + graph.num_vertices() + ) + .into()); + } + Ok(Self { graph }) } /// Get a reference to the underlying graph. @@ -180,9 +201,12 @@ impl crate::solvers::BruteForceProblem for PartitionIntoPathsOfLength2 where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - let q = self.num_groups(); - vec![q; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_groups()) } } diff --git a/src/models/graph/partition_into_perfect_matchings.rs b/src/models/graph/partition_into_perfect_matchings.rs index e93976e53..29f1debb1 100644 --- a/src/models/graph/partition_into_perfect_matchings.rs +++ b/src/models/graph/partition_into_perfect_matchings.rs @@ -55,7 +55,7 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct PartitionIntoPerfectMatchings { /// The underlying graph. @@ -64,21 +64,43 @@ pub struct PartitionIntoPerfectMatchings { num_matchings: usize, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoPerfectMatchingsData { + graph: G, + num_matchings: usize, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoPerfectMatchings +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoPerfectMatchingsData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.num_matchings).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoPerfectMatchings { /// Create a new Partition Into Perfect Matchings instance. /// /// # Panics /// Panics if `num_matchings` is zero or greater than `graph.num_vertices()`. pub fn new(graph: G, num_matchings: usize) -> Self { - assert!(num_matchings >= 1, "num_matchings must be at least 1"); - assert!( - num_matchings <= graph.num_vertices(), - "num_matchings must be at most num_vertices" - ); - Self { + Self::try_new(graph, num_matchings).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G, num_matchings: usize) -> Result { + if num_matchings == 0 { + return Err("num_matchings must be at least 1".into()); + } + if !(num_matchings <= graph.num_vertices()) { + return Err("num_matchings must be at most num_vertices".into()); + } + Ok(Self { graph, num_matchings, - } + }) } /// Get a reference to the underlying graph. @@ -148,8 +170,12 @@ impl crate::solvers::BruteForceProblem for PartitionIntoPerfectMatchings where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - vec![self.num_matchings; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_matchings) } } diff --git a/src/models/graph/partition_into_triangles.rs b/src/models/graph/partition_into_triangles.rs index 8638705d2..5e6f9e8a8 100644 --- a/src/models/graph/partition_into_triangles.rs +++ b/src/models/graph/partition_into_triangles.rs @@ -50,25 +50,47 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] #[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] pub struct PartitionIntoTriangles { /// The underlying graph. graph: G, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>"))] +struct PartitionIntoTrianglesData { + graph: G, +} + +impl<'de, G> Deserialize<'de> for PartitionIntoTriangles +where + G: Graph + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = PartitionIntoTrianglesData::::deserialize(deserializer)?; + Self::try_new(data.graph).map_err(serde::de::Error::custom) + } +} + impl PartitionIntoTriangles { /// Create a new Partition Into Triangles problem from a graph. /// /// # Panics /// Panics if the number of vertices is not divisible by 3. pub fn new(graph: G) -> Self { - assert!( - graph.num_vertices().is_multiple_of(3), - "Number of vertices ({}) must be divisible by 3", - graph.num_vertices() - ); - Self { graph } + Self::try_new(graph).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(graph: G) -> Result { + if !(graph.num_vertices().is_multiple_of(3)) { + return Err(format!( + "Number of vertices ({}) must be divisible by 3", + graph.num_vertices() + ) + .into()); + } + Ok(Self { graph }) } /// Get a reference to the underlying graph. @@ -168,9 +190,12 @@ impl crate::solvers::BruteForceProblem for PartitionIntoTriangles where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - let q = self.graph.num_vertices() / 3; - vec![q; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices() / 3) } } diff --git a/src/models/graph/path_constrained_network_flow.rs b/src/models/graph/path_constrained_network_flow.rs index e9f324664..e4db720fe 100644 --- a/src/models/graph/path_constrained_network_flow.rs +++ b/src/models/graph/path_constrained_network_flow.rs @@ -34,6 +34,7 @@ inventory::submit! { /// - the induced arc loads do not exceed the arc capacities /// - the total delivered flow reaches the requirement #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PathConstrainedNetworkFlowData")] pub struct PathConstrainedNetworkFlow { graph: DirectedGraph, capacities: Vec, @@ -43,6 +44,30 @@ pub struct PathConstrainedNetworkFlow { requirement: i64, } +#[derive(Deserialize)] +struct PathConstrainedNetworkFlowData { + graph: DirectedGraph, + capacities: Vec, + source: usize, + sink: usize, + paths: Vec>, + requirement: i64, +} + +impl TryFrom for PathConstrainedNetworkFlow { + type Error = crate::registry::ConstructionError; + fn try_from(data: PathConstrainedNetworkFlowData) -> Result { + Self::try_new( + data.graph, + data.capacities, + data.source, + data.sink, + data.paths, + data.requirement, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct PathConstrainedNetworkFlowCreateSpec { /// Directed graph arcs. @@ -327,11 +352,14 @@ impl Problem for PathConstrainedNetworkFlow { } impl crate::solvers::BruteForceProblem for PathConstrainedNetworkFlow { - fn dimensions(&self) -> Vec { - self.paths - .iter() - .map(|path| (self.path_bottleneck(path) as usize) + 1) - .collect() + fn num_variables(&self) -> Result { + Ok(self.paths.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from( + i128::from(self.path_bottleneck(&self.paths[variable])) + 1, + )?) } } diff --git a/src/models/graph/prize_collecting_steiner_forest.rs b/src/models/graph/prize_collecting_steiner_forest.rs index 1503614e1..265ce1688 100644 --- a/src/models/graph/prize_collecting_steiner_forest.rs +++ b/src/models/graph/prize_collecting_steiner_forest.rs @@ -223,12 +223,27 @@ impl PrizeCollectingSteinerForest { } for (index, prize) in vertex_prizes.iter().enumerate() { prize.validate_element(&format!("vertex prize at index {index}"))?; + if prize.to_sum() < W::Sum::zero() { + return Err(ConstructionError::InvalidInput(format!( + "vertex prize at index {index} must be nonnegative" + ))); + } } for (index, cost) in edge_costs.iter().enumerate() { cost.validate_element(&format!("edge cost at index {index}"))?; + if cost.to_sum() < W::Sum::zero() { + return Err(ConstructionError::InvalidInput(format!( + "edge cost at index {index} must be nonnegative" + ))); + } } beta.validate_element("beta")?; omega.validate_element("omega")?; + if beta.to_sum() < W::Sum::zero() || omega.to_sum() < W::Sum::zero() { + return Err(ConstructionError::InvalidInput( + "beta and omega must be nonnegative".into(), + )); + } Ok(Self { graph, vertex_prizes, @@ -389,8 +404,16 @@ where G: Graph + VariantParam, W: WeightElement + VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices() + self.graph.num_edges()] + fn num_variables(&self) -> Result { + (self.graph.num_vertices()) + .checked_add(self.graph.num_edges()) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/rooted_tree_arrangement.rs b/src/models/graph/rooted_tree_arrangement.rs index 316e28187..6dd796133 100644 --- a/src/models/graph/rooted_tree_arrangement.rs +++ b/src/models/graph/rooted_tree_arrangement.rs @@ -160,9 +160,16 @@ impl crate::solvers::BruteForceProblem for RootedTreeArrangement where G: Graph + VariantParam, { - fn dimensions(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; 2 * n] + fn num_variables(&self) -> Result { + (2usize) + .checked_mul(self.graph.num_vertices()) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.graph.num_vertices()) } } diff --git a/src/models/graph/rural_postman.rs b/src/models/graph/rural_postman.rs index 2561b04e1..6d032b580 100644 --- a/src/models/graph/rural_postman.rs +++ b/src/models/graph/rural_postman.rs @@ -52,7 +52,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `W` - The weight type for edge lengths (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct RuralPostman { /// The underlying graph. graph: G, @@ -62,6 +62,26 @@ pub struct RuralPostman { required_edges: Vec, } +#[derive(Deserialize)] +#[serde(bound(deserialize = "G: Graph + Deserialize<'de>, W: WeightElement + Deserialize<'de>"))] +struct RuralPostmanData { + graph: G, + edge_lengths: Vec, + required_edges: Vec, +} + +impl<'de, G, W> Deserialize<'de> for RuralPostman +where + G: Graph + Deserialize<'de>, + W: WeightElement + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = RuralPostmanData::::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_lengths, data.required_edges) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct RuralPostmanCreateSpec { #[create(codec = "edge-list")] @@ -96,7 +116,7 @@ impl TryFrom for RuralPostman { { return Err(format!("required edge index {edge} is out of bounds").into()); } - Ok(Self::new(graph, edge_lengths, spec.required_edges)) + Self::try_new(graph, edge_lengths, spec.required_edges) } } @@ -138,24 +158,30 @@ impl RuralPostman { /// Panics if edge_lengths length does not match graph edges, /// or if any required edge index is out of bounds. pub fn new(graph: G, edge_lengths: Vec, required_edges: Vec) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); + Self::try_new(graph, edge_lengths, required_edges).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_lengths: Vec, + required_edges: Vec, + ) -> Result { + Self::check_weights(&graph, &edge_lengths)?; for &idx in &required_edges { - assert!( - idx < graph.num_edges(), - "required edge index {} out of bounds (graph has {} edges)", - idx, - graph.num_edges() - ); + if !(idx < graph.num_edges()) { + return Err(format!( + "required edge index {} out of bounds (graph has {} edges)", + idx, + graph.num_edges() + ) + .into()); + } } - Self { + Ok(Self { graph, edge_lengths, required_edges, - } + }) } /// Get a reference to the underlying graph. @@ -194,6 +220,13 @@ impl RuralPostman { self.edge_lengths = weights; } + fn check_weights(graph: &G, weights: &[W]) -> Result<(), crate::registry::ConstructionError> { + if weights.len() != graph.num_edges() { + return Err("edge_lengths length must match num_edges".into()); + } + Ok(()) + } + /// Get the edge lengths as a Vec. pub fn weights(&self) -> Vec { self.edge_lengths.clone() @@ -354,8 +387,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![3; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(3usize) } } @@ -400,3 +437,57 @@ pub(crate) fn canonical_model_example_specs() -> Vec, "DecisionRuralPostman"); +crate::register_decision_variant!( + RuralPostman, "DecisionRuralPostman", "2^num_vertices * num_vertices^2", &[], + "Does a feasible solution meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "" }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "" }, + crate::registry::FieldInfo { name: "edge_weights", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "required_edges", type_name: "Vec", description: "" }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_rural_postman_to_rural_postman", + build: || { + let graph = SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + ], + ); + let source = crate::models::decision::Decision::new( + RuralPostman::new(graph, vec![1, 1, 1, 1, 1, 1, 2, 2], vec![0, 2, 4]), + 6, + ); + let witness = serde_json::json!(vec![1, 1, 1, 1, 1, 1, 0, 0]); + crate::example_db::specs::rule_example_with_witness::<_, RuralPostman>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/shortest_weight_constrained_path.rs b/src/models/graph/shortest_weight_constrained_path.rs index 0dab498cf..0f455b0c2 100644 --- a/src/models/graph/shortest_weight_constrained_path.rs +++ b/src/models/graph/shortest_weight_constrained_path.rs @@ -51,7 +51,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`) /// * `N` - The edge length / weight type (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct ShortestWeightConstrainedPath { /// The underlying graph. graph: G, @@ -67,6 +67,39 @@ pub struct ShortestWeightConstrainedPath { weight_bound: N::Sum, } +#[derive(Deserialize)] +#[serde(bound( + deserialize = "G: Graph + Deserialize<'de>, N: WeightElement + Deserialize<'de>, N::Sum: Deserialize<'de>" +))] +struct ShortestWeightConstrainedPathData { + graph: G, + edge_lengths: Vec, + edge_weights: Vec, + source_vertex: usize, + target_vertex: usize, + weight_bound: N::Sum, +} + +impl<'de, G, N> Deserialize<'de> for ShortestWeightConstrainedPath +where + G: Graph + Deserialize<'de>, + N: WeightElement + Deserialize<'de>, + N::Sum: Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = ShortestWeightConstrainedPathData::::deserialize(deserializer)?; + Self::try_new( + data.graph, + data.edge_lengths, + data.edge_weights, + data.source_vertex, + data.target_vertex, + data.weight_bound, + ) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct ShortestWeightConstrainedPathCreateSpec { /// The underlying graph G=(V,E). @@ -127,29 +160,30 @@ impl TryFrom if spec.weight_bound <= 0 { return Err("weight_bound must be positive".to_string().into()); } - Ok(Self::new( + Self::try_new( spec.graph, spec.edge_lengths, spec.edge_weights, spec.source_vertex, spec.target_vertex, spec.weight_bound, - )) + ) } } impl ShortestWeightConstrainedPath { - fn assert_positive_edge_values(values: &[N], label: &str) { - let zero = N::Sum::zero(); - assert!( - values.iter().all(|value| value.to_sum() > zero.clone()), - "All {label} must be positive (> 0)" - ); - } - - fn assert_positive_bound(bound: &N::Sum, label: &str) { - let zero = N::Sum::zero(); - assert!(bound > &zero, "{label} must be positive (> 0)"); + fn check_edge_values( + graph: &G, + values: &[N], + label: &str, + ) -> Result<(), crate::registry::ConstructionError> { + if values.len() != graph.num_edges() { + return Err(format!("{label} length must match num_edges").into()); + } + if !values.iter().all(|value| value.to_sum() > N::Sum::zero()) { + return Err(format!("All {label} must be positive (> 0)").into()); + } + Ok(()) } /// Create a new ShortestWeightConstrainedPath instance. @@ -166,39 +200,54 @@ impl ShortestWeightConstrainedPath { target_vertex: usize, weight_bound: N::Sum, ) -> Self { - assert_eq!( - edge_lengths.len(), - graph.num_edges(), - "edge_lengths length must match num_edges" - ); - assert_eq!( - edge_weights.len(), - graph.num_edges(), - "edge_weights length must match num_edges" - ); - Self::assert_positive_edge_values(&edge_lengths, "edge lengths"); - Self::assert_positive_edge_values(&edge_weights, "edge weights"); - assert!( - source_vertex < graph.num_vertices(), - "source_vertex {} out of bounds (graph has {} vertices)", + Self::try_new( + graph, + edge_lengths, + edge_weights, source_vertex, - graph.num_vertices() - ); - assert!( - target_vertex < graph.num_vertices(), - "target_vertex {} out of bounds (graph has {} vertices)", target_vertex, - graph.num_vertices() - ); - Self::assert_positive_bound(&weight_bound, "weight_bound"); - Self { + weight_bound, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: G, + edge_lengths: Vec, + edge_weights: Vec, + source_vertex: usize, + target_vertex: usize, + weight_bound: N::Sum, + ) -> Result { + Self::check_edge_values(&graph, &edge_lengths, "edge lengths")?; + Self::check_edge_values(&graph, &edge_weights, "edge weights")?; + if !(source_vertex < graph.num_vertices()) { + return Err(format!( + "source_vertex {} out of bounds (graph has {} vertices)", + source_vertex, + graph.num_vertices() + ) + .into()); + } + if !(target_vertex < graph.num_vertices()) { + return Err(format!( + "target_vertex {} out of bounds (graph has {} vertices)", + target_vertex, + graph.num_vertices() + ) + .into()); + } + if weight_bound.partial_cmp(&N::Sum::zero()) != Some(std::cmp::Ordering::Greater) { + return Err("weight_bound must be positive (> 0)".into()); + } + Ok(Self { graph, edge_lengths, edge_weights, source_vertex, target_vertex, weight_bound, - } + }) } /// Get a reference to the underlying graph. @@ -321,6 +370,14 @@ impl ShortestWeightConstrainedPath { Ok(Some(total_length)) } } + + 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)" + ); + } } impl Problem for ShortestWeightConstrainedPath @@ -356,8 +413,12 @@ where G: Graph + crate::variant::VariantParam, N: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/spin_glass.rs b/src/models/graph/spin_glass.rs index b99974051..1f774e8a8 100644 --- a/src/models/graph/spin_glass.rs +++ b/src/models/graph/spin_glass.rs @@ -392,8 +392,12 @@ where + num_traits::Zero + num_traits::Bounded, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_vertices()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_vertices()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -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 meet the objective bound?", + category: crate::registry::ProblemCategory::Graph, + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i64", &["i64"]), + ], + fields: [ + crate::registry::FieldInfo { name: "graph", type_name: "Vec<(usize,usize)>", description: "Undirected interaction graph edges." }, + crate::registry::FieldInfo { name: "num_vertices", type_name: "usize", description: "Vertex count, needed to preserve isolated spins." }, + crate::registry::FieldInfo { name: "couplings", type_name: "Vec", description: "Pairwise couplings; defaults to one per edge." }, + crate::registry::FieldInfo { name: "fields", type_name: "Vec", description: "On-site fields; defaults to zero per vertex." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| SpinGlass::::config_to_spins(&indices).expect("enumerated spin bits are valid") +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_spin_glass_to_spin_glass", + build: || { + let source = crate::models::decision::Decision::new( + SpinGlass::::without_fields( + 5, + vec![ + ((0, 1), 1), + ((1, 2), 1), + ((3, 4), 1), + ((0, 3), 1), + ((1, 3), 1), + ((1, 4), 1), + ((2, 4), 1), + ], + ) + .unwrap(), + -3, + ); + let witness = serde_json::json!(vec![1, -1, 1, 1, -1]); + crate::example_db::specs::rule_example_with_witness::<_, SpinGlass>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/graph/steiner_tree.rs b/src/models/graph/steiner_tree.rs index 6f819ecae..712378a56 100644 --- a/src/models/graph/steiner_tree.rs +++ b/src/models/graph/steiner_tree.rs @@ -47,6 +47,10 @@ inventory::submit! { /// - Selected edges form a tree (connected + acyclic) /// - All terminal vertices are included /// +/// At least one terminal is required. With one terminal, selecting no edges +/// represents the tree consisting of that terminal alone. Signed edge weights +/// are allowed; additional edges must still form a tree containing the terminal. +/// /// # Type Parameters /// /// * `G` - The graph type (e.g., `SimpleGraph`) @@ -105,8 +109,8 @@ impl SteinerTree { if edge_weights.len() != graph.num_edges() { return Err("edge_weights length must match num_edges".into()); } - if terminals.len() < 2 { - return Err("at least 2 terminals required".into()); + if terminals.is_empty() { + return Err("at least one terminal required".into()); } let distinct_terminals: BTreeSet<_> = terminals.iter().copied().collect(); if distinct_terminals.len() != terminals.len() { @@ -222,7 +226,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 @@ -290,13 +294,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 Steiner tree edge weights", - )?; - } + total = W::checked_add_to_sum( + total, + self.edge_weights[idx].to_sum(), + "summing Steiner tree edge weights", + )?; } } Min(Some(total)) @@ -309,8 +311,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -345,8 +351,10 @@ impl TryFrom for SteinerTree { } } +// For signed weights, enumerate nonterminal vertex subsets and compute an MST +// on each induced graph. Terminal-subset shortest-path DP assumes nonnegative weights. 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/steiner_tree_in_graphs.rs b/src/models/graph/steiner_tree_in_graphs.rs deleted file mode 100644 index fe7e42f5d..000000000 --- a/src/models/graph/steiner_tree_in_graphs.rs +++ /dev/null @@ -1,396 +0,0 @@ -//! Steiner Tree in Graphs problem implementation. -//! -//! The Steiner Tree problem asks for a minimum-weight subtree of a graph -//! that connects all terminal vertices. - -use crate::registry::{CreateSpec, ProblemSchemaEntry, VariantDimension}; -use crate::topology::{Graph, SimpleGraph}; -use crate::traits::Problem; -use crate::types::{Min, One, WeightElement}; -use num_traits::Zero; -use serde::{Deserialize, Serialize}; - -inventory::submit! { - ProblemSchemaEntry { - name: "SteinerTreeInGraphs", - display_name: "Steiner Tree in Graphs", - aliases: &[], - dimensions: &[ - VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("weight", "i64", &["One", "i64"]), - ], - category: crate::registry::ProblemCategory::Graph, - module_path: module_path!(), - description: "Find minimum weight subtree connecting all terminal vertices", - fields: SteinerTreeInGraphsCreateSpec::::FIELDS, - } -} - -/// The Steiner Tree in Graphs problem. -/// -/// Given a weighted graph G = (V, E) with edge weights w_e and a -/// subset R ⊆ V of required terminal vertices, find a subtree T of G -/// that includes all vertices of R and minimizes the total edge weight -/// Σ_{e ∈ T} w(e). -/// -/// # Representation -/// -/// Each edge is assigned a binary variable: -/// - 0: edge is not in the tree -/// - 1: edge is in the tree -/// -/// A valid Steiner tree requires: -/// - All terminal vertices are connected through selected edges -/// - Selected edges form a connected subgraph (optimally a tree) -/// -/// # Type Parameters -/// -/// * `G` - The graph type (e.g., `SimpleGraph`) -/// * `W` - The weight type for edges (e.g., `i64`, `f64`) -/// -/// # Example -/// -/// ``` -/// use problemreductions::models::graph::SteinerTreeInGraphs; -/// use problemreductions::topology::SimpleGraph; -/// use problemreductions::{Problem, BruteForce}; -/// -/// // Path graph 0-1-2-3, terminals {0, 3} -/// let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); -/// let problem = SteinerTreeInGraphs::new(graph, vec![0, 3], vec![1, 1, 1]); -/// -/// let solver = BruteForce::new(); -/// let solution = solver.solve(&problem).unwrap().unwrap(); -/// // Optimal: select all 3 edges (the only path from 0 to 3) -/// assert_eq!(solution, vec![true, true, true]); -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SteinerTreeInGraphs { - /// The underlying graph. - graph: G, - /// Required terminal vertices. - terminals: Vec, - /// Weights for each edge (in edge index order). - edge_weights: Vec, -} - -#[derive(Debug, Deserialize, crate::CreateSpec)] -struct SteinerTreeInGraphsCreateSpec { - /// The underlying graph. - graph: SimpleGraph, - /// Required terminal vertices. - terminals: Vec, - /// Edge weights; defaults to one per edge. - edge_weights: Option>, -} -impl TryFrom> for SteinerTreeInGraphs -where - W: WeightElement, -{ - type Error = crate::registry::ConstructionError; - fn try_from(spec: SteinerTreeInGraphsCreateSpec) -> Result { - let count = spec.graph.num_edges(); - let edge_weights = spec - .edge_weights - .unwrap_or_else(|| (0..count).map(|_| W::unit()).collect()); - if edge_weights.len() != count { - return Err(format!( - "edge_weights has {} entries, expected {count}", - edge_weights.len() - ) - .into()); - } - if let Some(&terminal) = spec - .terminals - .iter() - .find(|&&t| t >= spec.graph.num_vertices()) - { - return Err(format!("terminal {terminal} is outside the graph").into()); - } - Ok(Self::new(spec.graph, spec.terminals, edge_weights)) - } -} - -impl SteinerTreeInGraphs { - /// Create a SteinerTreeInGraphs problem from a graph, terminals, and edge weights. - /// - /// # Panics - /// Panics if `edge_weights.len() != graph.num_edges()` or any terminal index is out of bounds. - 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" - ); - for &t in &terminals { - assert!( - t < graph.num_vertices(), - "terminal vertex {} out of bounds (num_vertices = {})", - t, - graph.num_vertices() - ); - } - Self { - graph, - terminals, - edge_weights, - } - } - - /// Get a reference to the underlying graph. - pub fn graph(&self) -> &G { - &self.graph - } - - /// Get the terminal vertices. - pub fn terminals(&self) -> &[usize] { - &self.terminals - } - - /// Get all edges with their weights. - pub fn edges(&self) -> Vec<(usize, usize, W)> { - self.graph - .edges() - .into_iter() - .zip(self.edge_weights.iter().cloned()) - .map(|((u, v), w)| (u, v, w)) - .collect() - } - - /// Set new weights for the problem. - pub fn set_weights(&mut self, weights: Vec) { - assert_eq!(weights.len(), self.graph.num_edges()); - self.edge_weights = weights; - } - - /// Get the weights for the problem. - pub fn weights(&self) -> Vec { - self.edge_weights.clone() - } - - /// Check if the problem uses a non-unit weight type. - pub fn is_weighted(&self) -> bool - where - W: WeightElement, - { - !W::IS_UNIT - } - - /// Check if a configuration is a valid Steiner tree. - pub fn is_valid_solution(&self, config: &[usize]) -> bool { - if config.len() != self.graph.num_edges() { - return false; - } - let selected: Vec = config.iter().map(|&s| s == 1).collect(); - is_steiner_tree(&self.graph, &self.terminals, &selected) - } -} - -impl SteinerTreeInGraphs { - /// Get the number of vertices in the underlying graph. - pub fn num_vertices(&self) -> usize { - self.graph().num_vertices() - } - - /// Get the number of edges in the underlying graph. - pub fn num_edges(&self) -> usize { - self.graph().num_edges() - } - - /// Get the number of terminal vertices. - pub fn num_terminals(&self) -> usize { - self.terminals.len() - } -} - -impl Problem for SteinerTreeInGraphs -where - G: Graph + crate::variant::VariantParam, - W: WeightElement + crate::variant::VariantParam, -{ - const NAME: &'static str = "SteinerTreeInGraphs"; - type Solution = Vec; - type Value = Min; - - crate::problem_parameters![ - ("num_edges", num_edges), - ("num_terminals", num_terminals), - ("num_vertices", num_vertices), - ]; - - fn variant() -> Vec<(&'static str, &'static str)> { - crate::variant_params![G, W] - } - - fn evaluate( - &self, - config: &Self::Solution, - ) -> Result, crate::traits::EvaluationError> { - Ok({ - if config.len() != self.graph.num_edges() { - return Err(crate::traits::EvaluationError::InvalidConfiguration( - "edge-selection length does not match the graph".into(), - )); - } - let selected = config; - if !is_steiner_tree(&self.graph, &self.terminals, selected) { - return Ok(Min(None)); - } - let mut total = W::Sum::zero(); - for (idx, &sel) in config.iter().enumerate() { - if sel { - if let Some(w) = self.edge_weights.get(idx) { - total = W::checked_add_to_sum( - total, - w.to_sum(), - "summing Steiner tree edge weights", - )?; - } - } - } - Min(Some(total)) - }) - } -} - -impl crate::solvers::BruteForceProblem for SteinerTreeInGraphs -where - G: Graph + crate::variant::VariantParam, - W: WeightElement + crate::variant::VariantParam, -{ - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] - } -} - -/// Check if a selection of edges forms a valid Steiner tree (connected subgraph spanning all terminals). -/// -/// A valid Steiner tree requires: -/// 1. All terminal vertices are reachable from each other through selected edges. -/// 2. The selected edges form a connected subgraph that includes all terminals. -/// -/// Note: The optimal solution is always a tree, but we accept any connected subgraph -/// spanning all terminals (the brute-force solver will find the minimum-weight one). -/// -/// # Panics -/// Panics if `selected.len() != graph.num_edges()`. -pub(crate) fn is_steiner_tree(graph: &G, terminals: &[usize], selected: &[bool]) -> bool { - assert_eq!( - selected.len(), - graph.num_edges(), - "selected length must match num_edges" - ); - - // If no terminals, any selection is trivially valid (including empty) - if terminals.is_empty() { - return true; - } - - // If only one terminal, it's valid as long as that terminal exists - // (no edges needed to connect a single vertex) - if terminals.len() == 1 { - return true; - } - - // Build adjacency list from selected edges - let n = graph.num_vertices(); - let edges = graph.edges(); - let mut adj: Vec> = vec![vec![]; n]; - - let mut has_any_edge = false; - for (idx, &sel) in selected.iter().enumerate() { - if sel { - let (u, v) = edges[idx]; - adj[u].push(v); - adj[v].push(u); - has_any_edge = true; - } - } - - if !has_any_edge { - return false; - } - - // BFS from the first terminal to check connectivity of all terminals - let start = terminals[0]; - let mut visited = vec![false; n]; - let mut queue = std::collections::VecDeque::new(); - visited[start] = true; - queue.push_back(start); - - while let Some(node) = queue.pop_front() { - for &neighbor in &adj[node] { - if !visited[neighbor] { - visited[neighbor] = true; - queue.push_back(neighbor); - } - } - } - - // All terminals must be reachable - terminals.iter().all(|&t| visited[t]) -} - -crate::impl_random_generate!(SteinerTreeInGraphs, crate::random::SimpleGraphRandomSpec, |spec| { - if spec.num_vertices < 2 { - return Err("num_vertices must be at least 2".to_string().into()); - } - let graph = spec.graph()?; - let terminals = (0..std::cmp::max(2, spec.num_vertices / 2)).collect(); - let weights = vec![1; graph.num_edges()]; - Ok(SteinerTreeInGraphs::new(graph, terminals, weights)) -}); - -#[derive(Debug, Deserialize, crate::CreateSpec)] -struct SteinerTreeInGraphsOneCreateSpec { - /// The underlying graph. - graph: SimpleGraph, - terminals: Vec, -} - -impl TryFrom for SteinerTreeInGraphs { - type Error = crate::registry::ConstructionError; - fn try_from(spec: SteinerTreeInGraphsOneCreateSpec) -> Result { - let weights = vec![One; spec.graph.num_edges()]; - if let Some(&terminal) = spec - .terminals - .iter() - .find(|&&t| t >= spec.graph.num_vertices()) - { - return Err(format!("terminal {terminal} is outside the graph").into()); - } - Ok(Self::new(spec.graph, spec.terminals, weights)) - } -} - -crate::declare_variants! { - default SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsCreateSpec random, - SteinerTreeInGraphs => "2^num_terminals * num_vertices^3" create SteinerTreeInGraphsOneCreateSpec, -} - -crate::register_brute_force! { - SteinerTreeInGraphs decode |_, indices: Vec| crate::config::config_to_bits(&indices), - SteinerTreeInGraphs decode |_, indices: Vec| crate::config::config_to_bits(&indices), -} - -#[cfg(feature = "example-db")] -pub(crate) fn canonical_model_example_specs() -> Vec { - vec![crate::example_db::specs::ModelExampleSpec { - id: "steiner_tree_in_graphs_simplegraph", - instance: Box::new(SteinerTreeInGraphs::new( - SimpleGraph::new( - 6, - vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 5), (3, 4), (4, 5)], - ), - vec![0, 3, 5], - vec![3, 2, 4, 1, 2, 3, 1], - )), - // Optimal: edges {0,2}(w=2), {2,3}(w=1), {2,5}(w=2) = weight 5 - optimal_config: serde_json::json!(vec![false, true, false, true, true, false, false]), - optimal_value: serde_json::json!(5), - }] -} - -#[cfg(test)] -#[path = "../../unit_tests/models/graph/steiner_tree_in_graphs.rs"] -mod tests; diff --git a/src/models/graph/strong_connectivity_augmentation.rs b/src/models/graph/strong_connectivity_augmentation.rs index 83c2c6cdf..eb60bec1a 100644 --- a/src/models/graph/strong_connectivity_augmentation.rs +++ b/src/models/graph/strong_connectivity_augmentation.rs @@ -213,8 +213,12 @@ impl crate::solvers::BruteForceProblem for StrongConnectivityAugmentation where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.candidate_arcs.len()] + fn num_variables(&self) -> Result { + Ok(self.candidate_arcs.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/subgraph_isomorphism.rs b/src/models/graph/subgraph_isomorphism.rs index def3018fc..a5a35b2b6 100644 --- a/src/models/graph/subgraph_isomorphism.rs +++ b/src/models/graph/subgraph_isomorphism.rs @@ -175,16 +175,18 @@ impl Problem for SubgraphIsomorphism { } impl crate::solvers::BruteForceProblem for SubgraphIsomorphism { - fn dimensions(&self) -> Vec { - let n_host = self.host_graph.num_vertices(); - let n_pattern = self.pattern_graph.num_vertices(); + fn num_variables(&self) -> Result { + Ok(self.pattern_graph.num_vertices()) + } - if n_pattern > n_host { - // No injective mapping possible: each variable gets an empty domain. - vec![0; n_pattern] - } else { - vec![n_host; n_pattern] - } + fn dimension(&self, _variable: usize) -> Result { + Ok( + if self.pattern_graph.num_vertices() > self.host_graph.num_vertices() { + 0 + } else { + self.host_graph.num_vertices() + }, + ) } } diff --git a/src/models/graph/traveling_salesman.rs b/src/models/graph/traveling_salesman.rs index b658067da..aaba87d17 100644 --- a/src/models/graph/traveling_salesman.rs +++ b/src/models/graph/traveling_salesman.rs @@ -47,7 +47,7 @@ inventory::submit! { /// /// * `G` - The graph type (e.g., `SimpleGraph`, `KingsSubgraph`) /// * `W` - The weight type for edges (e.g., `i64`, `f64`) -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct TravelingSalesman { /// The underlying graph. graph: G, @@ -55,6 +55,23 @@ pub struct TravelingSalesman { edge_weights: Vec, } +#[derive(Deserialize)] +struct TravelingSalesmanData { + graph: G, + edge_weights: Vec, +} + +impl<'de, G, W> Deserialize<'de> for TravelingSalesman +where + G: Graph + Deserialize<'de>, + W: Clone + Default + Deserialize<'de>, +{ + fn deserialize>(deserializer: D) -> Result { + let data = TravelingSalesmanData::deserialize(deserializer)?; + Self::try_new(data.graph, data.edge_weights).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct TravelingSalesmanCreateSpec { #[create(codec = "edge-list")] @@ -80,7 +97,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 { + Self::check_weights(&graph, &edge_weights)?; + Ok(Self { graph, edge_weights, - } + }) } /// Create a TravelingSalesman problem with unit weights. @@ -162,6 +179,16 @@ impl TravelingSalesman { self.edge_weights = weights; } + fn check_weights( + graph: &G, + edge_weights: &[W], + ) -> Result<(), crate::registry::ConstructionError> { + if edge_weights.len() != graph.num_edges() { + return Err("edge_weights length must match graph num_edges".into()); + } + Ok(()) + } + /// Get the weights for the problem. pub fn weights(&self) -> Vec { self.edge_weights.clone() @@ -252,8 +279,12 @@ where G: Graph + crate::variant::VariantParam, W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.graph.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.graph.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/undirected_flow_lower_bounds.rs b/src/models/graph/undirected_flow_lower_bounds.rs index ff9b0657f..ea6785896 100644 --- a/src/models/graph/undirected_flow_lower_bounds.rs +++ b/src/models/graph/undirected_flow_lower_bounds.rs @@ -33,6 +33,7 @@ inventory::submit! { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "UndirectedFlowLowerBoundsCreateSpec")] pub struct UndirectedFlowLowerBounds { graph: SimpleGraph, capacities: Vec, @@ -60,50 +61,14 @@ struct UndirectedFlowLowerBoundsCreateSpec { impl TryFrom for UndirectedFlowLowerBounds { type Error = crate::registry::ConstructionError; fn try_from(spec: UndirectedFlowLowerBoundsCreateSpec) -> Result { - let edges = spec.graph.num_edges(); - if spec.capacities.len() != edges { - return Err(format!( - "capacities has {} entries, expected {edges}", - spec.capacities.len() - ) - .into()); - } - if spec.lower_bounds.len() != edges { - return Err(format!( - "lower_bounds has {} entries, expected {edges}", - spec.lower_bounds.len() - ) - .into()); - } - let vertices = spec.graph.num_vertices(); - if spec.source >= vertices || spec.sink >= 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.requirement == 0 { - return Err("requirement must be at least 1".to_string().into()); - } - if let Some((index, _)) = spec - .lower_bounds - .iter() - .zip(&spec.capacities) - .enumerate() - .find(|(_, (&lower, &upper))| lower > upper) - { - return Err(format!("lower bound at edge {index} exceeds its capacity").into()); - } - Ok(Self::new( + Self::try_new( spec.graph, spec.capacities, spec.lower_bounds, spec.source, spec.sink, spec.requirement, - )) + ) } } @@ -116,44 +81,56 @@ impl UndirectedFlowLowerBounds { sink: usize, requirement: i64, ) -> Self { - assert_eq!( - capacities.len(), - graph.num_edges(), - "capacities length must match graph num_edges" - ); - assert_eq!( - lower_bounds.len(), - graph.num_edges(), - "lower_bounds length must match graph num_edges" - ); + Self::try_new(graph, capacities, lower_bounds, source, sink, requirement) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + graph: SimpleGraph, + capacities: Vec, + lower_bounds: Vec, + source: usize, + sink: usize, + requirement: i64, + ) -> Result { + if capacities.len() != graph.num_edges() { + return Err("capacities length must match graph num_edges".into()); + } + if lower_bounds.len() != graph.num_edges() { + return Err("lower_bounds length must match graph num_edges".into()); + } let num_vertices = graph.num_vertices(); - assert!( - source < num_vertices, - "source must be less than num_vertices ({num_vertices})" - ); - assert!( - sink < num_vertices, - "sink must be less than num_vertices ({num_vertices})" - ); - assert!(source != sink, "source and sink must be distinct"); - assert!(requirement >= 1, "requirement must be at least 1"); + if !(source < num_vertices) { + return Err(format!("source must be less than num_vertices ({num_vertices})").into()); + } + if !(sink < num_vertices) { + return Err(format!("sink must be less than num_vertices ({num_vertices})").into()); + } + if source == sink { + return Err("source and sink must be distinct".into()); + } + if requirement < 1 { + return Err("requirement must be at least 1".into()); + } for (edge_index, (&lower, &upper)) in lower_bounds.iter().zip(&capacities).enumerate() { - assert!( - lower <= upper, - "lower bound at edge {edge_index} must be at most its capacity" - ); + if !(lower <= upper) { + return Err(format!( + "lower bound at edge {edge_index} must be at most its capacity" + ) + .into()); + } } - Self { + Ok(Self { graph, capacities, lower_bounds, source, sink, requirement, - } + }) } pub fn graph(&self) -> &SimpleGraph { @@ -305,8 +282,12 @@ impl Problem for UndirectedFlowLowerBounds { } impl crate::solvers::BruteForceProblem for UndirectedFlowLowerBounds { - fn dimensions(&self) -> Vec { - vec![2; self.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/graph/undirected_two_commodity_integral_flow.rs b/src/models/graph/undirected_two_commodity_integral_flow.rs index ad8a584f1..63183e80d 100644 --- a/src/models/graph/undirected_two_commodity_integral_flow.rs +++ b/src/models/graph/undirected_two_commodity_integral_flow.rs @@ -30,6 +30,7 @@ inventory::submit! { /// - `f2(u, v)` /// - `f2(v, u)` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "UndirectedTwoCommodityIntegralFlowData")] pub struct UndirectedTwoCommodityIntegralFlow { graph: SimpleGraph, capacities: Vec, @@ -41,6 +42,34 @@ pub struct UndirectedTwoCommodityIntegralFlow { requirement_2: i64, } +#[derive(Deserialize)] +struct UndirectedTwoCommodityIntegralFlowData { + graph: SimpleGraph, + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: i64, + requirement_2: i64, +} + +impl TryFrom for UndirectedTwoCommodityIntegralFlow { + type Error = crate::registry::ConstructionError; + fn try_from(data: UndirectedTwoCommodityIntegralFlowData) -> Result { + Self::try_new( + data.graph, + data.capacities, + data.source_1, + data.sink_1, + data.source_2, + data.sink_2, + data.requirement_1, + data.requirement_2, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct UndirectedTwoCommodityIntegralFlowCreateSpec { /// Undirected graph edges. @@ -129,11 +158,33 @@ impl UndirectedTwoCommodityIntegralFlow { requirement_1: i64, requirement_2: i64, ) -> Self { - assert_eq!( - capacities.len(), - graph.num_edges(), - "capacities length must match graph num_edges" - ); + Self::try_new( + graph, + capacities, + source_1, + sink_1, + source_2, + sink_2, + requirement_1, + requirement_2, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + #[allow(clippy::too_many_arguments)] + fn try_new( + graph: SimpleGraph, + capacities: Vec, + source_1: usize, + sink_1: usize, + source_2: usize, + sink_2: usize, + requirement_1: i64, + requirement_2: i64, + ) -> Result { + if capacities.len() != graph.num_edges() { + return Err("capacities length must match graph num_edges".into()); + } let num_vertices = graph.num_vertices(); for (label, vertex) in [ @@ -142,23 +193,18 @@ impl UndirectedTwoCommodityIntegralFlow { ("source_2", source_2), ("sink_2", sink_2), ] { - assert!( - vertex < num_vertices, - "{label} must be less than num_vertices ({num_vertices})" - ); + if !(vertex < num_vertices) { + return Err( + format!("{label} must be less than num_vertices ({num_vertices})").into(), + ); + } } - for &capacity in &capacities { - let domain = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)); - assert!( - domain.is_some(), - "edge capacities must fit into usize for dims()" - ); + if !(capacities.iter().all(|&capacity| capacity >= 0)) { + return Err("capacities must be nonnegative".into()); } - Self { + Ok(Self { graph, capacities, source_1, @@ -167,7 +213,7 @@ impl UndirectedTwoCommodityIntegralFlow { sink_2, requirement_1, requirement_2, - } + }) } pub fn graph(&self) -> &SimpleGraph { @@ -228,13 +274,6 @@ impl UndirectedTwoCommodityIntegralFlow { self.num_edges() * 4 } - fn domain_size(capacity: i64) -> usize { - usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .expect("capacity already validated to fit into usize") - } - fn edge_flows(&self, config: &[usize], edge_index: usize) -> Option<[usize; 4]> { let start = edge_index.checked_mul(4)?; Some([ @@ -403,14 +442,14 @@ impl Problem for UndirectedTwoCommodityIntegralFlow { } impl crate::solvers::BruteForceProblem for UndirectedTwoCommodityIntegralFlow { - fn dimensions(&self) -> Vec { - self.capacities - .iter() - .flat_map(|&capacity| { - let domain = Self::domain_size(capacity); - std::iter::repeat_n(domain, 4) - }) - .collect() + fn num_variables(&self) -> Result { + Ok(4 * self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from( + i128::from(self.capacities[variable / 4]) + 1, + )?) } } diff --git a/src/models/misc/additional_key.rs b/src/models/misc/additional_key.rs index d15016c1b..abe66066c 100644 --- a/src/models/misc/additional_key.rs +++ b/src/models/misc/additional_key.rs @@ -61,6 +61,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "AdditionalKeyData")] pub struct AdditionalKey { num_attributes: usize, dependencies: Vec<(Vec, Vec)>, @@ -68,6 +69,26 @@ pub struct AdditionalKey { known_keys: Vec>, } +#[derive(Deserialize)] +struct AdditionalKeyData { + num_attributes: usize, + dependencies: Vec<(Vec, Vec)>, + relation_attrs: Vec, + known_keys: Vec>, +} + +impl TryFrom for AdditionalKey { + type Error = crate::registry::ConstructionError; + fn try_from(data: AdditionalKeyData) -> Result { + Self::try_new( + data.num_attributes, + data.dependencies, + data.relation_attrs, + data.known_keys, + ) + } +} + impl AdditionalKey { /// Create a new AdditionalKey instance. /// @@ -81,42 +102,58 @@ impl AdditionalKey { relation_attrs: Vec, known_keys: Vec>, ) -> Self { + Self::try_new(num_attributes, dependencies, relation_attrs, known_keys) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_attributes: usize, + dependencies: Vec<(Vec, Vec)>, + relation_attrs: Vec, + known_keys: Vec>, + ) -> Result { // Validate all attribute indices for &a in &relation_attrs { - assert!( - a < num_attributes, - "relation_attrs element {a} >= num_attributes {num_attributes}" - ); + if !(a < num_attributes) { + return Err(format!( + "relation_attrs element {a} >= num_attributes {num_attributes}" + ) + .into()); + } } // Validate relation_attrs uniqueness let mut sorted_ra = relation_attrs.clone(); sorted_ra.sort_unstable(); sorted_ra.dedup(); - assert_eq!( - sorted_ra.len(), - relation_attrs.len(), - "relation_attrs contains duplicates" - ); + if sorted_ra.len() != relation_attrs.len() { + return Err("relation_attrs contains duplicates".into()); + } for (lhs, rhs) in &dependencies { for &a in lhs { - assert!( - a < num_attributes, - "dependency lhs attribute {a} >= num_attributes {num_attributes}" - ); + if !(a < num_attributes) { + return Err(format!( + "dependency lhs attribute {a} >= num_attributes {num_attributes}" + ) + .into()); + } } for &a in rhs { - assert!( - a < num_attributes, - "dependency rhs attribute {a} >= num_attributes {num_attributes}" - ); + if !(a < num_attributes) { + return Err(format!( + "dependency rhs attribute {a} >= num_attributes {num_attributes}" + ) + .into()); + } } } for key in &known_keys { for &a in key { - assert!( - a < num_attributes, - "known_keys attribute {a} >= num_attributes {num_attributes}" - ); + if !(a < num_attributes) { + return Err(format!( + "known_keys attribute {a} >= num_attributes {num_attributes}" + ) + .into()); + } } } // Sort known_keys entries internally for consistent comparison @@ -127,12 +164,12 @@ impl AdditionalKey { k }) .collect(); - Self { + Ok(Self { num_attributes, dependencies, relation_attrs, known_keys, - } + }) } /// Returns the number of attributes in the universal set A. @@ -265,8 +302,12 @@ impl Problem for AdditionalKey { } impl crate::solvers::BruteForceProblem for AdditionalKey { - fn dimensions(&self) -> Vec { - vec![2; self.relation_attrs.len()] + fn num_variables(&self) -> Result { + Ok(self.relation_attrs.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/betweenness.rs b/src/models/misc/betweenness.rs index bf55a1c12..64778ea16 100644 --- a/src/models/misc/betweenness.rs +++ b/src/models/misc/betweenness.rs @@ -170,8 +170,12 @@ impl Problem for Betweenness { } impl crate::solvers::BruteForceProblem for Betweenness { - fn dimensions(&self) -> Vec { - vec![self.num_elements; self.num_elements] + fn num_variables(&self) -> Result { + Ok(self.num_elements) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_elements) } } diff --git a/src/models/misc/bin_packing.rs b/src/models/misc/bin_packing.rs index 0f952f549..d307109c4 100644 --- a/src/models/misc/bin_packing.rs +++ b/src/models/misc/bin_packing.rs @@ -155,9 +155,12 @@ where W: WeightElement + crate::variant::VariantParam, W::Sum: PartialOrd, { - fn dimensions(&self) -> Vec { - let n = self.sizes.len(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.sizes.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.sizes.len()) } } diff --git a/src/models/misc/boyce_codd_normal_form_violation.rs b/src/models/misc/boyce_codd_normal_form_violation.rs index a09631428..c693988c7 100644 --- a/src/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/models/misc/boyce_codd_normal_form_violation.rs @@ -58,6 +58,7 @@ inventory::submit! { /// .unwrap()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "BoyceCoddNormalFormViolationData")] pub struct BoyceCoddNormalFormViolation { /// Total number of attributes (elements are `0..num_attributes`). num_attributes: usize, @@ -67,6 +68,24 @@ pub struct BoyceCoddNormalFormViolation { target_subset: Vec, } +#[derive(Deserialize)] +struct BoyceCoddNormalFormViolationData { + num_attributes: usize, + functional_deps: Vec<(Vec, Vec)>, + target_subset: Vec, +} + +impl TryFrom for BoyceCoddNormalFormViolation { + type Error = crate::registry::ConstructionError; + fn try_from(data: BoyceCoddNormalFormViolationData) -> Result { + Self::try_new( + data.num_attributes, + data.functional_deps, + data.target_subset, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct BoyceCoddNormalFormViolationCreateSpec { /// Total number of attributes in A. @@ -107,7 +126,7 @@ impl TryFrom for BoyceCoddNormalFormViol ) .into()); } - Ok(Self::new(spec.n, spec.subsets, spec.target)) + Self::try_new(spec.n, spec.subsets, spec.target) } } @@ -129,27 +148,32 @@ impl BoyceCoddNormalFormViolation { functional_deps: Vec<(Vec, Vec)>, target_subset: Vec, ) -> Self { - assert!(!target_subset.is_empty(), "target_subset must be non-empty"); + Self::try_new(num_attributes, functional_deps, target_subset) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_attributes: usize, + functional_deps: Vec<(Vec, Vec)>, + target_subset: Vec, + ) -> Result { + if target_subset.is_empty() { + return Err("target_subset must be non-empty".into()); + } let mut functional_deps = functional_deps; for (fd_index, (lhs, rhs)) in functional_deps.iter_mut().enumerate() { - assert!( - !lhs.is_empty(), - "Functional dependency {} has an empty LHS", - fd_index - ); + if lhs.is_empty() { + return Err(format!("Functional dependency {} has an empty LHS", fd_index).into()); + } lhs.sort_unstable(); lhs.dedup(); rhs.sort_unstable(); rhs.dedup(); for &attr in lhs.iter().chain(rhs.iter()) { - assert!( - attr < num_attributes, - "Functional dependency {} contains attribute {} which is out of range (num_attributes = {})", - fd_index, - attr, - num_attributes - ); + if !(attr < num_attributes) { + return Err(format!("Functional dependency {} contains attribute {} which is out of range (num_attributes = {})", fd_index, attr, num_attributes).into()); + } } } @@ -157,19 +181,16 @@ impl BoyceCoddNormalFormViolation { target_subset.sort_unstable(); target_subset.dedup(); for &attr in &target_subset { - assert!( - attr < num_attributes, - "target_subset contains attribute {} which is out of range (num_attributes = {})", - attr, - num_attributes - ); + if !(attr < num_attributes) { + return Err(format!("target_subset contains attribute {} which is out of range (num_attributes = {})", attr, num_attributes).into()); + } } - Self { + Ok(Self { num_attributes, functional_deps, target_subset, - } + }) } /// Return the total number of attributes. @@ -269,8 +290,12 @@ impl Problem for BoyceCoddNormalFormViolation { } impl crate::solvers::BruteForceProblem for BoyceCoddNormalFormViolation { - fn dimensions(&self) -> Vec { - vec![2; self.target_subset.len()] + fn num_variables(&self) -> Result { + Ok(self.target_subset.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/capacity_assignment.rs b/src/models/misc/capacity_assignment.rs index f397cdb4d..7ca9f765f 100644 --- a/src/models/misc/capacity_assignment.rs +++ b/src/models/misc/capacity_assignment.rs @@ -27,6 +27,7 @@ inventory::submit! { /// with respect to the ordered capacity list. The objective is to minimize /// total cost subject to a delay budget constraint. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "CapacityAssignmentCreateSpec")] pub struct CapacityAssignment { capacities: Vec, cost: Vec>, @@ -93,51 +94,53 @@ impl CapacityAssignment { delay: Vec>, delay_budget: i64, ) -> Self { - assert!(!capacities.is_empty(), "capacities must be non-empty"); - assert!( - capacities.iter().all(|&capacity| capacity > 0), - "capacities must be positive" - ); - assert!( - capacities.windows(2).all(|w| w[0] < w[1]), - "capacities must be strictly increasing" - ); - assert_eq!( - cost.len(), - delay.len(), - "cost and delay must have the same number of links" - ); + Self::try_new(capacities, cost, delay, delay_budget) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + capacities: Vec, + cost: Vec>, + delay: Vec>, + delay_budget: i64, + ) -> Result { + if capacities.is_empty() { + return Err("capacities must be non-empty".into()); + } + if !(capacities.iter().all(|&capacity| capacity > 0)) { + return Err("capacities must be positive".into()); + } + if !(capacities.windows(2).all(|w| w[0] < w[1])) { + return Err("capacities must be strictly increasing".into()); + } + if cost.len() != delay.len() { + return Err("cost and delay must have the same number of links".into()); + } let num_capacities = capacities.len(); for (link, row) in cost.iter().enumerate() { - assert_eq!( - row.len(), - num_capacities, - "cost row {link} length must match capacities length" - ); - assert!( - row.windows(2).all(|w| w[0] <= w[1]), - "cost row {link} must be non-decreasing" - ); + if row.len() != num_capacities { + return Err(format!("cost row {link} length must match capacities length").into()); + } + if !(row.windows(2).all(|w| w[0] <= w[1])) { + return Err(format!("cost row {link} must be non-decreasing").into()); + } } for (link, row) in delay.iter().enumerate() { - assert_eq!( - row.len(), - num_capacities, - "delay row {link} length must match capacities length" - ); - assert!( - row.windows(2).all(|w| w[0] >= w[1]), - "delay row {link} must be non-increasing" - ); + if row.len() != num_capacities { + return Err(format!("delay row {link} length must match capacities length").into()); + } + if !(row.windows(2).all(|w| w[0] >= w[1])) { + return Err(format!("delay row {link} must be non-increasing").into()); + } } - Self { + Ok(Self { capacities, cost, delay, delay_budget, - } + }) } /// Number of communication links. @@ -245,8 +248,12 @@ impl Problem for CapacityAssignment { } impl crate::solvers::BruteForceProblem for CapacityAssignment { - fn dimensions(&self) -> Vec { - vec![self.num_capacities(); self.num_links()] + fn num_variables(&self) -> Result { + Ok(self.num_links()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_capacities()) } } diff --git a/src/models/misc/closest_string.rs b/src/models/misc/closest_string.rs index 722d23dfd..2f556d627 100644 --- a/src/models/misc/closest_string.rs +++ b/src/models/misc/closest_string.rs @@ -46,11 +46,26 @@ inventory::submit! { /// syntactically feasible; the objective is its worst-case Hamming distance /// to the input strings. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ClosestStringData")] pub struct ClosestString { alphabet_size: usize, strings: Vec>, } +#[derive(Deserialize)] +struct ClosestStringData { + alphabet_size: usize, + strings: Vec>, +} + +impl TryFrom for ClosestString { + type Error = crate::registry::ConstructionError; + + fn try_from(data: ClosestStringData) -> Result { + Self::try_new(data.alphabet_size, data.strings) + } +} + impl ClosestString { /// Create a new `ClosestString` instance. /// @@ -62,30 +77,34 @@ impl ClosestString { /// - `alphabet_size == 0` while any input string is non-empty, /// - any symbol in any input string is `>= alphabet_size`. pub fn new(alphabet_size: usize, strings: Vec>) -> Self { - assert!( - !strings.is_empty(), - "ClosestString requires at least one input string" - ); + Self::try_new(alphabet_size, strings).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + alphabet_size: usize, + strings: Vec>, + ) -> Result { + if strings.is_empty() { + return Err("ClosestString requires at least one input string".into()); + } let string_length = strings[0].len(); - assert!( - strings.iter().all(|s| s.len() == string_length), - "all input strings must have the same length" - ); - assert!( - alphabet_size > 0 || string_length == 0, - "alphabet_size must be > 0 when input strings are non-empty" - ); - assert!( - strings - .iter() - .flat_map(|s| s.iter()) - .all(|&symbol| symbol < alphabet_size), - "input symbols must be less than alphabet_size" - ); - Self { + if !(strings.iter().all(|s| s.len() == string_length)) { + return Err("all input strings must have the same length".into()); + } + if !(alphabet_size > 0 || string_length == 0) { + return Err("alphabet_size must be > 0 when input strings are non-empty".into()); + } + if !(strings + .iter() + .flat_map(|s| s.iter()) + .all(|&symbol| symbol < alphabet_size)) + { + return Err("input symbols must be less than alphabet_size".into()); + } + Ok(Self { alphabet_size, strings, - } + }) } /// Returns the alphabet size `q`. @@ -169,8 +188,12 @@ impl Problem for ClosestString { } impl crate::solvers::BruteForceProblem for ClosestString { - fn dimensions(&self) -> Vec { - vec![self.alphabet_size; self.string_length()] + fn num_variables(&self) -> Result { + Ok(self.string_length()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.alphabet_size) } } diff --git a/src/models/misc/closest_substring.rs b/src/models/misc/closest_substring.rs index 56a504e5a..04680a18f 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)> { @@ -235,16 +219,22 @@ impl Problem for ClosestSubstring { } impl crate::solvers::BruteForceProblem for ClosestSubstring { - fn dimensions(&self) -> Vec { - let ell = self.substring_length; - let mut dims = vec![self.alphabet_size; ell]; - dims.extend(self.strings.iter().map(|s| s.len() - ell + 1)); - dims + fn num_variables(&self) -> Result { + Ok(self.substring_length + self.strings.len()) + } + + fn dimension(&self, variable: usize) -> Result { + if variable < self.substring_length { + Ok(self.alphabet_size) + } else { + Ok(self.strings[variable - self.substring_length].len() - self.substring_length + 1) + } } } crate::declare_variants! { - default ClosestSubstring => "alphabet_size ^ substring_length * num_window_choice_product", + // AM-GM bounds the product of window counts by their mean raised to num_strings. + default ClosestSubstring => "alphabet_size ^ substring_length * (total_num_windows / num_strings)^num_strings", } crate::register_brute_force! { diff --git a/src/models/misc/clustering.rs b/src/models/misc/clustering.rs index e87998c73..ebb692dfa 100644 --- a/src/models/misc/clustering.rs +++ b/src/models/misc/clustering.rs @@ -59,6 +59,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ClusteringData")] pub struct Clustering { /// Symmetric distance matrix with zero diagonal. distances: Vec>, @@ -68,6 +69,21 @@ pub struct Clustering { diameter_bound: i64, } +#[derive(Deserialize)] +struct ClusteringData { + distances: Vec>, + num_clusters: usize, + diameter_bound: i64, +} + +impl TryFrom for Clustering { + type Error = crate::registry::ConstructionError; + + fn try_from(data: ClusteringData) -> Result { + Self::try_new(data.distances, data.num_clusters, data.diameter_bound) + } +} + impl Clustering { /// Create a new Clustering instance. /// @@ -80,35 +96,46 @@ impl Clustering { /// - diagonal entries are not zero /// - `num_clusters` is zero pub fn new(distances: Vec>, num_clusters: usize, diameter_bound: i64) -> Self { + Self::try_new(distances, num_clusters, diameter_bound) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + distances: Vec>, + num_clusters: usize, + diameter_bound: i64, + ) -> Result { let n = distances.len(); - assert!(n > 0, "Clustering requires at least one element"); - assert!(num_clusters > 0, "num_clusters must be at least 1"); + if !(n > 0) { + return Err("Clustering requires at least one element".into()); + } + if num_clusters == 0 { + return Err("num_clusters must be at least 1".into()); + } for (i, row) in distances.iter().enumerate() { - assert_eq!( - row.len(), - n, - "Distance matrix must be square: row {i} has {} columns, expected {n}", - row.len() - ); - assert_eq!( - distances[i][i], 0, - "Diagonal entry distances[{i}][{i}] must be 0" - ); + if row.len() != n { + return Err(format!( + "Distance matrix must be square: row {i} has {} columns, expected {n}", + row.len() + ) + .into()); + } + if distances[i][i] != 0 { + return Err(format!("Diagonal entry distances[{i}][{i}] must be 0").into()); + } } for (i, row_i) in distances.iter().enumerate() { for j in (i + 1)..n { - assert_eq!( - row_i[j], distances[j][i], - "Distance matrix must be symmetric: distances[{i}][{j}] = {} != distances[{j}][{i}] = {}", - row_i[j], distances[j][i] - ); + if row_i[j] != distances[j][i] { + return Err(format!("Distance matrix must be symmetric: distances[{i}][{j}] = {} != distances[{j}][{i}] = {}", row_i[j], distances[j][i]).into()); + } } } - Self { + Ok(Self { distances, num_clusters, diameter_bound, - } + }) } /// Returns the distance matrix. @@ -192,8 +219,12 @@ impl Problem for Clustering { } impl crate::solvers::BruteForceProblem for Clustering { - fn dimensions(&self) -> Vec { - vec![self.num_clusters; self.num_elements()] + fn num_variables(&self) -> Result { + Ok(self.num_elements()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_clusters) } } diff --git a/src/models/misc/conjunctive_boolean_query.rs b/src/models/misc/conjunctive_boolean_query.rs index a6ce31f50..7d62fdd2d 100644 --- a/src/models/misc/conjunctive_boolean_query.rs +++ b/src/models/misc/conjunctive_boolean_query.rs @@ -76,6 +76,7 @@ pub enum QueryArg { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "ConjunctiveBooleanQueryData")] pub struct ConjunctiveBooleanQuery { domain_size: usize, relations: Vec, @@ -83,6 +84,26 @@ pub struct ConjunctiveBooleanQuery { conjuncts: Vec<(usize, Vec)>, } +#[derive(Deserialize)] +struct ConjunctiveBooleanQueryData { + domain_size: usize, + relations: Vec, + num_variables: usize, + conjuncts: Vec<(usize, Vec)>, +} + +impl TryFrom for ConjunctiveBooleanQuery { + type Error = crate::registry::ConstructionError; + fn try_from(data: ConjunctiveBooleanQueryData) -> Result { + Self::try_new( + data.domain_size, + data.relations, + data.num_variables, + data.conjuncts, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct ConjunctiveBooleanQueryCreateSpec { /// Size of the finite domain. @@ -185,57 +206,73 @@ impl ConjunctiveBooleanQuery { num_variables: usize, conjuncts: Vec<(usize, Vec)>, ) -> Self { + Self::try_new(domain_size, relations, num_variables, conjuncts) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + domain_size: usize, + relations: Vec, + num_variables: usize, + conjuncts: Vec<(usize, Vec)>, + ) -> Result { for (i, rel) in relations.iter().enumerate() { for (j, tuple) in rel.tuples.iter().enumerate() { - assert!( - tuple.len() == rel.arity, - "Relation {i}: tuple {j} has length {}, expected arity {}", - tuple.len(), - rel.arity - ); + if !(tuple.len() == rel.arity) { + return Err(format!( + "Relation {i}: tuple {j} has length {}, expected arity {}", + tuple.len(), + rel.arity + ) + .into()); + } for (k, &val) in tuple.iter().enumerate() { - assert!( - val < domain_size, - "Relation {i}: tuple {j}, entry {k} is {val}, must be < {domain_size}" - ); + if !(val < domain_size) { + return Err(format!( + "Relation {i}: tuple {j}, entry {k} is {val}, must be < {domain_size}" + ) + .into()); + } } } } for (i, (rel_idx, args)) in conjuncts.iter().enumerate() { - assert!( - *rel_idx < relations.len(), - "Conjunct {i}: relation index {rel_idx} out of range (have {} relations)", - relations.len() - ); - assert!( - args.len() == relations[*rel_idx].arity, - "Conjunct {i}: has {} args, expected arity {}", - args.len(), - relations[*rel_idx].arity - ); + if !(*rel_idx < relations.len()) { + return Err(format!( + "Conjunct {i}: relation index {rel_idx} out of range (have {} relations)", + relations.len() + ) + .into()); + } + if !(args.len() == relations[*rel_idx].arity) { + return Err(format!( + "Conjunct {i}: has {} args, expected arity {}", + args.len(), + relations[*rel_idx].arity + ) + .into()); + } for (k, arg) in args.iter().enumerate() { match arg { QueryArg::Variable(v) => { - assert!( - *v < num_variables, - "Conjunct {i}, arg {k}: Variable({v}) >= num_variables ({num_variables})" - ); + if !(*v < num_variables) { + return Err(format!("Conjunct {i}, arg {k}: Variable({v}) >= num_variables ({num_variables})").into()); + } } QueryArg::Constant(c) => { - assert!( - *c < domain_size, - "Conjunct {i}, arg {k}: Constant({c}) >= domain_size ({domain_size})" - ); + if !(*c < domain_size) { + return Err(format!("Conjunct {i}, arg {k}: Constant({c}) >= domain_size ({domain_size})").into()); + } } } } } - Self { + Ok(Self { domain_size, relations, num_variables, conjuncts, - } + }) } /// Returns the size of the finite domain. @@ -317,8 +354,12 @@ impl Problem for ConjunctiveBooleanQuery { } impl crate::solvers::BruteForceProblem for ConjunctiveBooleanQuery { - fn dimensions(&self) -> Vec { - vec![self.domain_size; self.num_variables] + fn num_variables(&self) -> Result { + Ok(self.num_variables) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.domain_size) } } diff --git a/src/models/misc/conjunctive_query_foldability.rs b/src/models/misc/conjunctive_query_foldability.rs index d8f6ed074..c4ee0bd34 100644 --- a/src/models/misc/conjunctive_query_foldability.rs +++ b/src/models/misc/conjunctive_query_foldability.rs @@ -86,6 +86,7 @@ pub enum Term { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ConjunctiveQueryFoldabilityData")] pub struct ConjunctiveQueryFoldability { /// Size of the finite domain D. domain_size: usize, @@ -101,6 +102,30 @@ pub struct ConjunctiveQueryFoldability { query2_conjuncts: Vec<(usize, Vec)>, } +#[derive(Deserialize)] +struct ConjunctiveQueryFoldabilityData { + domain_size: usize, + num_distinguished: usize, + num_undistinguished: usize, + relation_arities: Vec, + query1_conjuncts: Vec<(usize, Vec)>, + query2_conjuncts: Vec<(usize, Vec)>, +} + +impl TryFrom for ConjunctiveQueryFoldability { + type Error = crate::registry::ConstructionError; + fn try_from(data: ConjunctiveQueryFoldabilityData) -> Result { + Self::try_new( + data.domain_size, + data.num_distinguished, + data.num_undistinguished, + data.relation_arities, + data.query1_conjuncts, + data.query2_conjuncts, + ) + } +} + impl ConjunctiveQueryFoldability { /// Create a new `ConjunctiveQueryFoldability` instance. /// @@ -129,6 +154,25 @@ impl ConjunctiveQueryFoldability { query1_conjuncts: Vec<(usize, Vec)>, query2_conjuncts: Vec<(usize, Vec)>, ) -> Self { + Self::try_new( + domain_size, + num_distinguished, + num_undistinguished, + relation_arities, + query1_conjuncts, + query2_conjuncts, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + domain_size: usize, + num_distinguished: usize, + num_undistinguished: usize, + relation_arities: Vec, + query1_conjuncts: Vec<(usize, Vec)>, + query2_conjuncts: Vec<(usize, Vec)>, + ) -> Result { let instance = Self { domain_size, num_distinguished, @@ -137,55 +181,63 @@ impl ConjunctiveQueryFoldability { query1_conjuncts, query2_conjuncts, }; - instance.validate(); - instance + instance.validate()?; + Ok(instance) } - /// Validate the instance, panicking on any inconsistency. - fn validate(&self) { + /// Check relation arities and argument indices. + fn validate(&self) -> Result<(), crate::registry::ConstructionError> { for (query_name, conjuncts) in [ ("Q1", &self.query1_conjuncts), ("Q2", &self.query2_conjuncts), ] { for (atom_idx, (rel_idx, args)) in conjuncts.iter().enumerate() { - assert!( - *rel_idx < self.relation_arities.len(), - "Atom {atom_idx} of {query_name}: relation index {rel_idx} out of range \ + if !(*rel_idx < self.relation_arities.len()) { + return Err(format!( + "Atom {atom_idx} of {query_name}: relation index {rel_idx} out of range \ (num_relations = {})", - self.relation_arities.len() - ); + self.relation_arities.len() + ) + .into()); + }; let arity = self.relation_arities[*rel_idx]; - assert_eq!( - args.len(), - arity, - "Atom {atom_idx} of {query_name}: relation {rel_idx} has arity {arity} \ + if args.len() != arity { + return Err(format!( + "Atom {atom_idx} of {query_name}: relation {rel_idx} has arity {arity} \ but got {} arguments", - args.len() - ); + args.len() + ) + .into()); + }; for term in args { match term { - Term::Constant(i) => assert!( - *i < self.domain_size, - "Atom {atom_idx} of {query_name}: Constant({i}) out of range \ + Term::Constant(i) => { + if !(*i < self.domain_size) { + return Err(format!( + "Atom {atom_idx} of {query_name}: Constant({i}) out of range \ (domain_size = {})", - self.domain_size - ), - Term::Distinguished(i) => assert!( - *i < self.num_distinguished, - "Atom {atom_idx} of {query_name}: Distinguished({i}) out of range \ - (num_distinguished = {})", - self.num_distinguished - ), - Term::Undistinguished(i) => assert!( - *i < self.num_undistinguished, - "Atom {atom_idx} of {query_name}: Undistinguished({i}) out of range \ - (num_undistinguished = {})", - self.num_undistinguished - ), + self.domain_size + ) + .into()); + } + } + Term::Distinguished(i) => { + if !(*i < self.num_distinguished) { + return Err(format!("Atom {atom_idx} of {query_name}: Distinguished({i}) out of range \ + (num_distinguished = {})", self.num_distinguished).into()); + } + } + Term::Undistinguished(i) => { + if !(*i < self.num_undistinguished) { + return Err(format!("Atom {atom_idx} of {query_name}: Undistinguished({i}) out of range \ + (num_undistinguished = {})", self.num_undistinguished).into()); + } + } } } } } + Ok(()) } /// Returns the size of the finite domain D. @@ -325,9 +377,22 @@ impl Problem for ConjunctiveQueryFoldability { impl crate::solvers::BruteForceProblem for ConjunctiveQueryFoldability { /// Each undistinguished variable can map to any element of `D ∪ X ∪ Y`. - fn dimensions(&self) -> Vec { - let range = self.domain_size + self.num_distinguished + self.num_undistinguished; - vec![range; self.num_undistinguished] + fn num_variables(&self) -> Result { + Ok(self.num_undistinguished) + } + + fn dimension(&self, _variable: usize) -> Result { + ((self.domain_size) + .checked_add(self.num_distinguished) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a coordinate cardinality".into(), + ) + })?) + .checked_add(self.num_undistinguished) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/misc/consistency_of_database_frequency_tables.rs b/src/models/misc/consistency_of_database_frequency_tables.rs index b177ed320..9563a84cb 100644 --- a/src/models/misc/consistency_of_database_frequency_tables.rs +++ b/src/models/misc/consistency_of_database_frequency_tables.rs @@ -97,6 +97,7 @@ inventory::submit! { /// The Consistency of Database Frequency Tables decision problem. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ConsistencyOfDatabaseFrequencyTablesData")] pub struct ConsistencyOfDatabaseFrequencyTables { num_objects: usize, attribute_domains: Vec, @@ -104,6 +105,26 @@ pub struct ConsistencyOfDatabaseFrequencyTables { known_values: Vec, } +#[derive(Deserialize)] +struct ConsistencyOfDatabaseFrequencyTablesData { + num_objects: usize, + attribute_domains: Vec, + frequency_tables: Vec, + known_values: Vec, +} + +impl TryFrom for ConsistencyOfDatabaseFrequencyTables { + type Error = crate::registry::ConstructionError; + fn try_from(data: ConsistencyOfDatabaseFrequencyTablesData) -> Result { + Self::try_new( + data.num_objects, + data.attribute_domains, + data.frequency_tables, + data.known_values, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct ConsistencyOfDatabaseFrequencyTablesCreateSpec { /// Number of database objects. @@ -220,20 +241,34 @@ impl ConsistencyOfDatabaseFrequencyTables { frequency_tables: Vec, known_values: Vec, ) -> Self { + Self::try_new( + num_objects, + attribute_domains, + frequency_tables, + known_values, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_objects: usize, + attribute_domains: Vec, + frequency_tables: Vec, + known_values: Vec, + ) -> Result { validate_cdft_create( num_objects, &attribute_domains, &frequency_tables, &known_values, - ) - .unwrap_or_else(|error| panic!("{error}")); + )?; - Self { + Ok(Self { num_objects, attribute_domains, frequency_tables, known_values, - } + }) } /// Returns the number of objects. @@ -261,9 +296,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, or one for the empty attribute list. + pub fn max_domain_size(&self) -> usize { + self.attribute_domains.iter().copied().max().unwrap_or(1) } /// Returns the sum of all attribute-domain sizes. @@ -318,7 +353,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), @@ -386,17 +421,23 @@ impl Problem for ConsistencyOfDatabaseFrequencyTables { } impl crate::solvers::BruteForceProblem for ConsistencyOfDatabaseFrequencyTables { - fn dimensions(&self) -> Vec { - let mut dims = Vec::with_capacity(self.num_assignment_variables()); - for _ in 0..self.num_objects { - dims.extend(self.attribute_domains.iter().copied()); - } - dims + fn num_variables(&self) -> Result { + (self.num_objects) + .checked_mul(self.attribute_domains.len()) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a search coordinate size".into(), + ) + }) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.attribute_domains[variable % self.attribute_domains.len()]) } } crate::declare_variants! { - default ConsistencyOfDatabaseFrequencyTables => "domain_size_product^num_objects" create ConsistencyOfDatabaseFrequencyTablesCreateSpec, + default ConsistencyOfDatabaseFrequencyTables => "max_domain_size^(num_attributes * num_objects)" create ConsistencyOfDatabaseFrequencyTablesCreateSpec, } crate::register_brute_force! { diff --git a/src/models/misc/cosine_product_integration.rs b/src/models/misc/cosine_product_integration.rs index 824e46e09..bdfd218ff 100644 --- a/src/models/misc/cosine_product_integration.rs +++ b/src/models/misc/cosine_product_integration.rs @@ -55,10 +55,24 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "CosineProductIntegrationData")] pub struct CosineProductIntegration { coefficients: Vec, } +#[derive(Deserialize)] +struct CosineProductIntegrationData { + coefficients: Vec, +} + +impl TryFrom for CosineProductIntegration { + type Error = crate::registry::ConstructionError; + + fn try_from(data: CosineProductIntegrationData) -> Result { + Self::try_new(data.coefficients) + } +} + impl CosineProductIntegration { /// Create a new CosineProductIntegration instance. /// @@ -66,11 +80,14 @@ impl CosineProductIntegration { /// /// Panics if `coefficients` is empty. pub fn new(coefficients: Vec) -> Self { - assert!( - !coefficients.is_empty(), - "CosineProductIntegration requires at least one coefficient" - ); - Self { coefficients } + Self::try_new(coefficients).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(coefficients: Vec) -> Result { + if coefficients.is_empty() { + return Err("CosineProductIntegration requires at least one coefficient".into()); + } + Ok(Self { coefficients }) } /// Returns the cosine coefficients. @@ -132,8 +149,12 @@ impl Problem for CosineProductIntegration { } impl crate::solvers::BruteForceProblem for CosineProductIntegration { - fn dimensions(&self) -> Vec { - vec![2; self.num_coefficients()] + fn num_variables(&self) -> Result { + Ok(self.num_coefficients()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/cyclic_ordering.rs b/src/models/misc/cyclic_ordering.rs index 0edaffe5f..14d70565b 100644 --- a/src/models/misc/cyclic_ordering.rs +++ b/src/models/misc/cyclic_ordering.rs @@ -175,8 +175,12 @@ impl Problem for CyclicOrdering { } impl crate::solvers::BruteForceProblem for CyclicOrdering { - fn dimensions(&self) -> Vec { - vec![self.num_elements; self.num_elements] + fn num_variables(&self) -> Result { + Ok(self.num_elements) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_elements) } } diff --git a/src/models/misc/dynamic_storage_allocation.rs b/src/models/misc/dynamic_storage_allocation.rs index 37ccb3c60..81ff0e69d 100644 --- a/src/models/misc/dynamic_storage_allocation.rs +++ b/src/models/misc/dynamic_storage_allocation.rs @@ -172,11 +172,18 @@ impl Problem for DynamicStorageAllocation { } impl crate::solvers::BruteForceProblem for DynamicStorageAllocation { - fn dimensions(&self) -> Vec { - self.items - .iter() - .map(|&(_, _, s)| self.memory_size - s + 1) - .collect() + fn num_variables(&self) -> Result { + Ok(self.items.len()) + } + + fn dimension(&self, variable: usize) -> Result { + (self.memory_size - self.items[variable].2) + .checked_add(1usize) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a search coordinate size".into(), + ) + }) } } diff --git a/src/models/misc/ensemble_computation.rs b/src/models/misc/ensemble_computation.rs index abe93a81c..dd7bb7e15 100644 --- a/src/models/misc/ensemble_computation.rs +++ b/src/models/misc/ensemble_computation.rs @@ -228,8 +228,20 @@ impl Problem for EnsembleComputation { } impl crate::solvers::BruteForceProblem for EnsembleComputation { - fn dimensions(&self) -> Vec { - vec![self.universe_size + self.budget; 2 * self.budget] + fn num_variables(&self) -> Result { + (2usize).checked_mul(self.budget).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.universe_size) + .checked_add(self.budget) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a coordinate cardinality".into(), + ) + }) } } diff --git a/src/models/misc/expected_retrieval_cost.rs b/src/models/misc/expected_retrieval_cost.rs index a904aa39b..a86f3efcd 100644 --- a/src/models/misc/expected_retrieval_cost.rs +++ b/src/models/misc/expected_retrieval_cost.rs @@ -109,6 +109,15 @@ impl ExpectedRetrievalCost { Ok(Some(masses)) } + /// Number of intervening sectors, wrapping around the device. + pub(crate) fn latency_distance(&self, source: usize, target: usize) -> usize { + if source < target { + target - source - 1 + } else { + self.num_sectors - source + target - 1 + } + } + pub fn expected_cost( &self, config: &[usize], @@ -119,17 +128,7 @@ impl ExpectedRetrievalCost { let mut total = 0.0; for source in 0..self.num_sectors { for target in 0..self.num_sectors { - let latency = i64::try_from(latency_distance(self.num_sectors, source, target)) - .map_err(|_| { - crate::traits::EvaluationError::IntegerOverflow( - "converting expected-retrieval latency to i64".to_string(), - ) - })?; - let latency = crate::types::i64_to_exact_f64(latency).map_err(|_| { - crate::traits::EvaluationError::InexactFloatConversion( - "converting expected-retrieval latency to f64".to_string(), - ) - })?; + let latency = self.latency_distance(source, target) as f64; let term = masses[source] * masses[target] * latency; let next = total + term; if !term.is_finite() || !next.is_finite() { @@ -202,16 +201,12 @@ impl Problem for ExpectedRetrievalCost { } impl crate::solvers::BruteForceProblem for ExpectedRetrievalCost { - fn dimensions(&self) -> Vec { - vec![self.num_sectors; self.num_records()] + fn num_variables(&self) -> Result { + Ok(self.num_records()) } -} -fn latency_distance(num_sectors: usize, source: usize, target: usize) -> usize { - if source < target { - target - source - 1 - } else { - num_sectors - source + target - 1 + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_sectors) } } diff --git a/src/models/misc/factoring.rs b/src/models/misc/factoring.rs index eae8f35da..e943c45ba 100644 --- a/src/models/misc/factoring.rs +++ b/src/models/misc/factoring.rs @@ -238,8 +238,14 @@ impl Problem for Factoring { } impl crate::solvers::BruteForceProblem for Factoring { - fn dimensions(&self) -> Vec { - vec![2; self.m + self.n] + fn num_variables(&self) -> Result { + (self.m).checked_add(self.n).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/feasible_register_assignment.rs b/src/models/misc/feasible_register_assignment.rs index 2e485061c..f619e3174 100644 --- a/src/models/misc/feasible_register_assignment.rs +++ b/src/models/misc/feasible_register_assignment.rs @@ -90,15 +90,13 @@ impl<'de> Deserialize<'de> for FeasibleRegisterAssignment { D: Deserializer<'de>, { let data = FeasibleRegisterAssignmentData::deserialize(deserializer)?; - let (dependencies, dependents) = Self::build_adjacency(data.num_vertices, &data.arcs); - Ok(Self { - num_vertices: data.num_vertices, - arcs: data.arcs, - num_registers: data.num_registers, - assignment: data.assignment, - dependencies, - dependents, - }) + Self::try_new( + data.num_vertices, + data.arcs, + data.num_registers, + data.assignment, + ) + .map_err(serde::de::Error::custom) } } @@ -116,47 +114,57 @@ impl FeasibleRegisterAssignment { num_registers: usize, assignment: Vec, ) -> Self { + Self::try_new(num_vertices, arcs, num_registers, assignment) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + num_registers: usize, + assignment: Vec, + ) -> Result { for &(v, u) in &arcs { - assert!( - v < num_vertices && u < num_vertices, - "Arc ({}, {}) out of bounds for {} vertices", - v, - u, - num_vertices - ); - assert!(v != u, "Self-loop ({}, {}) not allowed in a DAG", v, u); + if !(v < num_vertices && u < num_vertices) { + return Err(format!( + "Arc ({}, {}) out of bounds for {} vertices", + v, u, num_vertices + ) + .into()); + }; + if v == u { + return Err(format!("Self-loop ({}, {}) not allowed in a DAG", v, u).into()); + }; } - assert_eq!( - assignment.len(), - num_vertices, - "Assignment length {} does not match num_vertices {}", - assignment.len(), - num_vertices - ); - if num_vertices > 0 { - assert!( - num_registers > 0, - "num_registers must be positive when there are vertices" - ); + if assignment.len() != num_vertices { + return Err(format!( + "Assignment length {} does not match num_vertices {}", + assignment.len(), + num_vertices + ) + .into()); + }; + if num_vertices > 0 && num_registers == 0 { + return Err("num_registers must be positive when there are vertices".into()); } for (v, &r) in assignment.iter().enumerate() { - assert!( - r < num_registers, - "Assignment[{}] = {} is out of bounds for {} registers", - v, - r, - num_registers - ); + if !(r < num_registers) { + return Err(format!( + "Assignment[{}] = {} is out of bounds for {} registers", + v, r, num_registers + ) + .into()); + }; } let (dependencies, dependents) = Self::build_adjacency(num_vertices, &arcs); - Self { + Ok(Self { num_vertices, arcs, num_registers, assignment, dependencies, dependents, - } + }) } /// Build dependency and dependent adjacency lists from arcs. @@ -303,8 +311,12 @@ impl Problem for FeasibleRegisterAssignment { } impl crate::solvers::BruteForceProblem for FeasibleRegisterAssignment { - fn dimensions(&self) -> Vec { - vec![self.num_vertices; self.num_vertices] + fn num_variables(&self) -> Result { + Ok(self.num_vertices) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_vertices) } } diff --git a/src/models/misc/flow_shop_scheduling.rs b/src/models/misc/flow_shop_scheduling.rs index bd9ee27eb..2dfbfb4e3 100644 --- a/src/models/misc/flow_shop_scheduling.rs +++ b/src/models/misc/flow_shop_scheduling.rs @@ -35,7 +35,7 @@ inventory::submit! { /// /// # Representation /// -/// Configurations use Lehmer code encoding with `dims() = [n, n-1, ..., 1]`. +/// Configurations use Lehmer code encoding with `coordinate cardinalities = [n, n-1, ..., 1]`. /// A config `[c_0, c_1, ..., c_{n-1}]` where `c_i < n - i` is decoded by /// maintaining a list of available jobs and picking the `c_i`-th element: /// @@ -58,6 +58,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "FlowShopSchedulingData")] pub struct FlowShopScheduling { /// Number of processors (machines). num_processors: usize, @@ -67,6 +68,21 @@ pub struct FlowShopScheduling { deadline: i64, } +#[derive(Deserialize)] +struct FlowShopSchedulingData { + num_processors: usize, + task_lengths: Vec>, + deadline: i64, +} + +impl TryFrom for FlowShopScheduling { + type Error = crate::registry::ConstructionError; + + fn try_from(data: FlowShopSchedulingData) -> Result { + Self::try_new(data.num_processors, data.task_lengths, data.deadline) + } +} + impl FlowShopScheduling { /// Create a new Flow Shop Scheduling instance. /// @@ -79,26 +95,37 @@ impl FlowShopScheduling { /// # Panics /// Panics if any job does not have exactly `num_processors` tasks. pub fn new(num_processors: usize, task_lengths: Vec>, deadline: i64) -> Self { + Self::try_new(num_processors, task_lengths, deadline) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_processors: usize, + task_lengths: Vec>, + deadline: i64, + ) -> Result { for (j, tasks) in task_lengths.iter().enumerate() { - assert_eq!( - tasks.len(), - num_processors, - "Job {} has {} tasks, expected {}", - j, - tasks.len(), - num_processors - ); + if tasks.len() != num_processors { + return Err(format!( + "Job {} has {} tasks, expected {}", + j, + tasks.len(), + num_processors + ) + .into()); + } } - assert!( - task_lengths.iter().flatten().all(|&length| length >= 0), - "task lengths must be nonnegative" - ); - assert!(deadline >= 0, "deadline must be nonnegative"); - Self { + if !(task_lengths.iter().flatten().all(|&length| length >= 0)) { + return Err("task lengths must be nonnegative".into()); + } + if !(deadline >= 0) { + return Err("deadline must be nonnegative".into()); + } + Ok(Self { num_processors, task_lengths, deadline, - } + }) } /// Get the number of processors. @@ -214,8 +241,12 @@ impl Problem for FlowShopScheduling { } impl crate::solvers::BruteForceProblem for FlowShopScheduling { - fn dimensions(&self) -> Vec { - super::lehmer_dims(self.num_jobs()) + fn num_variables(&self) -> Result { + Ok(self.num_jobs()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.num_jobs() - variable) } } diff --git a/src/models/misc/grouping_by_swapping.rs b/src/models/misc/grouping_by_swapping.rs index 863c0fec6..7ad4a0ac7 100644 --- a/src/models/misc/grouping_by_swapping.rs +++ b/src/models/misc/grouping_by_swapping.rs @@ -27,12 +27,28 @@ inventory::submit! { /// adjacent swap position `i` (swap positions `i` and `i + 1`) or the special /// no-op value `string_len - 1`. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "GroupingBySwappingData")] pub struct GroupingBySwapping { alphabet_size: usize, string: Vec, budget: usize, } +#[derive(Deserialize)] +struct GroupingBySwappingData { + alphabet_size: usize, + string: Vec, + budget: usize, +} + +impl TryFrom for GroupingBySwapping { + type Error = crate::registry::ConstructionError; + + fn try_from(data: GroupingBySwappingData) -> Result { + Self::try_new(data.alphabet_size, data.string, data.budget) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct GroupingBySwappingCreateSpec { /// Optional alphabet size; omitted values are inferred from the string. @@ -93,23 +109,28 @@ impl GroupingBySwapping { /// Panics if the string contains a symbol outside the declared alphabet, /// or if the string is empty while the budget is positive. pub fn new(alphabet_size: usize, string: Vec, budget: usize) -> Self { - assert!( - alphabet_size > 0 || string.is_empty(), - "alphabet_size must be > 0 when string is non-empty" - ); - assert!( - string.iter().all(|&symbol| symbol < alphabet_size), - "input symbols must be less than alphabet_size" - ); - assert!( - !string.is_empty() || budget == 0, - "budget must be 0 when string is empty" - ); - Self { + Self::try_new(alphabet_size, string, budget).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + alphabet_size: usize, + string: Vec, + budget: usize, + ) -> Result { + if !(alphabet_size > 0 || string.is_empty()) { + return Err("alphabet_size must be > 0 when string is non-empty".into()); + } + if !(string.iter().all(|&symbol| symbol < alphabet_size)) { + return Err("input symbols must be less than alphabet_size".into()); + } + if !(!string.is_empty() || budget == 0) { + return Err("budget must be 0 when string is empty".into()); + } + Ok(Self { alphabet_size, string, budget, - } + }) } /// Returns the alphabet size. @@ -227,8 +248,12 @@ impl Problem for GroupingBySwapping { } impl crate::solvers::BruteForceProblem for GroupingBySwapping { - fn dimensions(&self) -> Vec { - vec![self.string_len(); self.budget] + fn num_variables(&self) -> Result { + Ok(self.budget) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.string_len()) } } diff --git a/src/models/misc/integer_expression_membership.rs b/src/models/misc/integer_expression_membership.rs index e526e32b3..e10b7af47 100644 --- a/src/models/misc/integer_expression_membership.rs +++ b/src/models/misc/integer_expression_membership.rs @@ -155,6 +155,7 @@ impl IntExpr { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "IntegerExpressionMembershipData")] pub struct IntegerExpressionMembership { /// The recursive expression tree. expression: IntExpr, @@ -162,6 +163,20 @@ pub struct IntegerExpressionMembership { target: i64, } +#[derive(Deserialize)] +struct IntegerExpressionMembershipData { + expression: IntExpr, + target: i64, +} + +impl TryFrom for IntegerExpressionMembership { + type Error = crate::registry::ConstructionError; + + fn try_from(data: IntegerExpressionMembershipData) -> Result { + Self::try_new(data.expression, data.target) + } +} + impl IntegerExpressionMembership { /// Create a new IntegerExpressionMembership instance. /// @@ -169,12 +184,20 @@ impl IntegerExpressionMembership { /// * `expression` - The integer expression tree /// * `target` - The target integer K pub fn new(expression: IntExpr, target: i64) -> Self { - assert!(target > 0, "target must be a positive integer (got 0)"); - assert!( - expression.all_atoms_positive(), - "all Atom values must be positive (> 0)" - ); - Self { expression, target } + Self::try_new(expression, target).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + expression: IntExpr, + target: i64, + ) -> Result { + if target <= 0 { + return Err("target must be a positive integer (got 0)".into()); + } + if !(expression.all_atoms_positive()) { + return Err("all Atom values must be positive (> 0)".into()); + } + Ok(Self { expression, target }) } /// Returns a reference to the expression tree. @@ -245,8 +268,12 @@ impl Problem for IntegerExpressionMembership { } impl crate::solvers::BruteForceProblem for IntegerExpressionMembership { - fn dimensions(&self) -> Vec { - vec![2; self.num_union_nodes()] + fn num_variables(&self) -> Result { + Ok(self.num_union_nodes()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/job_shop_scheduling.rs b/src/models/misc/job_shop_scheduling.rs index 2be0b6f7e..4b576287d 100644 --- a/src/models/misc/job_shop_scheduling.rs +++ b/src/models/misc/job_shop_scheduling.rs @@ -25,11 +25,26 @@ inventory::submit! { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "JobShopSchedulingData")] pub struct JobShopScheduling { num_processors: usize, jobs: Vec>, } +#[derive(Deserialize)] +struct JobShopSchedulingData { + num_processors: usize, + jobs: Vec>, +} + +impl TryFrom for JobShopScheduling { + type Error = crate::registry::ConstructionError; + + fn try_from(data: JobShopSchedulingData) -> Result { + Self::try_new(data.num_processors, data.jobs) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct JobShopSchedulingCreateSpec { /// Jobs expressed as ordered processor-duration operations. @@ -97,40 +112,42 @@ struct FlattenedTasks { impl JobShopScheduling { pub fn new(num_processors: usize, jobs: Vec>) -> Self { - let num_tasks: usize = jobs.iter().map(Vec::len).sum(); - if num_tasks > 0 { - assert!( - num_processors > 0, - "num_processors must be positive when tasks are present" - ); + Self::try_new(num_processors, jobs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_processors: usize, + jobs: Vec>, + ) -> Result { + if jobs.iter().any(|job| !job.is_empty()) && !(num_processors > 0) { + return Err("num_processors must be positive when tasks are present".into()); + } + if !(jobs.iter().flatten().all(|&(_, length)| length >= 0)) { + return Err("operation lengths must be nonnegative".into()); } - assert!( - jobs.iter().flatten().all(|&(_, length)| length >= 0), - "operation lengths must be nonnegative" - ); for (job_index, job) in jobs.iter().enumerate() { for (task_index, &(processor, _length)) in job.iter().enumerate() { - assert!( - processor < num_processors, - "job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}" - ); + if !(processor < num_processors) { + return Err(format!("job {job_index} task {task_index} uses processor {processor}, but num_processors = {num_processors}").into()); + } } for (task_index, pair) in job.windows(2).enumerate() { - assert_ne!( - pair[0].0, - pair[1].0, - "job {job_index} tasks {task_index} and {} must use different processors", - task_index + 1 - ); + if pair[0].0 == pair[1].0 { + return Err(format!( + "job {job_index} tasks {task_index} and {} must use different processors", + task_index + 1 + ) + .into()); + } } } - Self { + Ok(Self { num_processors, jobs, - } + }) } pub fn num_processors(&self) -> usize { @@ -317,12 +334,25 @@ impl Problem for JobShopScheduling { } impl crate::solvers::BruteForceProblem for JobShopScheduling { - fn dimensions(&self) -> Vec { - self.flatten_tasks() - .machine_task_ids - .into_iter() - .flat_map(|machine_tasks| super::lehmer_dims(machine_tasks.len())) - .collect() + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + let mut offset = variable; + for processor in 0..self.num_processors { + let count = self + .jobs + .iter() + .flatten() + .filter(|&&(machine, _)| machine == processor) + .count(); + if offset < count { + return Ok(count - offset); + } + offset -= count; + } + unreachable!("coordinate index is in range") } } diff --git a/src/models/misc/knapsack.rs b/src/models/misc/knapsack.rs index 95d7fd023..d52a5094b 100644 --- a/src/models/misc/knapsack.rs +++ b/src/models/misc/knapsack.rs @@ -44,15 +44,28 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "KnapsackData")] pub struct Knapsack { - #[serde(deserialize_with = "nonnegative_i64_vec::deserialize")] weights: Vec, - #[serde(deserialize_with = "nonnegative_i64_vec::deserialize")] values: Vec, - #[serde(deserialize_with = "nonnegative_i64::deserialize")] capacity: i64, } +#[derive(Deserialize)] +struct KnapsackData { + weights: Vec, + values: Vec, + capacity: i64, +} + +impl TryFrom for Knapsack { + type Error = crate::registry::ConstructionError; + + fn try_from(data: KnapsackData) -> Result { + Self::try_new(data.weights, data.values, data.capacity) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct KnapsackCreateSpec { /// Nonnegative item weights; defaults to one per value. @@ -78,7 +91,7 @@ impl TryFrom for Knapsack { .to_string() .into()); } - Ok(Self::new(weights, spec.values, spec.capacity)) + Self::try_new(weights, spec.values, spec.capacity) } } @@ -89,25 +102,31 @@ impl Knapsack { /// Panics if `weights` and `values` have different lengths, or if any /// weight, value, or the capacity is negative. pub fn new(weights: Vec, values: Vec, capacity: i64) -> Self { - assert_eq!( - weights.len(), - values.len(), - "weights and values must have the same length" - ); - assert!( - weights.iter().all(|&weight| weight >= 0), - "Knapsack weights must be nonnegative" - ); - assert!( - values.iter().all(|&value| value >= 0), - "Knapsack values must be nonnegative" - ); - assert!(capacity >= 0, "Knapsack capacity must be nonnegative"); - Self { + Self::try_new(weights, values, capacity).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + weights: Vec, + values: Vec, + capacity: i64, + ) -> Result { + if weights.len() != values.len() { + return Err("weights and values must have the same length".into()); + } + if !(weights.iter().all(|&weight| weight >= 0)) { + return Err("Knapsack weights must be nonnegative".into()); + } + if !(values.iter().all(|&value| value >= 0)) { + return Err("Knapsack values must be nonnegative".into()); + } + if !(capacity >= 0) { + return Err("Knapsack capacity must be nonnegative".into()); + } + Ok(Self { weights, values, capacity, - } + }) } /// Returns the item weights. @@ -197,8 +216,12 @@ impl Problem for Knapsack { } impl crate::solvers::BruteForceProblem for Knapsack { - fn dimensions(&self) -> Vec { - vec![2; self.num_items()] + fn num_variables(&self) -> Result { + Ok(self.num_items()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -210,42 +233,6 @@ crate::register_brute_force! { Knapsack decode |_, indices: Vec| crate::config::config_to_bits(&indices), } -mod nonnegative_i64 { - use serde::de::Error; - use serde::{Deserialize, Deserializer}; - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = i64::deserialize(deserializer)?; - if value < 0 { - return Err(D::Error::custom(format!( - "expected nonnegative integer, got {value}" - ))); - } - Ok(value) - } -} - -mod nonnegative_i64_vec { - use serde::de::Error; - use serde::{Deserialize, Deserializer}; - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let values = Vec::::deserialize(deserializer)?; - if let Some(value) = values.iter().copied().find(|value| *value < 0) { - return Err(D::Error::custom(format!( - "expected nonnegative integers, got {value}" - ))); - } - Ok(values) - } -} - #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { // 4 items: weights [2,3,4,5], values [3,4,5,7], capacity 7 diff --git a/src/models/misc/kth_largest_m_tuple.rs b/src/models/misc/kth_largest_m_tuple.rs index 8f6137c42..1bee02dae 100644 --- a/src/models/misc/kth_largest_m_tuple.rs +++ b/src/models/misc/kth_largest_m_tuple.rs @@ -141,12 +141,9 @@ impl KthLargestMTuple { self.sets.len() } - /// 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") + /// Returns the total number of elements across the input sets. + pub fn num_elements(&self) -> usize { + self.sets.iter().map(Vec::len).sum() } fn has_at_least_k_qualifying_tuples(&self) -> Result { @@ -208,7 +205,7 @@ impl Problem for KthLargestMTuple { type Solution = (); type Value = Or; - crate::problem_parameters![("num_sets", num_sets), ("total_tuples", total_tuples),]; + crate::problem_parameters![("num_sets", num_sets), ("num_elements", num_elements),]; fn variant() -> Vec<(&'static str, &'static str)> { crate::variant_params![] @@ -220,15 +217,19 @@ impl Problem for KthLargestMTuple { } impl crate::solvers::BruteForceProblem for KthLargestMTuple { - fn dimensions(&self) -> Vec { - vec![] + fn num_variables(&self) -> Result { + Ok(0) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(0) } } -// Best known: brute-force enumeration of all tuples, O(total_tuples * num_sets). +// Best known: brute-force enumeration of all tuples, O(product_i |X_i| * num_sets), bounded by AM-GM. // No sub-exponential exact algorithm is known for the general case. crate::declare_variants! { - default KthLargestMTuple => "total_tuples * num_sets" create KthLargestMTupleCreateSpec, + default KthLargestMTuple => "(num_elements / num_sets)^num_sets * num_sets" create KthLargestMTupleCreateSpec, } crate::register_brute_force! { diff --git a/src/models/misc/longest_common_subsequence.rs b/src/models/misc/longest_common_subsequence.rs index 15bb0c0d2..19ba82846 100644 --- a/src/models/misc/longest_common_subsequence.rs +++ b/src/models/misc/longest_common_subsequence.rs @@ -36,12 +36,27 @@ inventory::submit! { /// subsequence consists of the symbols before padding starts. The objective is /// to maximize the effective length. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "LongestCommonSubsequenceData")] pub struct LongestCommonSubsequence { alphabet_size: usize, strings: Vec>, max_length: usize, } +#[derive(Deserialize)] +struct LongestCommonSubsequenceData { + alphabet_size: usize, + strings: Vec>, +} + +impl TryFrom for LongestCommonSubsequence { + type Error = crate::registry::ConstructionError; + + fn try_from(data: LongestCommonSubsequenceData) -> Result { + Self::try_new(data.alphabet_size, data.strings) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct LongestCommonSubsequenceCreateSpec { /// Optional alphabet size; omitted values are inferred from the strings. @@ -104,23 +119,29 @@ impl LongestCommonSubsequence { /// an input symbol is outside the declared alphabet, or if all strings are /// empty (max_length would be 0, requiring at least one non-empty string). pub fn new(alphabet_size: usize, strings: Vec>) -> Self { + Self::try_new(alphabet_size, strings).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + alphabet_size: usize, + strings: Vec>, + ) -> Result { let max_length = strings.iter().map(|s| s.len()).min().unwrap_or(0); - assert!( - alphabet_size > 0 || strings.iter().all(|s| s.is_empty()), - "alphabet_size must be > 0 when any input string is non-empty" - ); - assert!( - strings - .iter() - .flat_map(|s| s.iter()) - .all(|&symbol| symbol < alphabet_size), - "input symbols must be less than alphabet_size" - ); - Self { + if !(alphabet_size > 0 || strings.iter().all(|s| s.is_empty())) { + return Err("alphabet_size must be > 0 when any input string is non-empty".into()); + } + if !(strings + .iter() + .flat_map(|s| s.iter()) + .all(|&symbol| symbol < alphabet_size)) + { + return Err("input symbols must be less than alphabet_size".into()); + } + Ok(Self { alphabet_size, strings, max_length, - } + }) } /// Returns the alphabet size. @@ -274,8 +295,14 @@ impl Problem for LongestCommonSubsequence { } impl crate::solvers::BruteForceProblem for LongestCommonSubsequence { - fn dimensions(&self) -> Vec { - vec![self.alphabet_size + 1; self.max_length] + fn num_variables(&self) -> Result { + Ok(self.max_length) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.alphabet_size).checked_add(1usize).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/misc/maximum_likelihood_ranking.rs b/src/models/misc/maximum_likelihood_ranking.rs index 6483aa1a8..30fa85d3f 100644 --- a/src/models/misc/maximum_likelihood_ranking.rs +++ b/src/models/misc/maximum_likelihood_ranking.rs @@ -55,10 +55,23 @@ 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 +80,44 @@ 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 = i128::from(entry) + i128::from(matrix[j][i]); 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. @@ -182,9 +202,12 @@ impl Problem for MaximumLikelihoodRanking { } impl crate::solvers::BruteForceProblem for MaximumLikelihoodRanking { - fn dimensions(&self) -> Vec { - let n = self.num_items(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.num_items()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_items()) } } diff --git a/src/models/misc/minimum_axiom_set.rs b/src/models/misc/minimum_axiom_set.rs index cbbe0fd03..2492ba4e0 100644 --- a/src/models/misc/minimum_axiom_set.rs +++ b/src/models/misc/minimum_axiom_set.rs @@ -63,6 +63,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumAxiomSetData")] pub struct MinimumAxiomSet { /// Total number of sentences |S|. num_sentences: usize, @@ -72,6 +73,20 @@ pub struct MinimumAxiomSet { implications: Vec<(Vec, usize)>, } +#[derive(Deserialize)] +struct MinimumAxiomSetData { + num_sentences: usize, + true_sentences: Vec, + implications: Vec<(Vec, usize)>, +} + +impl TryFrom for MinimumAxiomSet { + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumAxiomSetData) -> Result { + Self::try_new(data.num_sentences, data.true_sentences, data.implications) + } +} + impl MinimumAxiomSet { /// Create a new Minimum Axiom Set instance. /// @@ -85,37 +100,53 @@ impl MinimumAxiomSet { true_sentences: Vec, implications: Vec<(Vec, usize)>, ) -> Self { + Self::try_new(num_sentences, true_sentences, implications) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_sentences: usize, + true_sentences: Vec, + implications: Vec<(Vec, usize)>, + ) -> Result { // Validate true sentences for &s in &true_sentences { - assert!( - s < num_sentences, - "True sentence index {s} out of range [0, {num_sentences})" - ); + if !(s < num_sentences) { + return Err( + format!("True sentence index {s} out of range [0, {num_sentences})").into(), + ); + } } // Check no duplicates let mut seen = vec![false; num_sentences]; for &s in &true_sentences { - assert!(!seen[s], "Duplicate true sentence index {s}"); + if !(!seen[s]) { + return Err(format!("Duplicate true sentence index {s}").into()); + } seen[s] = true; } // Validate implications for (antecedents, consequent) in &implications { for &a in antecedents { - assert!( - a < num_sentences, - "Implication antecedent {a} out of range [0, {num_sentences})" - ); + if !(a < num_sentences) { + return Err(format!( + "Implication antecedent {a} out of range [0, {num_sentences})" + ) + .into()); + } + } + if !(*consequent < num_sentences) { + return Err(format!( + "Implication consequent {consequent} out of range [0, {num_sentences})" + ) + .into()); } - assert!( - *consequent < num_sentences, - "Implication consequent {consequent} out of range [0, {num_sentences})" - ); } - Self { + Ok(Self { num_sentences, true_sentences, implications, - } + }) } /// Returns the total number of sentences |S|. @@ -217,8 +248,12 @@ impl Problem for MinimumAxiomSet { } impl crate::solvers::BruteForceProblem for MinimumAxiomSet { - fn dimensions(&self) -> Vec { - vec![2; self.num_true_sentences()] + fn num_variables(&self) -> Result { + Ok(self.num_true_sentences()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/minimum_code_generation_one_register.rs b/src/models/misc/minimum_code_generation_one_register.rs index c0b1c6d4c..00de85369 100644 --- a/src/models/misc/minimum_code_generation_one_register.rs +++ b/src/models/misc/minimum_code_generation_one_register.rs @@ -57,6 +57,7 @@ inventory::submit! { /// assert_eq!(problem.evaluate(&solution).unwrap(), Min(Some(8))); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumCodeGenerationOneRegisterData")] pub struct MinimumCodeGenerationOneRegister { /// Number of vertices |V|. num_vertices: usize, @@ -66,6 +67,20 @@ pub struct MinimumCodeGenerationOneRegister { num_leaves: usize, } +#[derive(Deserialize)] +struct MinimumCodeGenerationOneRegisterData { + num_vertices: usize, + edges: Vec<(usize, usize)>, + num_leaves: usize, +} + +impl TryFrom for MinimumCodeGenerationOneRegister { + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumCodeGenerationOneRegisterData) -> Result { + Self::try_new(data.num_vertices, data.edges, data.num_leaves) + } +} + impl MinimumCodeGenerationOneRegister { /// Create a new instance. /// @@ -80,36 +95,50 @@ impl MinimumCodeGenerationOneRegister { /// Panics if any edge index is out of bounds, if any vertex has /// out-degree > 2, or if `num_leaves > num_vertices`. pub fn new(num_vertices: usize, edges: Vec<(usize, usize)>, num_leaves: usize) -> Self { - assert!( - num_leaves <= num_vertices, - "num_leaves ({num_leaves}) exceeds num_vertices ({num_vertices})" - ); + Self::try_new(num_vertices, edges, num_leaves).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_vertices: usize, + edges: Vec<(usize, usize)>, + num_leaves: usize, + ) -> Result { + if !(num_leaves <= num_vertices) { + return Err( + format!("num_leaves ({num_leaves}) exceeds num_vertices ({num_vertices})").into(), + ); + } let mut out_degree = vec![0usize; num_vertices]; for &(parent, child) in &edges { - assert!( - parent < num_vertices && child < num_vertices, - "Edge ({parent}, {child}) out of bounds for {num_vertices} vertices" - ); - assert!( - parent != child, - "Self-loop ({parent}, {parent}) not allowed" - ); + if !(parent < num_vertices && child < num_vertices) { + return Err(format!( + "Edge ({parent}, {child}) out of bounds for {num_vertices} vertices" + ) + .into()); + } + if parent == child { + return Err(format!("Self-loop ({parent}, {parent}) not allowed").into()); + } out_degree[parent] += 1; } for (v, °) in out_degree.iter().enumerate() { - assert!(deg <= 2, "Vertex {v} has out-degree {deg} > 2"); + if !(deg <= 2) { + return Err(format!("Vertex {v} has out-degree {deg} > 2").into()); + } } // Verify leaf count: leaves are vertices with out-degree 0 let actual_leaves = out_degree.iter().filter(|&&d| d == 0).count(); - assert_eq!( - actual_leaves, num_leaves, - "Declared num_leaves ({num_leaves}) != actual leaf count ({actual_leaves})" - ); - Self { + if actual_leaves != num_leaves { + return Err(format!( + "Declared num_leaves ({num_leaves}) != actual leaf count ({actual_leaves})" + ) + .into()); + } + Ok(Self { num_vertices, edges, num_leaves, - } + }) } /// Get the number of vertices. @@ -338,9 +367,12 @@ impl Problem for MinimumCodeGenerationOneRegister { } impl crate::solvers::BruteForceProblem for MinimumCodeGenerationOneRegister { - fn dimensions(&self) -> Vec { - let n_internal = self.num_internal(); - vec![n_internal; n_internal] + fn num_variables(&self) -> Result { + Ok(self.num_internal()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_internal()) } } diff --git a/src/models/misc/minimum_code_generation_parallel_assignments.rs b/src/models/misc/minimum_code_generation_parallel_assignments.rs index 405f7a962..074f1b788 100644 --- a/src/models/misc/minimum_code_generation_parallel_assignments.rs +++ b/src/models/misc/minimum_code_generation_parallel_assignments.rs @@ -56,33 +56,60 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumCodeGenerationParallelAssignmentsData")] pub struct MinimumCodeGenerationParallelAssignments { num_variables: usize, assignments: Vec<(usize, Vec)>, } +#[derive(Deserialize)] +struct MinimumCodeGenerationParallelAssignmentsData { + num_variables: usize, + assignments: Vec<(usize, Vec)>, +} + +impl TryFrom + for MinimumCodeGenerationParallelAssignments +{ + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumCodeGenerationParallelAssignmentsData) -> Result { + Self::try_new(data.num_variables, data.assignments) + } +} + impl MinimumCodeGenerationParallelAssignments { /// Create a new MinimumCodeGenerationParallelAssignments instance. /// /// # Panics /// Panics if any target variable or read variable index is >= num_variables. pub fn new(num_variables: usize, assignments: Vec<(usize, Vec)>) -> Self { + Self::try_new(num_variables, assignments).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_variables: usize, + assignments: Vec<(usize, Vec)>, + ) -> Result { for (i, (target, reads)) in assignments.iter().enumerate() { - assert!( - *target < num_variables, - "assignment {i}: target variable {target} >= num_variables {num_variables}" - ); + if !(*target < num_variables) { + return Err(format!( + "assignment {i}: target variable {target} >= num_variables {num_variables}" + ) + .into()); + } for &r in reads { - assert!( - r < num_variables, - "assignment {i}: read variable {r} >= num_variables {num_variables}" - ); + if !(r < num_variables) { + return Err(format!( + "assignment {i}: read variable {r} >= num_variables {num_variables}" + ) + .into()); + } } } - Self { + Ok(Self { num_variables, assignments, - } + }) } /// Returns the number of variables. @@ -175,9 +202,12 @@ impl Problem for MinimumCodeGenerationParallelAssignments { } impl crate::solvers::BruteForceProblem for MinimumCodeGenerationParallelAssignments { - fn dimensions(&self) -> Vec { - let m = self.num_assignments(); - vec![m; m] + fn num_variables(&self) -> Result { + Ok(self.num_assignments()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_assignments()) } } diff --git a/src/models/misc/minimum_code_generation_unlimited_registers.rs b/src/models/misc/minimum_code_generation_unlimited_registers.rs index fbf5f84ee..c9851f76d 100644 --- a/src/models/misc/minimum_code_generation_unlimited_registers.rs +++ b/src/models/misc/minimum_code_generation_unlimited_registers.rs @@ -62,6 +62,7 @@ inventory::submit! { /// assert_eq!(problem.evaluate(&solution).unwrap(), Min(Some(4))); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumCodeGenerationUnlimitedRegistersData")] pub struct MinimumCodeGenerationUnlimitedRegisters { /// Number of vertices |V|. num_vertices: usize, @@ -71,6 +72,22 @@ pub struct MinimumCodeGenerationUnlimitedRegisters { right_arcs: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct MinimumCodeGenerationUnlimitedRegistersData { + num_vertices: usize, + left_arcs: Vec<(usize, usize)>, + right_arcs: Vec<(usize, usize)>, +} + +impl TryFrom + for MinimumCodeGenerationUnlimitedRegisters +{ + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumCodeGenerationUnlimitedRegistersData) -> Result { + Self::try_new(data.num_vertices, data.left_arcs, data.right_arcs) + } +} + impl MinimumCodeGenerationUnlimitedRegisters { /// Create a new instance. /// @@ -90,56 +107,67 @@ impl MinimumCodeGenerationUnlimitedRegisters { left_arcs: Vec<(usize, usize)>, right_arcs: Vec<(usize, usize)>, ) -> Self { + Self::try_new(num_vertices, left_arcs, right_arcs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_vertices: usize, + left_arcs: Vec<(usize, usize)>, + right_arcs: Vec<(usize, usize)>, + ) -> Result { let mut left_count = vec![0usize; num_vertices]; let mut right_count = vec![0usize; num_vertices]; for &(parent, child) in &left_arcs { - assert!( - parent < num_vertices && child < num_vertices, - "Left arc ({parent}, {child}) out of bounds for {num_vertices} vertices" - ); - assert!( - parent != child, - "Self-loop ({parent}, {parent}) not allowed" - ); + if !(parent < num_vertices && child < num_vertices) { + return Err(format!( + "Left arc ({parent}, {child}) out of bounds for {num_vertices} vertices" + ) + .into()); + } + if parent == child { + return Err(format!("Self-loop ({parent}, {parent}) not allowed").into()); + } left_count[parent] += 1; } for &(parent, child) in &right_arcs { - assert!( - parent < num_vertices && child < num_vertices, - "Right arc ({parent}, {child}) out of bounds for {num_vertices} vertices" - ); - assert!( - parent != child, - "Self-loop ({parent}, {parent}) not allowed" - ); + if !(parent < num_vertices && child < num_vertices) { + return Err(format!( + "Right arc ({parent}, {child}) out of bounds for {num_vertices} vertices" + ) + .into()); + } + if parent == child { + return Err(format!("Self-loop ({parent}, {parent}) not allowed").into()); + } right_count[parent] += 1; } for v in 0..num_vertices { let out = left_count[v] + right_count[v]; - assert!(out <= 2, "Vertex {v} has out-degree {out} > 2"); + if !(out <= 2) { + return Err(format!("Vertex {v} has out-degree {out} > 2").into()); + } // Binary vertex: exactly one left and one right - if out == 2 { - assert!( - left_count[v] == 1 && right_count[v] == 1, - "Binary vertex {v} must have exactly 1 left and 1 right arc" + if out == 2 && !(left_count[v] == 1 && right_count[v] == 1) { + return Err( + format!("Binary vertex {v} must have exactly 1 left and 1 right arc").into(), ); } // Unary vertex: one left arc (result overwrites operand register) - if out == 1 { - assert!( - left_count[v] == 1 && right_count[v] == 0, + if out == 1 && !(left_count[v] == 1 && right_count[v] == 0) { + return Err(format!( "Unary vertex {v} must have exactly 1 left arc and 0 right arcs" - ); + ) + .into()); } } - Self { + Ok(Self { num_vertices, left_arcs, right_arcs, - } + }) } /// Get the number of vertices. @@ -364,9 +392,12 @@ 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] + fn num_variables(&self) -> Result { + Ok(self.num_internal()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_internal()) } } diff --git a/src/models/misc/minimum_decision_tree.rs b/src/models/misc/minimum_decision_tree.rs index da7297a4e..791d8c659 100644 --- a/src/models/misc/minimum_decision_tree.rs +++ b/src/models/misc/minimum_decision_tree.rs @@ -50,6 +50,7 @@ inventory::submit! { /// let value = solver.solve(&problem).unwrap(); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumDecisionTreeCreateSpec")] pub struct MinimumDecisionTree { /// Binary matrix: test_matrix[j][i] = true iff object i passes test j. test_matrix: Vec>, @@ -73,38 +74,7 @@ struct MinimumDecisionTreeCreateSpec { impl TryFrom for MinimumDecisionTree { type Error = crate::registry::ConstructionError; fn try_from(spec: MinimumDecisionTreeCreateSpec) -> Result { - if spec.num_objects < 2 { - return Err("num_objects must be at least 2".into()); - } - if spec.num_tests == 0 { - return Err("num_tests must be positive".into()); - } - if spec.test_matrix.len() != spec.num_tests { - return Err("test_matrix row count must equal num_tests".into()); - } - if spec - .test_matrix - .iter() - .any(|row| row.len() != spec.num_objects) - { - return Err("each test_matrix row must have num_objects columns".into()); - } - for a in 0..spec.num_objects { - for b in a + 1..spec.num_objects { - if !(0..spec.num_tests) - .any(|test| spec.test_matrix[test][a] != spec.test_matrix[test][b]) - { - return Err( - format!("objects {a} and {b} are not distinguished by any test").into(), - ); - } - } - } - Ok(Self { - test_matrix: spec.test_matrix, - num_objects: spec.num_objects, - num_tests: spec.num_tests, - }) + Self::try_new(spec.test_matrix, spec.num_objects, spec.num_tests) } } @@ -116,35 +86,44 @@ impl MinimumDecisionTree { /// - 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 { - assert!(num_objects >= 2, "Need at least 2 objects"); - assert!(num_tests >= 1, "Need at least 1 test"); - assert_eq!( - test_matrix.len(), - num_tests, - "test_matrix must have num_tests rows" - ); + Self::try_new(test_matrix, num_objects, num_tests).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + test_matrix: Vec>, + num_objects: usize, + num_tests: usize, + ) -> Result { + if !(num_objects >= 2) { + return Err("Need at least 2 objects".into()); + } + if num_tests == 0 { + return Err("Need at least 1 test".into()); + } + if test_matrix.len() != num_tests { + return Err("test_matrix must have num_tests rows".into()); + } for (j, row) in test_matrix.iter().enumerate() { - assert_eq!( - row.len(), - num_objects, - "test_matrix[{j}] must have num_objects columns" - ); + if row.len() != num_objects { + return Err(format!("test_matrix[{j}] must have num_objects columns").into()); + } } // Check that every pair of objects is distinguished by at least one test for a in 0..num_objects { for b in (a + 1)..num_objects { let distinguished = (0..num_tests).any(|j| test_matrix[j][a] != test_matrix[j][b]); - assert!( - distinguished, - "Objects {a} and {b} are not distinguished by any test" - ); + if !(distinguished) { + return Err( + format!("Objects {a} and {b} are not distinguished by any test").into(), + ); + } } } - Self { + Ok(Self { test_matrix, num_objects, num_tests, - } + }) } /// Get the number of objects. @@ -163,8 +142,17 @@ impl MinimumDecisionTree { } /// Number of internal node slots in the flattened complete binary tree. - fn num_tree_slots(&self) -> usize { - (1usize << (self.num_objects - 1)) - 1 + fn num_tree_slots(&self) -> Result { + self.num_objects + .checked_sub(1) + .and_then(|depth| u32::try_from(depth).ok()) + .and_then(|depth| 1usize.checked_shl(depth)) + .map(|leaves| leaves - 1) + .ok_or_else(|| { + crate::traits::EvaluationError::IntegerOverflow( + "representing the decision-tree witness slots".into(), + ) + }) } /// Sentinel value meaning "this node is a leaf". @@ -176,7 +164,7 @@ impl MinimumDecisionTree { /// or None if the tree is invalid (doesn't identify all objects uniquely). fn simulate(&self, config: &[usize]) -> Result, crate::traits::EvaluationError> { let sentinel = self.leaf_sentinel(); - let max_slots = self.num_tree_slots(); + let max_slots = self.num_tree_slots()?; let mut seen_leaves = std::collections::HashSet::new(); let mut total_depth = 0_i64; @@ -232,7 +220,7 @@ impl Problem for MinimumDecisionTree { config: &Self::Solution, ) -> Result, crate::traits::EvaluationError> { Ok({ - if config.len() != self.num_tree_slots() { + if config.len() != self.num_tree_slots()? { return Err(crate::traits::EvaluationError::InvalidConfiguration( "decision-tree encoding length does not match the instance".into(), )); @@ -247,9 +235,14 @@ impl Problem for MinimumDecisionTree { } impl crate::solvers::BruteForceProblem for MinimumDecisionTree { - fn dimensions(&self) -> Vec { - // Each internal node can hold test 0..num_tests-1 or sentinel (leaf) - vec![self.num_tests + 1; self.num_tree_slots()] + fn num_variables(&self) -> Result { + Ok(self.num_tree_slots()?) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.num_tests).checked_add(1usize).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs index f002f1bc5..c6ab52e24 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), ]; @@ -313,16 +298,17 @@ impl Problem for MinimumDiscretePlanarInverseKinematics { } impl crate::solvers::BruteForceProblem for MinimumDiscretePlanarInverseKinematics { - fn dimensions(&self) -> Vec { - self.orientation_samples - .iter() - .map(|samples| samples.len()) - .collect() + fn num_variables(&self) -> Result { + Ok(self.orientation_samples.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.orientation_samples[variable].len()) } } crate::declare_variants! { - default MinimumDiscretePlanarInverseKinematics => "total_configurations", + default MinimumDiscretePlanarInverseKinematics => "(num_orientation_samples / num_links)^num_links", } crate::register_brute_force! { diff --git a/src/models/misc/minimum_disjunctive_normal_form.rs b/src/models/misc/minimum_disjunctive_normal_form.rs index ac2b429c0..4ca7e552d 100644 --- a/src/models/misc/minimum_disjunctive_normal_form.rs +++ b/src/models/misc/minimum_disjunctive_normal_form.rs @@ -70,6 +70,7 @@ impl PrimeImplicant { /// let value = solver.solve(&problem).unwrap(); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumDisjunctiveNormalFormData")] pub struct MinimumDisjunctiveNormalForm { /// Number of Boolean variables. num_variables: usize, @@ -81,6 +82,19 @@ pub struct MinimumDisjunctiveNormalForm { minterms: Vec, } +#[derive(Deserialize)] +struct MinimumDisjunctiveNormalFormData { + num_variables: usize, + truth_table: Vec, +} + +impl TryFrom for MinimumDisjunctiveNormalForm { + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumDisjunctiveNormalFormData) -> Result { + Self::try_new(data.num_variables, data.truth_table) + } +} + impl MinimumDisjunctiveNormalForm { /// Create a new MinimumDisjunctiveNormalForm problem. /// @@ -88,31 +102,43 @@ impl MinimumDisjunctiveNormalForm { /// - If truth_table length != 2^num_variables /// - If the function is identically false (no minterms) pub fn new(num_variables: usize, truth_table: Vec) -> Self { - assert!(num_variables >= 1, "Need at least 1 variable"); - assert_eq!( - truth_table.len(), - 1 << num_variables, - "Truth table must have 2^n entries" - ); + Self::try_new(num_variables, truth_table).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_variables: usize, + truth_table: Vec, + ) -> Result { + if num_variables == 0 { + return Err("Need at least 1 variable".into()); + } + if truth_table.len() + != 1usize + .checked_shl( + u32::try_from(num_variables).map_err(|_| "truth table size overflows usize")?, + ) + .ok_or("truth table size overflows usize")? + { + return Err("Truth table must have 2^n entries".into()); + } let minterms: Vec = truth_table .iter() .enumerate() .filter_map(|(i, &v)| if v { Some(i) } else { None }) .collect(); - assert!( - !minterms.is_empty(), - "Function must have at least one minterm" - ); + if minterms.is_empty() { + return Err("Function must have at least one minterm".into()); + } let prime_implicants = compute_prime_implicants(num_variables, &minterms); - Self { + Ok(Self { num_variables, truth_table, prime_implicants, minterms, - } + }) } /// Get the number of variables. @@ -197,8 +223,12 @@ impl Problem for MinimumDisjunctiveNormalForm { } impl crate::solvers::BruteForceProblem for MinimumDisjunctiveNormalForm { - fn dimensions(&self) -> Vec { - vec![2; self.prime_implicants.len()] + fn num_variables(&self) -> Result { + Ok(self.prime_implicants.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/minimum_external_macro_data_compression.rs b/src/models/misc/minimum_external_macro_data_compression.rs index dbaa37c8f..9141e9061 100644 --- a/src/models/misc/minimum_external_macro_data_compression.rs +++ b/src/models/misc/minimum_external_macro_data_compression.rs @@ -158,17 +158,6 @@ impl MinimumExternalMacroDataCompression { &self.string } - /// Returns the number of valid pointers into D (|s|*(|s|+1)/2). - fn num_pointers(&self) -> usize { - let n = self.string.len(); - n * (n + 1) / 2 - } - - /// Returns the C-slot domain size: alphabet_size + 1 (empty) + num_pointers. - fn c_domain_size(&self) -> usize { - self.alphabet_size + 1 + self.num_pointers() - } - /// Decode a pointer index (offset from alphabet_size+1) into (start, len) /// in the dictionary. Pointers are enumerated as: /// index 0 -> (0, 1), 1 -> (0, 2), ..., n-1 -> (0, n), @@ -322,13 +311,22 @@ impl Problem for MinimumExternalMacroDataCompression { } impl crate::solvers::BruteForceProblem for MinimumExternalMacroDataCompression { - fn dimensions(&self) -> Vec { - let n = self.string.len(); - let d_domain = self.alphabet_size + 1; // symbols + empty - let c_domain = self.c_domain_size(); // symbols + empty + pointers - let mut dims = vec![d_domain; n]; // D-slots - dims.extend(vec![c_domain; n]); // C-slots - dims + fn num_variables(&self) -> Result { + Ok(2 * self.string.len()) + } + + fn dimension(&self, variable: usize) -> Result { + if variable < self.string.len() { + Ok((self.alphabet_size).checked_add(1usize).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a search coordinate size".into(), + ) + })?) + } else { + let n = self.string.len() as u128; + let cardinality = self.alphabet_size as u128 + 1 + n * (n + 1) / 2; + Ok(usize::try_from(cardinality)?) + } } } diff --git a/src/models/misc/minimum_fault_detection_test_set.rs b/src/models/misc/minimum_fault_detection_test_set.rs index daadcd56d..23f3b014d 100644 --- a/src/models/misc/minimum_fault_detection_test_set.rs +++ b/src/models/misc/minimum_fault_detection_test_set.rs @@ -87,15 +87,8 @@ impl<'de> Deserialize<'de> for MinimumFaultDetectionTestSet { D: Deserializer<'de>, { let data = MinimumFaultDetectionTestSetData::deserialize(deserializer)?; - let coverage = - Self::build_coverage(data.num_vertices, &data.arcs, &data.inputs, &data.outputs); - Ok(Self { - num_vertices: data.num_vertices, - arcs: data.arcs, - inputs: data.inputs, - outputs: data.outputs, - coverage, - }) + Self::try_new(data.num_vertices, data.arcs, data.inputs, data.outputs) + .map_err(serde::de::Error::custom) } } @@ -112,42 +105,56 @@ impl MinimumFaultDetectionTestSet { inputs: Vec, outputs: Vec, ) -> Self { - assert!(!inputs.is_empty(), "Inputs must not be empty"); - assert!(!outputs.is_empty(), "Outputs must not be empty"); + Self::try_new(num_vertices, arcs, inputs, outputs).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + inputs: Vec, + outputs: Vec, + ) -> Result { + if inputs.is_empty() { + return Err("Inputs must not be empty".into()); + }; + if outputs.is_empty() { + return Err("Outputs must not be empty".into()); + }; for (i, &(u, v)) in arcs.iter().enumerate() { - assert!( - u < num_vertices && v < num_vertices, - "Arc {} ({}, {}) out of bounds for {} vertices", - i, - u, - v, - num_vertices - ); + if !(u < num_vertices && v < num_vertices) { + return Err(format!( + "Arc {} ({}, {}) out of bounds for {} vertices", + i, u, v, num_vertices + ) + .into()); + }; } for &inp in &inputs { - assert!( - inp < num_vertices, - "Input vertex {} out of bounds for {} vertices", - inp, - num_vertices - ); + if !(inp < num_vertices) { + return Err(format!( + "Input vertex {} out of bounds for {} vertices", + inp, num_vertices + ) + .into()); + }; } for &out in &outputs { - assert!( - out < num_vertices, - "Output vertex {} out of bounds for {} vertices", - out, - num_vertices - ); + if !(out < num_vertices) { + return Err(format!( + "Output vertex {} out of bounds for {} vertices", + out, num_vertices + ) + .into()); + }; } let coverage = Self::build_coverage(num_vertices, &arcs, &inputs, &outputs); - Self { + Ok(Self { num_vertices, arcs, inputs, outputs, coverage, - } + }) } /// Compute forward reachability from a given vertex using BFS on the DAG. @@ -335,8 +342,16 @@ impl Problem for MinimumFaultDetectionTestSet { } impl crate::solvers::BruteForceProblem for MinimumFaultDetectionTestSet { - fn dimensions(&self) -> Vec { - vec![2; self.inputs.len() * self.outputs.len()] + fn num_variables(&self) -> Result { + (self.inputs.len()) + .checked_mul(self.outputs.len()) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/minimum_internal_macro_data_compression.rs b/src/models/misc/minimum_internal_macro_data_compression.rs index 611981db0..028f09e0d 100644 --- a/src/models/misc/minimum_internal_macro_data_compression.rs +++ b/src/models/misc/minimum_internal_macro_data_compression.rs @@ -285,10 +285,22 @@ impl Problem for MinimumInternalMacroDataCompression { } impl crate::solvers::BruteForceProblem for MinimumInternalMacroDataCompression { - fn dimensions(&self) -> Vec { - let n = self.string.len(); - let domain = self.alphabet_size + n + 1; // literals + EOS + pointers - vec![domain; n] + fn num_variables(&self) -> Result { + Ok(self.string.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + ((self.alphabet_size) + .checked_add(self.string.len()) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow( + "computing a coordinate cardinality".into(), + ) + })?) + .checked_add(1usize) + .ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/misc/minimum_register_sufficiency_for_loops.rs b/src/models/misc/minimum_register_sufficiency_for_loops.rs index fd2bf3465..798386e27 100644 --- a/src/models/misc/minimum_register_sufficiency_for_loops.rs +++ b/src/models/misc/minimum_register_sufficiency_for_loops.rs @@ -62,6 +62,7 @@ inventory::submit! { /// assert_eq!(val, Min(Some(3))); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumRegisterSufficiencyForLoopsData")] pub struct MinimumRegisterSufficiencyForLoops { /// Loop length N (number of timesteps in the circular loop). loop_length: usize, @@ -69,6 +70,19 @@ pub struct MinimumRegisterSufficiencyForLoops { variables: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct MinimumRegisterSufficiencyForLoopsData { + loop_length: usize, + variables: Vec<(usize, usize)>, +} + +impl TryFrom for MinimumRegisterSufficiencyForLoops { + type Error = crate::registry::ConstructionError; + fn try_from(data: MinimumRegisterSufficiencyForLoopsData) -> Result { + Self::try_new(data.loop_length, data.variables) + } +} + impl MinimumRegisterSufficiencyForLoops { /// Create a new Minimum Register Sufficiency for Loops instance. /// @@ -77,27 +91,36 @@ impl MinimumRegisterSufficiencyForLoops { /// Panics if `loop_length` is zero, if any duration is zero or exceeds /// `loop_length`, or if any `start_time >= loop_length`. pub fn new(loop_length: usize, variables: Vec<(usize, usize)>) -> Self { - assert!(loop_length > 0, "loop_length must be positive"); + Self::try_new(loop_length, variables).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + loop_length: usize, + variables: Vec<(usize, usize)>, + ) -> Result { + if loop_length == 0 { + return Err("loop_length must be positive".into()); + } for (i, &(start, dur)) in variables.iter().enumerate() { - assert!( - start < loop_length, - "Variable {} start_time {} >= loop_length {}", - i, - start, - loop_length - ); - assert!( - dur > 0 && dur <= loop_length, - "Variable {} duration {} must be in [1, {}]", - i, - dur, - loop_length - ); + if !(start < loop_length) { + return Err(format!( + "Variable {} start_time {} >= loop_length {}", + i, start, loop_length + ) + .into()); + } + if !(dur > 0 && dur <= loop_length) { + return Err(format!( + "Variable {} duration {} must be in [1, {}]", + i, dur, loop_length + ) + .into()); + } } - Self { + Ok(Self { loop_length, variables, - } + }) } /// Get the loop length N. @@ -212,9 +235,12 @@ impl Problem for MinimumRegisterSufficiencyForLoops { } impl crate::solvers::BruteForceProblem for MinimumRegisterSufficiencyForLoops { - fn dimensions(&self) -> Vec { - let n = self.variables.len(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.variables.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.variables.len()) } } diff --git a/src/models/misc/minimum_tardiness_sequencing.rs b/src/models/misc/minimum_tardiness_sequencing.rs index c8865a5e2..dbd995de8 100644 --- a/src/models/misc/minimum_tardiness_sequencing.rs +++ b/src/models/misc/minimum_tardiness_sequencing.rs @@ -54,46 +54,34 @@ inventory::submit! { /// let solution = solver.solve(&problem).unwrap(); /// assert!(solution.is_some()); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumTardinessSequencing { lengths: Vec, deadlines: Vec, precedences: Vec<(usize, usize)>, } -macro_rules! minimum_tardiness_create_spec { - ($name:ident, $weight:ty, $construct:expr) => { - #[derive(Debug, Deserialize, crate::CreateSpec)] - struct $name { - lengths: Vec<$weight>, - deadlines: Vec, - precedences: Option>, - } +#[derive(Deserialize)] +struct MinimumTardinessSequencingData { + lengths: Vec, + deadlines: Vec, + precedences: Vec<(usize, usize)>, +} - impl TryFrom<$name> for MinimumTardinessSequencing<$weight> { - type Error = crate::registry::ConstructionError; +impl<'de> Deserialize<'de> for MinimumTardinessSequencing { + fn deserialize>(deserializer: D) -> Result { + let data = MinimumTardinessSequencingData::::deserialize(deserializer)?; + Self::try_new(data.lengths.len(), data.deadlines, data.precedences) + .map_err(serde::de::Error::custom) + } +} - fn try_from(spec: $name) -> Result { - if spec.lengths.len() != spec.deadlines.len() { - return Err("lengths and deadlines must have the same length" - .to_string() - .into()); - } - let precedences = spec.precedences.unwrap_or_default(); - let num_tasks = spec.lengths.len(); - if let Some(&(pred, succ)) = precedences - .iter() - .find(|&&(pred, succ)| pred >= num_tasks || succ >= num_tasks) - { - return Err(format!( - "precedence ({pred}, {succ}) is out of range for {num_tasks} tasks" - ) - .into()); - } - $construct(spec.lengths, spec.deadlines, precedences) - } - } - }; +impl<'de> Deserialize<'de> for MinimumTardinessSequencing { + fn deserialize>(deserializer: D) -> Result { + let data = MinimumTardinessSequencingData::::deserialize(deserializer)?; + Self::try_with_lengths(data.lengths, data.deadlines, data.precedences) + .map_err(serde::de::Error::custom) + } } #[derive(Debug, Deserialize, crate::CreateSpec)] @@ -112,24 +100,26 @@ impl TryFrom for MinimumTardinessSequen { return Err("precedence indices must be within the task count".into()); } - Ok(Self::new(num_tasks, spec.deadlines, precedences)) + Self::try_new(num_tasks, spec.deadlines, precedences) } } -minimum_tardiness_create_spec!( - MinimumTardinessSequencingI64CreateSpec, - i64, - |lengths: Vec, deadlines, precedences| { - if lengths.iter().any(|&length| length <= 0) { - return Err("all task lengths must be positive".to_string().into()); - } - Ok(MinimumTardinessSequencing::with_lengths( - lengths, - deadlines, - precedences, - )) +#[derive(Debug, Deserialize, crate::CreateSpec)] +struct MinimumTardinessSequencingI64CreateSpec { + lengths: Vec, + deadlines: Vec, + precedences: Option>, +} +impl TryFrom for MinimumTardinessSequencing { + type Error = crate::registry::ConstructionError; + fn try_from(spec: MinimumTardinessSequencingI64CreateSpec) -> Result { + Self::try_with_lengths( + spec.lengths, + spec.deadlines, + spec.precedences.unwrap_or_default(), + ) } -); +} impl MinimumTardinessSequencing { /// Create a new unit-length MinimumTardinessSequencing instance. @@ -139,17 +129,20 @@ impl MinimumTardinessSequencing { /// Panics if `deadlines.len() != num_tasks` or if any task index in `precedences` /// is out of range. pub fn new(num_tasks: usize, deadlines: Vec, precedences: Vec<(usize, usize)>) -> Self { - assert_eq!( - deadlines.len(), - num_tasks, - "deadlines length must equal num_tasks" - ); - validate_precedences(num_tasks, &precedences); - Self { + Self::try_new(num_tasks, deadlines, precedences).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_tasks: usize, + deadlines: Vec, + precedences: Vec<(usize, usize)>, + ) -> Result { + validate_task_data(num_tasks, &deadlines, &precedences)?; + Ok(Self { lengths: vec![One; num_tasks], deadlines, precedences, - } + }) } } @@ -165,40 +158,48 @@ impl MinimumTardinessSequencing { deadlines: Vec, precedences: Vec<(usize, usize)>, ) -> Self { - assert_eq!( - lengths.len(), - deadlines.len(), - "lengths and deadlines must have the same length" - ); - assert!( - lengths.iter().all(|&l| l > 0), - "all task lengths must be positive" - ); - let num_tasks = lengths.len(); - validate_precedences(num_tasks, &precedences); - Self { + Self::try_with_lengths(lengths, deadlines, precedences) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_with_lengths( + lengths: Vec, + deadlines: Vec, + precedences: Vec<(usize, usize)>, + ) -> Result { + validate_task_data(lengths.len(), &deadlines, &precedences)?; + if lengths.iter().any(|&length| length <= 0) { + return Err("all task lengths must be positive".into()); + } + Ok(Self { lengths, deadlines, precedences, - } + }) } } -fn validate_precedences(num_tasks: usize, precedences: &[(usize, usize)]) { +fn validate_task_data( + num_tasks: usize, + deadlines: &[i64], + precedences: &[(usize, usize)], +) -> Result<(), crate::registry::ConstructionError> { + if deadlines.len() != num_tasks { + return Err("deadlines length must equal num_tasks".into()); + } for &(pred, succ) in precedences { - assert!( - pred < num_tasks, - "predecessor index {} out of range (num_tasks = {})", - pred, - num_tasks - ); - assert!( - succ < num_tasks, - "successor index {} out of range (num_tasks = {})", - succ, - num_tasks - ); + if pred >= num_tasks { + return Err( + format!("predecessor index {pred} out of range (num_tasks = {num_tasks})").into(), + ); + } + if succ >= num_tasks { + return Err( + format!("successor index {succ} out of range (num_tasks = {num_tasks})").into(), + ); + } } + Ok(()) } impl MinimumTardinessSequencing { @@ -308,8 +309,12 @@ impl Problem for MinimumTardinessSequencing { } impl crate::solvers::BruteForceProblem for MinimumTardinessSequencing { - fn dimensions(&self) -> Vec { - super::lehmer_dims(self.num_tasks()) + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.num_tasks() - variable) } } @@ -382,8 +387,12 @@ impl Problem for MinimumTardinessSequencing { } impl crate::solvers::BruteForceProblem for MinimumTardinessSequencing { - fn dimensions(&self) -> Vec { - super::lehmer_dims(self.num_tasks()) + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.num_tasks() - variable) } } diff --git a/src/models/misc/minimum_weight_and_or_graph.rs b/src/models/misc/minimum_weight_and_or_graph.rs index 879879967..64856af28 100644 --- a/src/models/misc/minimum_weight_and_or_graph.rs +++ b/src/models/misc/minimum_weight_and_or_graph.rs @@ -115,13 +115,13 @@ impl TryFrom for MinimumWeightAndOrGraph { ) .into()); } - Ok(Self::new( + Self::try_new( spec.num_vertices, spec.arcs, spec.source, spec.gate_types, arc_weights, - )) + ) } } @@ -140,15 +140,14 @@ impl<'de> Deserialize<'de> for MinimumWeightAndOrGraph { D: Deserializer<'de>, { let data = MinimumWeightAndOrGraphData::deserialize(deserializer)?; - let outgoing = Self::build_outgoing(data.num_vertices, &data.arcs); - Ok(Self { - num_vertices: data.num_vertices, - arcs: data.arcs, - source: data.source, - gate_types: data.gate_types, - arc_weights: data.arc_weights, - outgoing, - }) + Self::try_new( + data.num_vertices, + data.arcs, + data.source, + data.gate_types, + data.arc_weights, + ) + .map_err(serde::de::Error::custom) } } @@ -167,49 +166,61 @@ impl MinimumWeightAndOrGraph { gate_types: Vec>, arc_weights: Vec, ) -> Self { - assert!( - source < num_vertices, - "Source vertex {} out of bounds for {} vertices", - source, - num_vertices - ); - assert_eq!( - gate_types.len(), - num_vertices, - "gate_types length {} does not match num_vertices {}", - gate_types.len(), - num_vertices - ); - assert_eq!( - arc_weights.len(), - arcs.len(), - "arc_weights length {} does not match number of arcs {}", - arc_weights.len(), - arcs.len() - ); - for (i, &(u, v)) in arcs.iter().enumerate() { - assert!( - u < num_vertices && v < num_vertices, - "Arc {} ({}, {}) out of bounds for {} vertices", - i, - u, - v, + Self::try_new(num_vertices, arcs, source, gate_types, arc_weights) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + source: usize, + gate_types: Vec>, + arc_weights: Vec, + ) -> Result { + if !(source < num_vertices) { + return Err(format!( + "Source vertex {} out of bounds for {} vertices", + source, num_vertices + ) + .into()); + }; + if gate_types.len() != num_vertices { + return Err(format!( + "gate_types length {} does not match num_vertices {}", + gate_types.len(), num_vertices - ); + ) + .into()); + }; + if arc_weights.len() != arcs.len() { + return Err(format!( + "arc_weights length {} does not match number of arcs {}", + arc_weights.len(), + arcs.len() + ) + .into()); + }; + for (i, &(u, v)) in arcs.iter().enumerate() { + if !(u < num_vertices && v < num_vertices) { + return Err(format!( + "Arc {} ({}, {}) out of bounds for {} vertices", + i, u, v, num_vertices + ) + .into()); + }; } - assert!( - gate_types[source].is_some(), - "Source vertex must be an AND or OR gate, not a leaf" - ); + if !(gate_types[source].is_some()) { + return Err("Source vertex must be an AND or OR gate, not a leaf".into()); + }; let outgoing = Self::build_outgoing(num_vertices, &arcs); - Self { + Ok(Self { num_vertices, arcs, source, gate_types, arc_weights, outgoing, - } + }) } /// Build outgoing arc index lists for each vertex. @@ -350,8 +361,12 @@ impl Problem for MinimumWeightAndOrGraph { } impl crate::solvers::BruteForceProblem for MinimumWeightAndOrGraph { - fn dimensions(&self) -> Vec { - vec![2; self.arcs.len()] + fn num_variables(&self) -> Result { + Ok(self.arcs.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } 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/multiprocessor_scheduling.rs b/src/models/misc/multiprocessor_scheduling.rs index 0cfb11a7c..733bb9486 100644 --- a/src/models/misc/multiprocessor_scheduling.rs +++ b/src/models/misc/multiprocessor_scheduling.rs @@ -50,11 +50,11 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MultiprocessorSchedulingCreateSpec")] pub struct MultiprocessorScheduling { /// Processing time for each task. lengths: Vec, /// Number of identical processors. - #[serde(deserialize_with = "positive_usize::deserialize")] num_processors: usize, /// Global deadline. deadline: i64, @@ -75,7 +75,7 @@ impl TryFrom for MultiprocessorScheduling { if spec.num_processors == 0 { return Err("num_processors must be positive".to_string().into()); } - Ok(Self::new(spec.lengths, spec.num_processors, spec.deadline)) + Self::try_new(spec.lengths, spec.num_processors, spec.deadline) } } @@ -85,17 +85,28 @@ impl MultiprocessorScheduling { /// # Panics /// Panics if `num_processors` is zero. pub fn new(lengths: Vec, num_processors: usize, deadline: i64) -> Self { - assert!(num_processors > 0, "num_processors must be positive"); - assert!( - lengths.iter().all(|&length| length >= 0), - "task lengths must be nonnegative" - ); - assert!(deadline >= 0, "deadline must be nonnegative"); - Self { + Self::try_new(lengths, num_processors, deadline).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + lengths: Vec, + num_processors: usize, + deadline: i64, + ) -> Result { + if num_processors == 0 { + return Err("num_processors must be positive".into()); + } + if !(lengths.iter().all(|&length| length >= 0)) { + return Err("task lengths must be nonnegative".into()); + } + if !(deadline >= 0) { + return Err("deadline must be nonnegative".into()); + } + Ok(Self { lengths, num_processors, deadline, - } + }) } /// Returns the processing times for each task. @@ -170,8 +181,12 @@ impl Problem for MultiprocessorScheduling { } impl crate::solvers::BruteForceProblem for MultiprocessorScheduling { - fn dimensions(&self) -> Vec { - vec![self.num_processors; self.num_tasks()] + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_processors) } } @@ -193,22 +208,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = usize::deserialize(deserializer)?; - if value == 0 { - return Err(D::Error::custom("expected positive integer, got 0")); - } - Ok(value) - } -} - #[cfg(test)] #[path = "../../unit_tests/models/misc/multiprocessor_scheduling.rs"] mod tests; diff --git a/src/models/misc/non_liveness_free_petri_net.rs b/src/models/misc/non_liveness_free_petri_net.rs index 66fca114a..70c643ee4 100644 --- a/src/models/misc/non_liveness_free_petri_net.rs +++ b/src/models/misc/non_liveness_free_petri_net.rs @@ -429,8 +429,12 @@ impl Problem for NonLivenessFreePetriNet { } impl crate::solvers::BruteForceProblem for NonLivenessFreePetriNet { - fn dimensions(&self) -> Vec { - vec![2; self.num_transitions] + fn num_variables(&self) -> Result { + Ok(self.num_transitions) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/numerical_3_dimensional_matching.rs b/src/models/misc/numerical_3_dimensional_matching.rs index 2a07faf11..9e3d72ff1 100644 --- a/src/models/misc/numerical_3_dimensional_matching.rs +++ b/src/models/misc/numerical_3_dimensional_matching.rs @@ -217,8 +217,12 @@ impl Problem for Numerical3DimensionalMatching { } impl crate::solvers::BruteForceProblem for Numerical3DimensionalMatching { - fn dimensions(&self) -> Vec { - vec![self.num_groups(); 2 * self.num_groups()] + fn num_variables(&self) -> Result { + Ok(2 * self.num_groups()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_groups()) } } diff --git a/src/models/misc/numerical_matching_with_target_sums.rs b/src/models/misc/numerical_matching_with_target_sums.rs index bccdc2f7c..c7e653acf 100644 --- a/src/models/misc/numerical_matching_with_target_sums.rs +++ b/src/models/misc/numerical_matching_with_target_sums.rs @@ -173,9 +173,12 @@ impl Problem for NumericalMatchingWithTargetSums { } impl crate::solvers::BruteForceProblem for NumericalMatchingWithTargetSums { - fn dimensions(&self) -> Vec { - let m = self.num_pairs(); - vec![m; m] + fn num_variables(&self) -> Result { + Ok(self.num_pairs()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_pairs()) } } diff --git a/src/models/misc/open_shop_scheduling.rs b/src/models/misc/open_shop_scheduling.rs index 30680b8cc..a6b1b702d 100644 --- a/src/models/misc/open_shop_scheduling.rs +++ b/src/models/misc/open_shop_scheduling.rs @@ -138,7 +138,7 @@ impl OpenShopScheduling { "operation count overflows usize".into(), ) })?; - let horizon = processing_times + processing_times .iter() .flatten() .try_fold(0i64, |total, &time| total.checked_add(time)) @@ -147,14 +147,6 @@ impl OpenShopScheduling { "schedule horizon overflows i64".into(), ) })?; - usize::try_from(horizon) - .ok() - .and_then(|value| value.checked_add(1)) - .ok_or_else(|| { - crate::registry::ConstructionError::IntegerOverflow( - "schedule horizon domain overflows usize".into(), - ) - })?; Ok(Self { num_machines, processing_times, @@ -177,16 +169,8 @@ impl OpenShopScheduling { } /// Return the sum of all processing times, a valid serial-schedule horizon. - pub fn schedule_horizon(&self) -> usize { - self.processing_times - .iter() - .flatten() - .try_fold(0usize, |total, &time| { - usize::try_from(time) - .ok() - .and_then(|time| total.checked_add(time)) - }) - .expect("processing times must fit the brute-force schedule horizon") + pub fn schedule_horizon(&self) -> i64 { + self.processing_times.iter().flatten().sum() } fn finish_time( @@ -284,12 +268,12 @@ impl Problem for OpenShopScheduling { } impl crate::solvers::BruteForceProblem for OpenShopScheduling { - fn dimensions(&self) -> Vec { - let domain = self - .schedule_horizon() - .checked_add(1) - .expect("schedule horizon overflow"); - vec![domain; self.num_jobs() * self.num_machines] + fn num_variables(&self) -> Result { + Ok(self.num_jobs() * self.num_machines) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.schedule_horizon()) + 1)?) } } @@ -329,3 +313,42 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Processing time of each job on each machine (n x m)." }, + crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_open_shop_scheduling_to_open_shop_scheduling", + build: || { + let source = crate::models::decision::Decision::new( + OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + ), + 8, + ); + let witness = serde_json::json!(vec![0, 3, 4, 3, 0, 6, 5, 6, 0, 6, 4, 3]); + crate::example_db::specs::rule_example_with_witness::<_, OpenShopScheduling>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/misc/optimum_communication_spanning_tree.rs b/src/models/misc/optimum_communication_spanning_tree.rs index a2732e7a1..ca183757f 100644 --- a/src/models/misc/optimum_communication_spanning_tree.rs +++ b/src/models/misc/optimum_communication_spanning_tree.rs @@ -63,12 +63,26 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "OptimumCommunicationSpanningTreeData")] pub struct OptimumCommunicationSpanningTree { num_vertices: usize, edge_weights: Vec>, requirements: Vec>, } +#[derive(Deserialize)] +struct OptimumCommunicationSpanningTreeData { + edge_weights: Vec>, + requirements: Vec>, +} + +impl TryFrom for OptimumCommunicationSpanningTree { + type Error = crate::registry::ConstructionError; + fn try_from(data: OptimumCommunicationSpanningTreeData) -> Result { + Self::try_new(data.edge_weights, data.requirements) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct OptimumCommunicationSpanningTreeCreateSpec { /// Number of vertices. @@ -108,7 +122,7 @@ impl TryFrom for OptimumCommunicatio } } } - Ok(Self::new(edge_weights, spec.requirements)) + Self::try_new(edge_weights, spec.requirements) } } @@ -125,73 +139,94 @@ impl OptimumCommunicationSpanningTree { /// Panics if the matrices are not square, not the same size, have nonzero /// diagonals, are not symmetric, or contain negative entries. pub fn new(edge_weights: Vec>, requirements: Vec>) -> Self { + Self::try_new(edge_weights, requirements).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + edge_weights: Vec>, + requirements: Vec>, + ) -> Result { let n = edge_weights.len(); - assert!(n >= 2, "must have at least 2 vertices"); - assert_eq!( - requirements.len(), - n, - "requirements matrix must have same size as edge_weights" - ); + if !(n >= 2) { + return Err("must have at least 2 vertices".into()); + } + if requirements.len() != n { + return Err("requirements matrix must have same size as edge_weights".into()); + } for (i, row) in edge_weights.iter().enumerate() { - assert_eq!( - row.len(), - n, - "edge_weights must be square: row {i} has length {} but expected {n}", - row.len() - ); - assert_eq!( - row[i], 0, - "diagonal of edge_weights must be zero: edge_weights[{i}][{i}] = {}", - row[i] - ); + if row.len() != n { + return Err(format!( + "edge_weights must be square: row {i} has length {} but expected {n}", + row.len() + ) + .into()); + } + if row[i] != 0 { + return Err(format!( + "diagonal of edge_weights must be zero: edge_weights[{i}][{i}] = {}", + row[i] + ) + .into()); + } } for (i, row) in requirements.iter().enumerate() { - assert_eq!( - row.len(), - n, - "requirements must be square: row {i} has length {} but expected {n}", - row.len() - ); - assert_eq!( - row[i], 0, - "diagonal of requirements must be zero: requirements[{i}][{i}] = {}", - row[i] - ); + if row.len() != n { + return Err(format!( + "requirements must be square: row {i} has length {} but expected {n}", + row.len() + ) + .into()); + } + if row[i] != 0 { + return Err(format!( + "diagonal of requirements must be zero: requirements[{i}][{i}] = {}", + row[i] + ) + .into()); + } } // Check symmetry and non-negativity for i in 0..n { for j in (i + 1)..n { - assert_eq!( - edge_weights[i][j], edge_weights[j][i], - "edge_weights must be symmetric: w[{i}][{j}]={} != w[{j}][{i}]={}", - edge_weights[i][j], edge_weights[j][i] - ); - assert!( - edge_weights[i][j] >= 0, - "edge_weights must be non-negative: w[{i}][{j}]={}", - edge_weights[i][j] - ); - assert_eq!( - requirements[i][j], requirements[j][i], - "requirements must be symmetric: r[{i}][{j}]={} != r[{j}][{i}]={}", - requirements[i][j], requirements[j][i] - ); - assert!( - requirements[i][j] >= 0, - "requirements must be non-negative: r[{i}][{j}]={}", - requirements[i][j] - ); + if edge_weights[i][j] != edge_weights[j][i] { + return Err(format!( + "edge_weights must be symmetric: w[{i}][{j}]={} != w[{j}][{i}]={}", + edge_weights[i][j], edge_weights[j][i] + ) + .into()); + } + if !(edge_weights[i][j] >= 0) { + return Err(format!( + "edge_weights must be non-negative: w[{i}][{j}]={}", + edge_weights[i][j] + ) + .into()); + } + if requirements[i][j] != requirements[j][i] { + return Err(format!( + "requirements must be symmetric: r[{i}][{j}]={} != r[{j}][{i}]={}", + requirements[i][j], requirements[j][i] + ) + .into()); + } + if !(requirements[i][j] >= 0) { + return Err(format!( + "requirements must be non-negative: r[{i}][{j}]={}", + requirements[i][j] + ) + .into()); + } } } - Self { + Ok(Self { num_vertices: n, edge_weights, requirements, - } + }) } /// Returns the number of vertices. @@ -369,8 +404,12 @@ impl Problem for OptimumCommunicationSpanningTree { } impl crate::solvers::BruteForceProblem for OptimumCommunicationSpanningTree { - fn dimensions(&self) -> Vec { - vec![2; self.num_edges()] + fn num_variables(&self) -> Result { + Ok(self.num_edges()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/paintshop.rs b/src/models/misc/paintshop.rs index e758c9faf..470e15ba3 100644 --- a/src/models/misc/paintshop.rs +++ b/src/models/misc/paintshop.rs @@ -50,6 +50,7 @@ inventory::submit! { /// } /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PaintShopData")] pub struct PaintShop { /// The sequence of car labels (as indices into unique cars). sequence_indices: Vec, @@ -61,42 +62,72 @@ pub struct PaintShop { num_cars: usize, } +#[derive(Deserialize)] +struct PaintShopData { + sequence_indices: Vec, + car_labels: Vec, +} + +impl TryFrom for PaintShop { + type Error = crate::registry::ConstructionError; + + fn try_from(data: PaintShopData) -> Result { + let sequence = data + .sequence_indices + .into_iter() + .map(|index| { + data.car_labels.get(index).ok_or_else(|| { + crate::registry::ConstructionError::from(format!( + "car index {index} is outside car_labels" + )) + }) + }) + .collect::, _>>()?; + Self::try_new(sequence) + } +} + impl PaintShop { /// Create a new Paint Shop problem from string labels. /// /// Each element in the sequence must appear exactly twice. pub fn new>(sequence: Vec) -> Self { - let sequence: Vec = sequence.iter().map(|s| s.as_ref().to_string()).collect(); - Self::from_strings(sequence) + Self::try_new(sequence).unwrap_or_else(|error| panic!("{error}")) } - /// Create from a vector of strings. - pub fn from_strings(sequence: Vec) -> Self { + fn try_new>( + sequence: Vec, + ) -> Result { // Build car-to-index mapping and count occurrences - let mut car_count: HashMap = HashMap::new(); - let mut car_to_index: HashMap = HashMap::new(); + let mut car_count: HashMap<&str, usize> = HashMap::new(); + let mut car_to_index: HashMap<&str, usize> = HashMap::new(); let mut car_labels: Vec = Vec::new(); for item in &sequence { - let count = car_count.entry(item.clone()).or_insert(0); + let item = item.as_ref(); + let count = car_count.entry(item).or_insert(0); if *count == 0 { - car_to_index.insert(item.clone(), car_labels.len()); - car_labels.push(item.clone()); + car_to_index.insert(item, car_labels.len()); + car_labels.push(item.to_owned()); } *count += 1; } // Verify each car appears exactly twice for (car, count) in &car_count { - assert_eq!( - *count, 2, - "Each car must appear exactly twice, but '{}' appears {} times", - car, count - ); + if *count != 2 { + return Err(format!( + "each car must appear exactly twice, but '{car}' appears {count} times" + ) + .into()); + } } // Convert sequence to indices - let sequence_indices: Vec = sequence.iter().map(|item| car_to_index[item]).collect(); + let sequence_indices: Vec = sequence + .iter() + .map(|item| car_to_index[item.as_ref()]) + .collect(); // Determine which positions are first occurrences let mut seen: HashSet = HashSet::new(); @@ -107,12 +138,12 @@ impl PaintShop { let num_cars = car_labels.len(); - Self { + Ok(Self { sequence_indices, car_labels, is_first, num_cars, - } + }) } /// Get the sequence length. @@ -183,6 +214,11 @@ impl PaintShop { ) }) } + + /// Create from a vector of strings. + pub fn from_strings(sequence: Vec) -> Self { + Self::new(sequence) + } } /// Count color switches in a painted sequence. @@ -214,8 +250,12 @@ impl Problem for PaintShop { } impl crate::solvers::BruteForceProblem for PaintShop { - fn dimensions(&self) -> Vec { - vec![2; self.num_cars] + fn num_variables(&self) -> Result { + Ok(self.num_cars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/partially_ordered_knapsack.rs b/src/models/misc/partially_ordered_knapsack.rs index af4e40f5b..3170ce153 100644 --- a/src/models/misc/partially_ordered_knapsack.rs +++ b/src/models/misc/partially_ordered_knapsack.rs @@ -127,12 +127,7 @@ impl TryFrom for PartiallyOrderedKnapsack { { return Err(format!("precedences contain a cycle involving item {item}").into()); } - Ok(Self::new( - spec.weights, - spec.values, - precedences, - spec.capacity, - )) + Self::try_new(spec.weights, spec.values, precedences, spec.capacity) } } @@ -151,12 +146,8 @@ impl Serialize for PartiallyOrderedKnapsack { impl<'de> Deserialize<'de> for PartiallyOrderedKnapsack { fn deserialize>(deserializer: D) -> Result { let raw = PartiallyOrderedKnapsackRaw::deserialize(deserializer)?; - Ok(Self::new( - raw.weights, - raw.values, - raw.precedences, - raw.capacity, - )) + Self::try_new(raw.weights, raw.values, raw.precedences, raw.capacity) + .map_err(serde::de::Error::custom) } } @@ -179,38 +170,55 @@ impl PartiallyOrderedKnapsack { precedences: Vec<(usize, usize)>, capacity: i64, ) -> Self { - assert_eq!( - weights.len(), - values.len(), - "weights and values must have the same length" - ); - assert!(capacity >= 0, "capacity must be non-negative"); + Self::try_new(weights, values, precedences, capacity) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + weights: Vec, + values: Vec, + precedences: Vec<(usize, usize)>, + capacity: i64, + ) -> Result { + if weights.len() != values.len() { + return Err("weights and values must have the same length".into()); + }; + if !(capacity >= 0) { + return Err("capacity must be non-negative".into()); + }; for (i, &w) in weights.iter().enumerate() { - assert!(w >= 0, "weight[{i}] must be non-negative, got {w}"); + if !(w >= 0) { + return Err(format!("weight[{i}] must be non-negative, got {w}").into()); + }; } for (i, &v) in values.iter().enumerate() { - assert!(v >= 0, "value[{i}] must be non-negative, got {v}"); + if !(v >= 0) { + return Err(format!("value[{i}] must be non-negative, got {v}").into()); + }; } let n = weights.len(); for &(a, b) in &precedences { - assert!(a < n, "precedence index {a} out of bounds (n={n})"); - assert!(b < n, "precedence index {b} out of bounds (n={n})"); + if !(a < n) { + return Err(format!("precedence index {a} out of bounds (n={n})").into()); + }; + if !(b < n) { + return Err(format!("precedence index {b} out of bounds (n={n})").into()); + }; } let predecessors = Self::compute_predecessors(&precedences, n); // Check for cycles: if any item is its own transitive predecessor, the DAG has a cycle for (i, preds) in predecessors.iter().enumerate() { - assert!( - !preds.contains(&i), - "precedences contain a cycle involving item {i}" - ); + if !(!preds.contains(&i)) { + return Err(format!("precedences contain a cycle involving item {i}").into()); + }; } - Self { + Ok(Self { weights, values, precedences, capacity, predecessors, - } + }) } /// Compute transitive predecessors for each item via Floyd-Warshall. @@ -344,8 +352,12 @@ impl Problem for PartiallyOrderedKnapsack { } impl crate::solvers::BruteForceProblem for PartiallyOrderedKnapsack { - fn dimensions(&self) -> Vec { - vec![2; self.num_items()] + fn num_variables(&self) -> Result { + Ok(self.num_items()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/partition.rs b/src/models/misc/partition.rs index 032f4eccc..dba5f04be 100644 --- a/src/models/misc/partition.rs +++ b/src/models/misc/partition.rs @@ -137,8 +137,12 @@ impl Problem for Partition { } impl crate::solvers::BruteForceProblem for Partition { - fn dimensions(&self) -> Vec { - vec![2; self.num_elements()] + fn num_variables(&self) -> Result { + Ok(self.num_elements()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/precedence_constrained_scheduling.rs b/src/models/misc/precedence_constrained_scheduling.rs index b062b26a2..9380bece8 100644 --- a/src/models/misc/precedence_constrained_scheduling.rs +++ b/src/models/misc/precedence_constrained_scheduling.rs @@ -47,6 +47,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PrecedenceConstrainedSchedulingData")] pub struct PrecedenceConstrainedScheduling { num_tasks: usize, num_processors: usize, @@ -54,6 +55,26 @@ pub struct PrecedenceConstrainedScheduling { precedences: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct PrecedenceConstrainedSchedulingData { + num_tasks: usize, + num_processors: usize, + deadline: i64, + precedences: Vec<(usize, usize)>, +} + +impl TryFrom for PrecedenceConstrainedScheduling { + type Error = crate::registry::ConstructionError; + fn try_from(data: PrecedenceConstrainedSchedulingData) -> Result { + Self::try_new( + data.num_tasks, + data.num_processors, + data.deadline, + data.precedences, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct PrecedenceConstrainedSchedulingCreateSpec { num_tasks: usize, @@ -76,10 +97,8 @@ impl TryFrom for PrecedenceConstraine .to_string() .into()); } - if spec.deadline < 0 || usize::try_from(spec.deadline).is_err() { - return Err("deadline must be nonnegative and fit usize" - .to_string() - .into()); + if spec.deadline < 0 { + return Err("deadline must be nonnegative".to_string().into()); } let precedences = spec.precedences.unwrap_or_default(); if let Some(&(pred, succ)) = precedences @@ -92,12 +111,12 @@ impl TryFrom for PrecedenceConstraine ) .into()); } - Ok(Self::new( + Self::try_new( spec.num_tasks, spec.num_processors, spec.deadline, precedences, - )) + ) } } @@ -114,32 +133,42 @@ impl PrecedenceConstrainedScheduling { deadline: i64, precedences: Vec<(usize, usize)>, ) -> Self { + Self::try_new(num_tasks, num_processors, deadline, precedences) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_tasks: usize, + num_processors: usize, + deadline: i64, + precedences: Vec<(usize, usize)>, + ) -> Result { if num_tasks > 0 { - assert!( - num_processors > 0, - "num_processors must be > 0 when there are tasks" - ); - assert!(deadline > 0, "deadline must be > 0 when there are tasks"); + if num_processors == 0 { + return Err("num_processors must be > 0 when there are tasks".into()); + } + if deadline <= 0 { + return Err("deadline must be > 0 when there are tasks".into()); + } + } + if !(deadline >= 0) { + return Err("deadline must be nonnegative".into()); } - assert!( - deadline >= 0 && usize::try_from(deadline).is_ok(), - "deadline must be nonnegative and fit usize" - ); for &(i, j) in &precedences { - assert!( - i < num_tasks && j < num_tasks, - "Precedence ({}, {}) out of bounds for {} tasks", - i, - j, - num_tasks - ); + if !(i < num_tasks && j < num_tasks) { + return Err(format!( + "Precedence ({}, {}) out of bounds for {} tasks", + i, j, num_tasks + ) + .into()); + } } - Self { + Ok(Self { num_tasks, num_processors, deadline, precedences, - } + }) } /// Get the number of tasks. @@ -194,24 +223,26 @@ impl Problem for PrecedenceConstrainedScheduling { "schedule length does not match the tasks".into(), )); } - let deadline = - usize::try_from(self.deadline).expect("validated deadline must fit usize"); - if config.iter().any(|&v| v >= deadline) { + if config + .iter() + .any(|&v| v as i128 >= i128::from(self.deadline)) + { return Err(crate::traits::EvaluationError::InvalidConfiguration( "schedule contains an out-of-range time slot".into(), )); } // 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)); } } // Check precedence constraints: for (i, j), slot[j] >= slot[i] + 1 for &(i, j) in &self.precedences { - if config[j] < config[i] + 1 { + if config[j] <= config[i] { return Ok(crate::types::Or(false)); } } @@ -222,11 +253,12 @@ impl Problem for PrecedenceConstrainedScheduling { } impl crate::solvers::BruteForceProblem for PrecedenceConstrainedScheduling { - fn dimensions(&self) -> Vec { - vec![ - usize::try_from(self.deadline).expect("validated deadline must fit usize"); - self.num_tasks - ] + fn num_variables(&self) -> Result { + Ok(self.num_tasks) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(usize::try_from(self.deadline)?) } } diff --git a/src/models/misc/preemptive_scheduling.rs b/src/models/misc/preemptive_scheduling.rs index 119fae84d..7a55c3308 100644 --- a/src/models/misc/preemptive_scheduling.rs +++ b/src/models/misc/preemptive_scheduling.rs @@ -275,9 +275,14 @@ impl Problem for PreemptiveScheduling { } impl crate::solvers::BruteForceProblem for PreemptiveScheduling { - fn dimensions(&self) -> Vec { - let d = self.d_max(); - vec![2; self.num_tasks() * d] + fn num_variables(&self) -> Result { + (self.num_tasks()).checked_mul(self.d_max()).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/production_planning.rs b/src/models/misc/production_planning.rs index 58e0449cc..8f1af36f3 100644 --- a/src/models/misc/production_planning.rs +++ b/src/models/misc/production_planning.rs @@ -24,8 +24,8 @@ inventory::submit! { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ProductionPlanningCreateSpec")] pub struct ProductionPlanning { - #[serde(deserialize_with = "positive_usize::deserialize")] num_periods: usize, demands: Vec, capacities: Vec, @@ -71,15 +71,10 @@ impl TryFrom for ProductionPlanning { ); } } - if spec.capacities.iter().any(|&capacity| { - usize::try_from(capacity) - .ok() - .and_then(|v| v.checked_add(1)) - .is_none() - }) { - return Err("capacities must fit in usize for dims()".to_string().into()); + if spec.capacities.iter().any(|&capacity| capacity < 0) { + return Err("capacities must be nonnegative".into()); } - Ok(Self::new( + Self::try_new( spec.num_periods, spec.demands, spec.capacities, @@ -87,7 +82,7 @@ impl TryFrom for ProductionPlanning { spec.production_costs, spec.inventory_costs, spec.cost_bound, - )) + ) } } @@ -101,7 +96,30 @@ impl ProductionPlanning { inventory_costs: Vec, cost_bound: i64, ) -> Self { - assert!(num_periods > 0, "num_periods must be positive"); + Self::try_new( + num_periods, + demands, + capacities, + setup_costs, + production_costs, + inventory_costs, + cost_bound, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_periods: usize, + demands: Vec, + capacities: Vec, + setup_costs: Vec, + production_costs: Vec, + inventory_costs: Vec, + cost_bound: i64, + ) -> Result { + if num_periods == 0 { + return Err("num_periods must be positive".into()); + } for len in [ demands.len(), capacities.len(), @@ -109,33 +127,25 @@ impl ProductionPlanning { production_costs.len(), inventory_costs.len(), ] { - assert_eq!( - len, num_periods, - "all per-period vectors must have length num_periods" - ); + if len != num_periods { + return Err("all per-period vectors must have length num_periods".into()); + } + } + if !(demands + .iter() + .chain(&capacities) + .chain(&setup_costs) + .chain(&production_costs) + .chain(&inventory_costs) + .all(|&value| value >= 0)) + { + return Err("demands, capacities, and costs must be nonnegative".into()); } - assert!( - capacities.iter().all(|&capacity| { - usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .is_some() - }), - "capacities must fit in usize for dims()" - ); - assert!( - demands - .iter() - .chain(&capacities) - .chain(&setup_costs) - .chain(&production_costs) - .chain(&inventory_costs) - .all(|&value| value >= 0), - "demands, capacities, and costs must be nonnegative" - ); - assert!(cost_bound >= 0, "cost bound must be nonnegative"); - - Self { + if !(cost_bound >= 0) { + return Err("cost bound must be nonnegative".into()); + } + + Ok(Self { num_periods, demands, capacities, @@ -143,7 +153,7 @@ impl ProductionPlanning { production_costs, inventory_costs, cost_bound, - } + }) } pub fn num_periods(&self) -> usize { @@ -200,11 +210,7 @@ impl Problem for ProductionPlanning { let mut total_cost = 0_i64; for (i, &production) in config.iter().enumerate() { - let capacity = match usize::try_from(self.capacities[i]) { - Ok(value) => value, - Err(_) => return Ok(Or(false)), - }; - if production > capacity { + if production as i128 > i128::from(self.capacities[i]) { return Ok(Or(false)); } @@ -289,16 +295,12 @@ impl Problem for ProductionPlanning { } impl crate::solvers::BruteForceProblem for ProductionPlanning { - fn dimensions(&self) -> Vec { - self.capacities - .iter() - .map(|&capacity| { - usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_add(1)) - .expect("capacities validated in constructor") - }) - .collect() + fn num_variables(&self) -> Result { + Ok(self.capacities.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.capacities[variable]) + 1)?) } } @@ -328,22 +330,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = usize::deserialize(deserializer)?; - if value == 0 { - return Err(D::Error::custom("expected positive integer, got 0")); - } - Ok(value) - } -} - #[cfg(test)] #[path = "../../unit_tests/models/misc/production_planning.rs"] mod tests; diff --git a/src/models/misc/rectilinear_picture_compression.rs b/src/models/misc/rectilinear_picture_compression.rs index cd399f591..22ef20b3b 100644 --- a/src/models/misc/rectilinear_picture_compression.rs +++ b/src/models/misc/rectilinear_picture_compression.rs @@ -78,7 +78,7 @@ impl<'de> Deserialize<'de> for RectilinearPictureCompression { bound: i64, } let inner = Inner::deserialize(deserializer)?; - Ok(Self::new(inner.matrix, inner.bound)) + Self::try_new(inner.matrix, inner.bound).map_err(serde::de::Error::custom) } } @@ -89,20 +89,30 @@ impl RectilinearPictureCompression { /// /// Panics if `matrix` is empty or has inconsistent row lengths. pub fn new(matrix: Vec>, bound: i64) -> Self { - assert!(!matrix.is_empty(), "Matrix must not be empty"); + Self::try_new(matrix, bound).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + matrix: Vec>, + bound: i64, + ) -> Result { + if matrix.is_empty() { + return Err("Matrix must not be empty".into()); + }; let cols = matrix[0].len(); - assert!(cols > 0, "Matrix must have at least one column"); - assert!( - matrix.iter().all(|row| row.len() == cols), - "All rows must have the same length" - ); + if !(cols > 0) { + return Err("Matrix must have at least one column".into()); + }; + if !(matrix.iter().all(|row| row.len() == cols)) { + return Err("All rows must have the same length".into()); + }; let mut instance = Self { matrix, bound, maximal_rects: Vec::new(), }; instance.maximal_rects = instance.compute_maximal_rectangles(); - instance + Ok(instance) } /// Returns the number of rows in the matrix. @@ -297,8 +307,12 @@ impl Problem for RectilinearPictureCompression { } impl crate::solvers::BruteForceProblem for RectilinearPictureCompression { - fn dimensions(&self) -> Vec { - vec![2; self.maximal_rects.len()] + fn num_variables(&self) -> Result { + Ok(self.maximal_rects.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/register_sufficiency.rs b/src/models/misc/register_sufficiency.rs index ebf2972f7..1769be996 100644 --- a/src/models/misc/register_sufficiency.rs +++ b/src/models/misc/register_sufficiency.rs @@ -56,6 +56,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "RegisterSufficiencyData")] pub struct RegisterSufficiency { /// Number of vertices. num_vertices: usize, @@ -65,6 +66,21 @@ pub struct RegisterSufficiency { bound: usize, } +#[derive(Deserialize)] +struct RegisterSufficiencyData { + num_vertices: usize, + arcs: Vec<(usize, usize)>, + bound: usize, +} + +impl TryFrom for RegisterSufficiency { + type Error = crate::registry::ConstructionError; + + fn try_from(data: RegisterSufficiencyData) -> Result { + Self::try_new(data.num_vertices, data.arcs, data.bound) + } +} + impl RegisterSufficiency { /// Create a new Register Sufficiency instance. /// @@ -73,21 +89,31 @@ impl RegisterSufficiency { /// Panics if any arc index is out of bounds (>= num_vertices), /// or if any arc is a self-loop. pub fn new(num_vertices: usize, arcs: Vec<(usize, usize)>, bound: usize) -> Self { + Self::try_new(num_vertices, arcs, bound).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + bound: usize, + ) -> Result { for &(v, u) in &arcs { - assert!( - v < num_vertices && u < num_vertices, - "Arc ({}, {}) out of bounds for {} vertices", - v, - u, - num_vertices - ); - assert!(v != u, "Self-loop ({}, {}) not allowed in a DAG", v, u); + if !(v < num_vertices && u < num_vertices) { + return Err(format!( + "Arc ({}, {}) out of bounds for {} vertices", + v, u, num_vertices + ) + .into()); + } + if v == u { + return Err(format!("Self-loop ({}, {}) not allowed in a DAG", v, u).into()); + } } - Self { + Ok(Self { num_vertices, arcs, bound, - } + }) } /// Get the number of vertices. @@ -391,8 +417,12 @@ impl Problem for RegisterSufficiency { } impl crate::solvers::BruteForceProblem for RegisterSufficiency { - fn dimensions(&self) -> Vec { - vec![self.num_vertices; self.num_vertices] + fn num_variables(&self) -> Result { + Ok(self.num_vertices) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_vertices) } } diff --git a/src/models/misc/resource_constrained_scheduling.rs b/src/models/misc/resource_constrained_scheduling.rs index c43d4bdca..1e9a2d205 100644 --- a/src/models/misc/resource_constrained_scheduling.rs +++ b/src/models/misc/resource_constrained_scheduling.rs @@ -254,8 +254,12 @@ impl Problem for ResourceConstrainedScheduling { } impl crate::solvers::BruteForceProblem for ResourceConstrainedScheduling { - fn dimensions(&self) -> Vec { - vec![self.deadline as usize; self.num_tasks()] + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(usize::try_from(self.deadline)?) } } diff --git a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs index 68459fc3f..67a83a47a 100644 --- a/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -37,7 +37,7 @@ inventory::submit! { /// # Representation /// /// Each task has a variable in `{0, ..., m-1}` representing its processor -/// assignment, giving `dims() = [m; n]`. +/// assignment, giving `coordinate cardinalities = [m; n]`. /// /// # Example /// @@ -303,8 +303,12 @@ impl Problem for SchedulingToMinimizeWeightedCompletionTime { } impl crate::solvers::BruteForceProblem for SchedulingToMinimizeWeightedCompletionTime { - fn dimensions(&self) -> Vec { - vec![self.num_processors; self.num_tasks()] + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_processors) } } diff --git a/src/models/misc/scheduling_with_individual_deadlines.rs b/src/models/misc/scheduling_with_individual_deadlines.rs index 6f8b21658..065055559 100644 --- a/src/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/models/misc/scheduling_with_individual_deadlines.rs @@ -29,6 +29,7 @@ inventory::submit! { /// satisfies `sigma(u) + 1 <= sigma(v)` and no time slot hosts more than /// `num_processors` tasks. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SchedulingWithIndividualDeadlinesData")] pub struct SchedulingWithIndividualDeadlines { num_tasks: usize, num_processors: usize, @@ -36,6 +37,26 @@ pub struct SchedulingWithIndividualDeadlines { precedences: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct SchedulingWithIndividualDeadlinesData { + num_tasks: usize, + num_processors: usize, + deadlines: Vec, + precedences: Vec<(usize, usize)>, +} + +impl TryFrom for SchedulingWithIndividualDeadlines { + type Error = crate::registry::ConstructionError; + fn try_from(data: SchedulingWithIndividualDeadlinesData) -> Result { + Self::try_new( + data.num_tasks, + data.num_processors, + data.deadlines, + data.precedences, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct SchedulingWithIndividualDeadlinesCreateSpec { /// Number of tasks. @@ -61,15 +82,6 @@ impl TryFrom for SchedulingWithIndi if spec.deadlines.iter().any(|&deadline| deadline < 0) { return Err("deadlines must be nonnegative".to_string().into()); } - if spec - .deadlines - .iter() - .any(|&deadline| usize::try_from(deadline).is_err()) - { - return Err("deadlines must fit usize to define schedule slots" - .to_string() - .into()); - } let precedences = spec.precedences.unwrap_or_default(); if let Some(&(pred, succ)) = precedences .iter() @@ -81,12 +93,12 @@ impl TryFrom for SchedulingWithIndi ) .into()); } - Ok(Self::new( + Self::try_new( spec.num_tasks, spec.num_processors, spec.deadlines, precedences, - )) + ) } } @@ -97,42 +109,45 @@ impl SchedulingWithIndividualDeadlines { deadlines: Vec, precedences: Vec<(usize, usize)>, ) -> Self { - assert_eq!( - deadlines.len(), - num_tasks, - "deadlines length must equal num_tasks" - ); - assert!( - deadlines.iter().all(|&deadline| deadline >= 0), - "deadlines must be nonnegative" - ); - assert!( - deadlines - .iter() - .all(|&deadline| usize::try_from(deadline).is_ok()), - "deadlines must fit usize to define schedule slots" - ); + Self::try_new(num_tasks, num_processors, deadlines, precedences) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_tasks: usize, + num_processors: usize, + deadlines: Vec, + precedences: Vec<(usize, usize)>, + ) -> Result { + if deadlines.len() != num_tasks { + return Err("deadlines length must equal num_tasks".into()); + } + if !(deadlines.iter().all(|&deadline| deadline >= 0)) { + return Err("deadlines must be nonnegative".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 {} out of range (num_tasks = {})", + pred, num_tasks + ) + .into()); + } + if !(succ < num_tasks) { + return Err(format!( + "successor index {} out of range (num_tasks = {})", + succ, num_tasks + ) + .into()); + } } - Self { + Ok(Self { num_tasks, num_processors, deadlines, precedences, - } + }) } pub fn num_tasks(&self) -> usize { @@ -188,15 +203,13 @@ impl Problem for SchedulingWithIndividualDeadlines { } for (&start, &deadline) in config.iter().zip(&self.deadlines) { - let deadline = - usize::try_from(deadline).expect("validated deadline must fit usize"); - if start >= deadline { + if start as i128 >= i128::from(deadline) { return Ok(crate::types::Or(false)); } } for &(pred, succ) in &self.precedences { - if config[pred] + 1 > config[succ] { + if config[pred] >= config[succ] { return Ok(crate::types::Or(false)); } } @@ -217,11 +230,12 @@ impl Problem for SchedulingWithIndividualDeadlines { } impl crate::solvers::BruteForceProblem for SchedulingWithIndividualDeadlines { - fn dimensions(&self) -> Vec { - self.deadlines - .iter() - .map(|&deadline| usize::try_from(deadline).expect("validated deadline must fit usize")) - .collect() + fn num_variables(&self) -> Result { + Ok(self.deadlines.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from(self.deadlines[variable])?) } } diff --git a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs index 95b9cc819..47c71350e 100644 --- a/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -209,8 +209,12 @@ impl Problem for SequencingToMinimizeMaximumCumulativeCost { } impl crate::solvers::BruteForceProblem for SequencingToMinimizeMaximumCumulativeCost { - fn dimensions(&self) -> Vec { - super::lehmer_dims(self.num_tasks()) + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.num_tasks() - variable) } } 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..1710eb310 100644 --- a/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -31,7 +31,7 @@ inventory::submit! { /// This is the weighted generalization of minimizing the number of tardy tasks /// (problem SS8 in Garey & Johnson, 1979, written $1 || sum w_j U_j$). /// -/// Configurations are direct permutation encodings with `dims() = [n; n]`: +/// Configurations are direct permutation encodings with `coordinate cardinalities = [n; n]`: /// each position holds the index of the task scheduled at that position. /// A configuration is valid iff it is a permutation of `0..n`. #[derive(Debug, Clone, Serialize)] @@ -221,9 +221,12 @@ impl Problem for SequencingToMinimizeTardyTaskWeight { } impl crate::solvers::BruteForceProblem for SequencingToMinimizeTardyTaskWeight { - fn dimensions(&self) -> Vec { - let n = self.num_tasks(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_tasks()) } } @@ -260,3 +263,41 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Lengths" }, +crate::registry::FieldInfo { name: "weights", type_name: "Option>", description: "Weights" }, +crate::registry::FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadlines" }, +crate::registry::FieldInfo { name: "bound", type_name: "i64", description: "Decision objective bound" }, +], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_sequencing_to_minimize_tardy_task_weight_to_sequencing_to_minimize_tardy_task_weight", + build: || { + let source = crate::models::decision::Decision::new(SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ), 3); + let witness = serde_json::json!(vec![3, 0, 4, 2, 1]); + crate::example_db::specs::rule_example_with_witness::<_, SequencingToMinimizeTardyTaskWeight>( + source, + crate::export::SolutionPair { source_config: witness.clone(), target_config: witness }, + ) + }, + }] +} diff --git a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 05cd17d62..eb8143890 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -35,7 +35,7 @@ inventory::submit! { /// and minimizes `sum_t w(t) * C(t)`, where `C(t)` is the completion time of /// task `t`. /// -/// Configurations use Lehmer code with `dims() = [n, n-1, ..., 1]`. +/// Configurations use Lehmer code with `coordinate cardinalities = [n, n-1, ..., 1]`. #[derive(Debug, Clone, Serialize)] pub struct SequencingToMinimizeWeightedCompletionTime { lengths: Vec, @@ -256,8 +256,12 @@ impl Problem for SequencingToMinimizeWeightedCompletionTime { } impl crate::solvers::BruteForceProblem for SequencingToMinimizeWeightedCompletionTime { - fn dimensions(&self) -> Vec { - super::lehmer_dims(self.num_tasks()) + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.num_tasks() - variable) } } diff --git a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs index 08b6d8419..0be845151 100644 --- a/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -52,6 +52,7 @@ inventory::submit! { /// assert!(solver.solve(&problem).unwrap().is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SequencingToMinimizeWeightedTardinessCreateSpec")] pub struct SequencingToMinimizeWeightedTardiness { lengths: Vec, weights: Vec, @@ -103,35 +104,39 @@ impl SequencingToMinimizeWeightedTardiness { /// /// Panics if the input vectors do not have the same length. pub fn new(lengths: Vec, weights: Vec, deadlines: Vec, bound: i64) -> Self { - assert_eq!( - lengths.len(), - weights.len(), - "weights length must equal lengths length" - ); - assert_eq!( - lengths.len(), - deadlines.len(), - "deadlines length must equal lengths length" - ); - assert!( - lengths.iter().all(|&length| length >= 0), - "task lengths must be nonnegative" - ); - assert!( - weights.iter().all(|&weight| weight >= 0), - "task weights must be nonnegative" - ); - assert!( - deadlines.iter().all(|&deadline| deadline >= 0), - "deadlines must be nonnegative" - ); - assert!(bound >= 0, "bound must be nonnegative"); - Self { + Self::try_new(lengths, weights, deadlines, bound).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + lengths: Vec, + weights: Vec, + deadlines: Vec, + bound: i64, + ) -> Result { + if lengths.len() != weights.len() { + return Err("weights length must equal lengths length".into()); + } + if lengths.len() != deadlines.len() { + return Err("deadlines length must equal lengths length".into()); + } + if !(lengths.iter().all(|&length| length >= 0)) { + return Err("task lengths must be nonnegative".into()); + } + if !(weights.iter().all(|&weight| weight >= 0)) { + return Err("task weights must be nonnegative".into()); + } + if !(deadlines.iter().all(|&deadline| deadline >= 0)) { + return Err("deadlines must be nonnegative".into()); + } + if !(bound >= 0) { + return Err("bound must be nonnegative".into()); + } + Ok(Self { lengths, weights, deadlines, bound, - } + }) } /// Returns the job lengths. @@ -249,8 +254,12 @@ impl Problem for SequencingToMinimizeWeightedTardiness { } impl crate::solvers::BruteForceProblem for SequencingToMinimizeWeightedTardiness { - fn dimensions(&self) -> Vec { - super::lehmer_dims(self.num_tasks()) + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.num_tasks() - variable) } } diff --git a/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs b/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs index 568c4f3df..e1e56be82 100644 --- a/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs +++ b/src/models/misc/sequencing_with_deadlines_and_set_up_times.rs @@ -39,7 +39,7 @@ inventory::submit! { /// This is problem SS14 in Garey & Johnson (1979), written /// $1 | s_{ij} | \text{feasibility}$. /// -/// Configurations are direct permutation encodings with `dims() = [n; n]`: +/// Configurations are direct permutation encodings with `coordinate cardinalities = [n; n]`: /// each position holds the index of the task scheduled at that position. /// A configuration is valid iff it is a permutation of `0..n`. #[derive(Debug, Clone, Serialize)] @@ -239,9 +239,12 @@ impl Problem for SequencingWithDeadlinesAndSetUpTimes { } impl crate::solvers::BruteForceProblem for SequencingWithDeadlinesAndSetUpTimes { - fn dimensions(&self) -> Vec { - let n = self.num_tasks(); - vec![n; n] + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_tasks()) } } diff --git a/src/models/misc/sequencing_with_release_times_and_deadlines.rs b/src/models/misc/sequencing_with_release_times_and_deadlines.rs index 3bc6449ea..b96aa1a31 100644 --- a/src/models/misc/sequencing_with_release_times_and_deadlines.rs +++ b/src/models/misc/sequencing_with_release_times_and_deadlines.rs @@ -37,7 +37,7 @@ inventory::submit! { /// /// Uses a permutation encoding (Lehmer code), where `config[i]` selects which /// remaining task to schedule next from the pool of unscheduled tasks. -/// `dims() = [n, n-1, ..., 2, 1]`. Tasks are scheduled left-to-right: each +/// `coordinate cardinalities = [n, n-1, ..., 2, 1]`. Tasks are scheduled left-to-right: each /// task starts at `max(release_time, current_time)`. The schedule is feasible /// iff every task finishes by its deadline. /// @@ -57,12 +57,29 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SequencingWithReleaseTimesAndDeadlinesData")] pub struct SequencingWithReleaseTimesAndDeadlines { lengths: Vec, release_times: Vec, deadlines: Vec, } +#[derive(Deserialize)] +struct SequencingWithReleaseTimesAndDeadlinesData { + lengths: Vec, + release_times: Vec, + deadlines: Vec, +} + +impl TryFrom + for SequencingWithReleaseTimesAndDeadlines +{ + type Error = crate::registry::ConstructionError; + fn try_from(data: SequencingWithReleaseTimesAndDeadlinesData) -> Result { + Self::try_new(data.lengths, data.release_times, data.deadlines) + } +} + impl SequencingWithReleaseTimesAndDeadlines { /// Create a new instance. /// @@ -70,25 +87,34 @@ impl SequencingWithReleaseTimesAndDeadlines { /// /// Panics if the three vectors have different lengths. pub fn new(lengths: Vec, release_times: Vec, deadlines: Vec) -> Self { - assert_eq!(lengths.len(), release_times.len()); - assert_eq!(lengths.len(), deadlines.len()); - assert!( - lengths.iter().all(|&length| length >= 0), - "task lengths must be nonnegative" - ); - assert!( - release_times.iter().all(|&release| release >= 0), - "release times must be nonnegative" - ); - assert!( - deadlines.iter().all(|&deadline| deadline >= 0), - "deadlines must be nonnegative" - ); - Self { + Self::try_new(lengths, release_times, deadlines).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + lengths: Vec, + release_times: Vec, + deadlines: Vec, + ) -> Result { + if lengths.len() != release_times.len() { + return Err("lengths and release_times must have the same length".into()); + } + if lengths.len() != deadlines.len() { + return Err("lengths and deadlines must have the same length".into()); + } + if !(lengths.iter().all(|&length| length >= 0)) { + return Err("task lengths must be nonnegative".into()); + } + if !(release_times.iter().all(|&release| release >= 0)) { + return Err("release times must be nonnegative".into()); + } + if !(deadlines.iter().all(|&deadline| deadline >= 0)) { + return Err("deadlines must be nonnegative".into()); + } + Ok(Self { lengths, release_times, deadlines, - } + }) } /// Returns the processing times. @@ -167,8 +193,12 @@ impl Problem for SequencingWithReleaseTimesAndDeadlines { } impl crate::solvers::BruteForceProblem for SequencingWithReleaseTimesAndDeadlines { - fn dimensions(&self) -> Vec { - super::lehmer_dims(self.num_tasks()) + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.num_tasks() - variable) } } diff --git a/src/models/misc/sequencing_within_intervals.rs b/src/models/misc/sequencing_within_intervals.rs index dd7d3deb3..8119c48d7 100644 --- a/src/models/misc/sequencing_within_intervals.rs +++ b/src/models/misc/sequencing_within_intervals.rs @@ -261,8 +261,15 @@ impl Problem for SequencingWithinIntervals { } impl crate::solvers::BruteForceProblem for SequencingWithinIntervals { - fn dimensions(&self) -> Vec { - self.start_slot_counts().collect() + fn num_variables(&self) -> Result { + Ok(self.num_tasks()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self + .start_slot_counts() + .nth(variable) + .expect("coordinate index is in range")) } } diff --git a/src/models/misc/shortest_common_supersequence.rs b/src/models/misc/shortest_common_supersequence.rs index b158a5347..e91462df9 100644 --- a/src/models/misc/shortest_common_supersequence.rs +++ b/src/models/misc/shortest_common_supersequence.rs @@ -55,12 +55,27 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ShortestCommonSupersequenceData")] pub struct ShortestCommonSupersequence { alphabet_size: usize, strings: Vec>, max_length: usize, } +#[derive(Deserialize)] +struct ShortestCommonSupersequenceData { + alphabet_size: usize, + strings: Vec>, +} + +impl TryFrom for ShortestCommonSupersequence { + type Error = crate::registry::ConstructionError; + + fn try_from(data: ShortestCommonSupersequenceData) -> Result { + Self::try_new(data.alphabet_size, data.strings) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct ShortestCommonSupersequenceCreateSpec { /// Input strings; the alphabet and maximum length are inferred from them. @@ -114,17 +129,29 @@ impl ShortestCommonSupersequence { /// Panics if `strings` is empty, or if `alphabet_size` is 0 and any input /// string is non-empty. pub fn new(alphabet_size: usize, strings: Vec>) -> Self { - assert!(!strings.is_empty(), "must have at least one string"); - let max_length: usize = strings.iter().map(|s| s.len()).sum(); - assert!( - alphabet_size > 0 || strings.iter().all(|s| s.is_empty()), - "alphabet_size must be > 0 when any input string is non-empty" - ); - Self { + Self::try_new(alphabet_size, strings).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + alphabet_size: usize, + strings: Vec>, + ) -> Result { + if strings.is_empty() { + return Err("must have at least one string".into()); + } + let max_length = strings.iter().try_fold(0usize, |total, string| { + total + .checked_add(string.len()) + .ok_or("maximum string length overflows usize") + })?; + if !(alphabet_size > 0 || strings.iter().all(|s| s.is_empty())) { + return Err("alphabet_size must be > 0 when any input string is non-empty".into()); + } + Ok(Self { alphabet_size, strings, max_length, - } + }) } /// Returns the alphabet size. @@ -238,8 +265,14 @@ impl Problem for ShortestCommonSupersequence { } impl crate::solvers::BruteForceProblem for ShortestCommonSupersequence { - fn dimensions(&self) -> Vec { - vec![self.alphabet_size + 1; self.max_length] + fn num_variables(&self) -> Result { + Ok(self.max_length) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.alphabet_size).checked_add(1usize).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/misc/shortest_common_superstring.rs b/src/models/misc/shortest_common_superstring.rs index 81fee2a1c..9f00fabc7 100644 --- a/src/models/misc/shortest_common_superstring.rs +++ b/src/models/misc/shortest_common_superstring.rs @@ -63,12 +63,27 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ShortestCommonSuperstringData")] pub struct ShortestCommonSuperstring { alphabet_size: usize, strings: Vec>, max_length: usize, } +#[derive(Deserialize)] +struct ShortestCommonSuperstringData { + alphabet_size: usize, + strings: Vec>, +} + +impl TryFrom for ShortestCommonSuperstring { + type Error = crate::registry::ConstructionError; + + fn try_from(data: ShortestCommonSuperstringData) -> Result { + Self::try_new(data.alphabet_size, data.strings) + } +} + impl ShortestCommonSuperstring { /// Create a new ShortestCommonSuperstring instance. /// @@ -80,17 +95,29 @@ impl ShortestCommonSuperstring { /// Panics if `strings` is empty, or if `alphabet_size` is 0 and any input /// string is non-empty. pub fn new(alphabet_size: usize, strings: Vec>) -> Self { - assert!(!strings.is_empty(), "must have at least one string"); - let max_length: usize = strings.iter().map(|s| s.len()).sum(); - assert!( - alphabet_size > 0 || strings.iter().all(|s| s.is_empty()), - "alphabet_size must be > 0 when any input string is non-empty" - ); - Self { + Self::try_new(alphabet_size, strings).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + alphabet_size: usize, + strings: Vec>, + ) -> Result { + if strings.is_empty() { + return Err("must have at least one string".into()); + } + let max_length = strings.iter().try_fold(0usize, |total, string| { + total + .checked_add(string.len()) + .ok_or("maximum string length overflows usize") + })?; + if !(alphabet_size > 0 || strings.iter().all(|s| s.is_empty())) { + return Err("alphabet_size must be > 0 when any input string is non-empty".into()); + } + Ok(Self { alphabet_size, strings, max_length, - } + }) } /// Returns the alphabet size. @@ -202,8 +229,14 @@ impl Problem for ShortestCommonSuperstring { } impl crate::solvers::BruteForceProblem for ShortestCommonSuperstring { - fn dimensions(&self) -> Vec { - vec![self.alphabet_size + 1; self.max_length] + fn num_variables(&self) -> Result { + Ok(self.max_length) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.alphabet_size).checked_add(1usize).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/misc/square_tiling.rs b/src/models/misc/square_tiling.rs index 4f8b4b951..aaf699686 100644 --- a/src/models/misc/square_tiling.rs +++ b/src/models/misc/square_tiling.rs @@ -207,8 +207,14 @@ impl Problem for SquareTiling { } impl crate::solvers::BruteForceProblem for SquareTiling { - fn dimensions(&self) -> Vec { - vec![self.tiles.len(); self.grid_size * self.grid_size] + fn num_variables(&self) -> Result { + (self.grid_size).checked_mul(self.grid_size).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.tiles.len()) } } diff --git a/src/models/misc/stacker_crane.rs b/src/models/misc/stacker_crane.rs index 6d56ca1b8..3d8744b5d 100644 --- a/src/models/misc/stacker_crane.rs +++ b/src/models/misc/stacker_crane.rs @@ -368,8 +368,12 @@ impl Problem for StackerCrane { } impl crate::solvers::BruteForceProblem for StackerCrane { - fn dimensions(&self) -> Vec { - vec![self.num_arcs(); self.num_arcs()] + fn num_variables(&self) -> Result { + Ok(self.num_arcs()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_arcs()) } } @@ -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: "Decision objective bound" }, + ], + decode: |_, indices: Vec| indices +); + +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_stacker_crane_to_stacker_crane", + build: || { + let source = crate::models::decision::Decision::new( + StackerCrane::new( + 6, + vec![(0, 4), (2, 5), (5, 1), (3, 0), (4, 3)], + vec![(0, 1), (1, 2), (2, 3), (3, 5), (4, 5), (0, 3), (1, 5)], + vec![3, 4, 2, 5, 3], + vec![2, 1, 3, 2, 1, 4, 3], + ), + 20, + ); + let witness = serde_json::json!(vec![0, 2, 1, 4, 3]); + crate::example_db::specs::rule_example_with_witness::<_, StackerCrane>( + source, + crate::export::SolutionPair { + source_config: witness.clone(), + target_config: witness, + }, + ) + }, + }] +} diff --git a/src/models/misc/staff_scheduling.rs b/src/models/misc/staff_scheduling.rs index 1d5d60a0d..b41a3d3a3 100644 --- a/src/models/misc/staff_scheduling.rs +++ b/src/models/misc/staff_scheduling.rs @@ -27,6 +27,7 @@ inventory::submit! { /// pattern. A configuration is satisfying iff the total assigned workers does /// not exceed `num_workers` and every period's staffing requirement is met. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "StaffSchedulingData")] pub struct StaffScheduling { shifts_per_schedule: usize, schedules: Vec>, @@ -34,6 +35,27 @@ pub struct StaffScheduling { num_workers: i64, } +#[derive(Deserialize)] +struct StaffSchedulingData { + shifts_per_schedule: usize, + schedules: Vec>, + requirements: Vec, + num_workers: i64, +} + +impl TryFrom for StaffScheduling { + type Error = crate::registry::ConstructionError; + + fn try_from(data: StaffSchedulingData) -> Result { + Self::try_new( + data.shifts_per_schedule, + data.schedules, + data.requirements, + data.num_workers, + ) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct StaffSchedulingCreateSpec { /// Required number of active periods in each schedule pattern. @@ -50,14 +72,8 @@ impl TryFrom for StaffScheduling { type Error = crate::registry::ConstructionError; fn try_from(spec: StaffSchedulingCreateSpec) -> Result { - if usize::try_from(spec.num_workers) - .ok() - .and_then(|workers| workers.checked_add(1)) - .is_none() - { - return Err("num_workers must be nonnegative and encodable by dims()" - .to_string() - .into()); + if spec.num_workers < 0 { + return Err("num_workers must be nonnegative".into()); } for (schedule_index, schedule) in spec.schedules.iter().enumerate() { if schedule.len() != spec.requirements.len() { @@ -77,12 +93,7 @@ impl TryFrom for StaffScheduling { .into()); } } - Ok(Self::new( - spec.k, - spec.schedules, - spec.requirements, - spec.num_workers, - )) + Self::try_new(spec.k, spec.schedules, spec.requirements, spec.num_workers) } } @@ -101,38 +112,47 @@ impl StaffScheduling { requirements: Vec, num_workers: i64, ) -> Self { - assert!( - usize::try_from(num_workers) - .ok() - .and_then(|workers| workers.checked_add(1)) - .is_some(), - "num_workers must be nonnegative and encodable by dims()" - ); + Self::try_new(shifts_per_schedule, schedules, requirements, num_workers) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + shifts_per_schedule: usize, + schedules: Vec>, + requirements: Vec, + num_workers: i64, + ) -> Result { + if !(num_workers >= 0) { + return Err("num_workers must be nonnegative".into()); + } let num_periods = requirements.len(); for (index, schedule) in schedules.iter().enumerate() { - assert_eq!( - schedule.len(), - num_periods, - "schedule {} has {} periods, expected {}", - index, - schedule.len(), - num_periods - ); + if schedule.len() != num_periods { + return Err(format!( + "schedule {} has {} periods, expected {}", + index, + schedule.len(), + num_periods + ) + .into()); + } let ones = schedule.iter().filter(|&&active| active).count(); - assert_eq!( - ones, shifts_per_schedule, - "schedule {} has {} active periods, expected {}", - index, ones, shifts_per_schedule - ); + if ones != shifts_per_schedule { + return Err(format!( + "schedule {} has {} active periods, expected {}", + index, ones, shifts_per_schedule + ) + .into()); + } } - Self { + Ok(Self { shifts_per_schedule, schedules, requirements, num_workers, - } + }) } /// Get the number of periods. @@ -165,13 +185,10 @@ impl StaffScheduling { self.schedules.len() } - fn worker_limit(&self) -> usize { - usize::try_from(self.num_workers) - .expect("validated nonnegative worker count must fit usize") - } - fn worker_counts_valid(&self, config: &[usize]) -> bool { - config.iter().all(|&count| count <= self.worker_limit()) + config + .iter() + .all(|&count| count as i128 <= i128::from(self.num_workers)) } fn within_budget(&self, config: &[usize]) -> Result { @@ -255,8 +272,12 @@ impl Problem for StaffScheduling { } impl crate::solvers::BruteForceProblem for StaffScheduling { - fn dimensions(&self) -> Vec { - vec![self.worker_limit() + 1; self.num_schedules()] + fn num_variables(&self) -> Result { + Ok(self.num_schedules()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(usize::try_from(i128::from(self.num_workers) + 1)?) } } diff --git a/src/models/misc/string_to_string_correction.rs b/src/models/misc/string_to_string_correction.rs index 0343806e8..e14395ec5 100644 --- a/src/models/misc/string_to_string_correction.rs +++ b/src/models/misc/string_to_string_correction.rs @@ -66,6 +66,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "StringToStringCorrectionData")] pub struct StringToStringCorrection { alphabet_size: usize, source: Vec, @@ -73,6 +74,22 @@ pub struct StringToStringCorrection { bound: usize, } +#[derive(Deserialize)] +struct StringToStringCorrectionData { + alphabet_size: usize, + source: Vec, + target: Vec, + bound: usize, +} + +impl TryFrom for StringToStringCorrection { + type Error = crate::registry::ConstructionError; + + fn try_from(data: StringToStringCorrectionData) -> Result { + Self::try_new(data.alphabet_size, data.source, data.target, data.bound) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct StringToStringCorrectionCreateSpec { /// Optional alphabet size; omitted values are inferred from both strings. @@ -133,24 +150,31 @@ impl StringToStringCorrection { /// non-empty, or if any symbol in `source` or `target` is /// `>= alphabet_size`. pub fn new(alphabet_size: usize, source: Vec, target: Vec, bound: usize) -> Self { - assert!( - alphabet_size > 0 || (source.is_empty() && target.is_empty()), - "alphabet_size must be > 0 when source or target is non-empty" - ); - assert!( - source.iter().all(|&s| s < alphabet_size), - "all source symbols must be < alphabet_size" - ); - assert!( - target.iter().all(|&s| s < alphabet_size), - "all target symbols must be < alphabet_size" - ); - Self { + Self::try_new(alphabet_size, source, target, bound) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + alphabet_size: usize, + source: Vec, + target: Vec, + bound: usize, + ) -> Result { + if !(alphabet_size > 0 || (source.is_empty() && target.is_empty())) { + return Err("alphabet_size must be > 0 when source or target is non-empty".into()); + } + if !(source.iter().all(|&s| s < alphabet_size)) { + return Err("all source symbols must be < alphabet_size".into()); + } + if !(target.iter().all(|&s| s < alphabet_size)) { + return Err("all target symbols must be < alphabet_size".into()); + } + Ok(Self { alphabet_size, source, target, bound, - } + }) } /// Returns the alphabet size. @@ -246,8 +270,12 @@ impl Problem for StringToStringCorrection { } impl crate::solvers::BruteForceProblem for StringToStringCorrection { - fn dimensions(&self) -> Vec { - vec![2 * self.source.len() + 1; self.bound] + fn num_variables(&self) -> Result { + Ok(self.bound) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2 * self.source.len() + 1) } } diff --git a/src/models/misc/subset_product.rs b/src/models/misc/subset_product.rs index 71cd0f4c1..1eb642982 100644 --- a/src/models/misc/subset_product.rs +++ b/src/models/misc/subset_product.rs @@ -51,6 +51,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SubsetProductData")] pub struct SubsetProduct { #[serde(with = "super::biguint_serde::decimal_biguint_vec")] sizes: Vec, @@ -58,6 +59,30 @@ pub struct SubsetProduct { target: BigUint, } +#[derive(Deserialize)] +struct SubsetProductData { + #[serde(with = "super::biguint_serde::decimal_biguint_vec")] + sizes: Vec, + #[serde(with = "super::biguint_serde::decimal_biguint")] + target: BigUint, +} + +impl TryFrom for SubsetProduct { + type Error = crate::registry::ConstructionError; + fn try_from(data: SubsetProductData) -> Result { + if data.sizes.iter().any(BigUint::is_zero) { + return Err("all sizes must be positive (> 0)".into()); + } + if data.target.is_zero() { + return Err("SubsetProduct target must be positive".into()); + } + Ok(Self { + sizes: data.sizes, + target: data.target, + }) + } +} + impl SubsetProduct { /// Create a new SubsetProduct instance. /// @@ -69,19 +94,22 @@ impl SubsetProduct { S: ToBigUint, T: ToBigUint, { - let sizes: Vec = sizes + Self::try_new(sizes, target).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(sizes: Vec, target: T) -> Result + where + S: ToBigUint, + T: ToBigUint, + { + let sizes = sizes .into_iter() - .map(|s| s.to_biguint().expect("All sizes must be positive (> 0)")) - .collect(); - assert!( - sizes.iter().all(|s| !s.is_zero()), - "All sizes must be positive (> 0)" - ); + .map(|size| size.to_biguint().ok_or("all sizes must be positive (> 0)")) + .collect::, _>>()?; let target = target .to_biguint() - .expect("SubsetProduct target must be nonnegative"); - assert!(!target.is_zero(), "SubsetProduct target must be positive"); - Self { sizes, target } + .ok_or("SubsetProduct target must be nonnegative")?; + SubsetProductData { sizes, target }.try_into() } /// Create a SubsetProduct without validating sizes (for testing edge cases). @@ -144,8 +172,12 @@ impl Problem for SubsetProduct { } impl crate::solvers::BruteForceProblem for SubsetProduct { - fn dimensions(&self) -> Vec { - vec![2; self.num_elements()] + fn num_variables(&self) -> Result { + Ok(self.num_elements()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/subset_sum.rs b/src/models/misc/subset_sum.rs index 95611a696..3d7f302e7 100644 --- a/src/models/misc/subset_sum.rs +++ b/src/models/misc/subset_sum.rs @@ -51,6 +51,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SubsetSumData")] pub struct SubsetSum { #[serde(with = "super::biguint_serde::decimal_biguint_vec")] sizes: Vec, @@ -58,6 +59,27 @@ pub struct SubsetSum { target: BigUint, } +#[derive(Deserialize)] +struct SubsetSumData { + #[serde(with = "super::biguint_serde::decimal_biguint_vec")] + sizes: Vec, + #[serde(with = "super::biguint_serde::decimal_biguint")] + target: BigUint, +} + +impl TryFrom for SubsetSum { + type Error = crate::registry::ConstructionError; + fn try_from(data: SubsetSumData) -> Result { + if data.sizes.iter().any(BigUint::is_zero) { + return Err("all sizes must be positive (> 0)".into()); + } + Ok(Self { + sizes: data.sizes, + target: data.target, + }) + } +} + impl SubsetSum { /// Create a new SubsetSum instance. /// @@ -69,18 +91,22 @@ impl SubsetSum { S: ToBigUint, T: ToBigUint, { - let sizes: Vec = sizes + Self::try_new(sizes, target).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new(sizes: Vec, target: T) -> Result + where + S: ToBigUint, + T: ToBigUint, + { + let sizes = sizes .into_iter() - .map(|s| s.to_biguint().expect("All sizes must be positive (> 0)")) - .collect(); - assert!( - sizes.iter().all(|s| !s.is_zero()), - "All sizes must be positive (> 0)" - ); + .map(|size| size.to_biguint().ok_or("all sizes must be positive (> 0)")) + .collect::, _>>()?; let target = target .to_biguint() - .expect("SubsetSum target must be nonnegative"); - Self { sizes, target } + .ok_or("SubsetSum target must be nonnegative")?; + SubsetSumData { sizes, target }.try_into() } /// Create a new SubsetSum instance without validating sizes. @@ -142,8 +168,12 @@ impl Problem for SubsetSum { } impl crate::solvers::BruteForceProblem for SubsetSum { - fn dimensions(&self) -> Vec { - vec![2; self.num_elements()] + fn num_variables(&self) -> Result { + Ok(self.num_elements()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/misc/sum_of_squares_partition.rs b/src/models/misc/sum_of_squares_partition.rs index 0edae8f31..5275aa009 100644 --- a/src/models/misc/sum_of_squares_partition.rs +++ b/src/models/misc/sum_of_squares_partition.rs @@ -197,8 +197,12 @@ impl Problem for SumOfSquaresPartition { } impl crate::solvers::BruteForceProblem for SumOfSquaresPartition { - fn dimensions(&self) -> Vec { - vec![self.num_groups; self.sizes.len()] + fn num_variables(&self) -> Result { + Ok(self.sizes.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_groups) } } diff --git a/src/models/misc/three_partition.rs b/src/models/misc/three_partition.rs index 9a57d336c..81739756f 100644 --- a/src/models/misc/three_partition.rs +++ b/src/models/misc/three_partition.rs @@ -214,8 +214,12 @@ impl Problem for ThreePartition { } impl crate::solvers::BruteForceProblem for ThreePartition { - fn dimensions(&self) -> Vec { - vec![self.num_groups(); self.num_elements()] + fn num_variables(&self) -> Result { + Ok(self.num_elements()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.num_groups()) } } diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index b96bbe294..40d9f934a 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -27,6 +27,7 @@ inventory::submit! { /// task-next, period-last order: /// `idx = ((c * num_tasks) + t) * num_periods + h`. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "TimetableDesignCreateSpec")] pub struct TimetableDesign { num_periods: usize, num_craftsmen: usize, @@ -117,14 +118,14 @@ impl TryFrom for TimetableDesign { ) .into()); } - Ok(Self::new( + Self::try_new( spec.num_periods, spec.num_craftsmen, spec.num_tasks, spec.craftsman_avail, spec.task_avail, spec.requirements, - )) + ) } } @@ -142,68 +143,93 @@ impl TimetableDesign { task_avail: Vec>, requirements: Vec>, ) -> Self { - assert_eq!( - craftsman_avail.len(), + Self::try_new( + num_periods, num_craftsmen, - "craftsman_avail has {} rows, expected {}", - craftsman_avail.len(), - num_craftsmen - ); + num_tasks, + craftsman_avail, + task_avail, + requirements, + ) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_periods: usize, + num_craftsmen: usize, + num_tasks: usize, + craftsman_avail: Vec>, + task_avail: Vec>, + requirements: Vec>, + ) -> Result { + if craftsman_avail.len() != num_craftsmen { + return Err(format!( + "craftsman_avail has {} rows, expected {}", + craftsman_avail.len(), + num_craftsmen + ) + .into()); + } for (craftsman, row) in craftsman_avail.iter().enumerate() { - assert_eq!( - row.len(), - num_periods, - "craftsman {} availability has {} periods, expected {}", - craftsman, - row.len(), - num_periods - ); + if row.len() != num_periods { + return Err(format!( + "craftsman {} availability has {} periods, expected {}", + craftsman, + row.len(), + num_periods + ) + .into()); + } } - assert_eq!( - task_avail.len(), - num_tasks, - "task_avail has {} rows, expected {}", - task_avail.len(), - num_tasks - ); + if task_avail.len() != num_tasks { + return Err(format!( + "task_avail has {} rows, expected {}", + task_avail.len(), + num_tasks + ) + .into()); + } for (task, row) in task_avail.iter().enumerate() { - assert_eq!( - row.len(), - num_periods, - "task {} availability has {} periods, expected {}", - task, - row.len(), - num_periods - ); + if row.len() != num_periods { + return Err(format!( + "task {} availability has {} periods, expected {}", + task, + row.len(), + num_periods + ) + .into()); + } } - assert_eq!( - requirements.len(), - num_craftsmen, - "requirements has {} rows, expected {}", - requirements.len(), - num_craftsmen - ); + if requirements.len() != num_craftsmen { + return Err(format!( + "requirements has {} rows, expected {}", + requirements.len(), + num_craftsmen + ) + .into()); + } for (craftsman, row) in requirements.iter().enumerate() { - assert_eq!( - row.len(), - num_tasks, - "requirements row {} has {} tasks, expected {}", - craftsman, - row.len(), - num_tasks - ); + if row.len() != num_tasks { + return Err(format!( + "requirements row {} has {} tasks, expected {}", + craftsman, + row.len(), + num_tasks + ) + .into()); + } } - Self { + Ok(Self { num_periods, num_craftsmen, num_tasks, craftsman_avail, task_avail, requirements, - } + }) } /// Get the number of periods. @@ -475,8 +501,12 @@ impl Problem for TimetableDesign { } impl crate::solvers::BruteForceProblem for TimetableDesign { - fn dimensions(&self) -> Vec { - vec![2; self.config_len()] + fn num_variables(&self) -> Result { + Ok(self.config_len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 21220aa30..b01593468 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -43,9 +43,8 @@ pub use graph::{ PartialFeedbackEdgeSet, PartitionIntoCliques, PartitionIntoForests, PartitionIntoPathsOfLength2, PartitionIntoPerfectMatchings, PartitionIntoTriangles, PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, - SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, - SubgraphIsomorphism, TravelingSalesman, UndirectedFlowLowerBounds, - UndirectedTwoCommodityIntegralFlow, + SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, + UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/models/set/comparative_containment.rs b/src/models/set/comparative_containment.rs index f628c9744..398136d5c 100644 --- a/src/models/set/comparative_containment.rs +++ b/src/models/set/comparative_containment.rs @@ -340,8 +340,12 @@ impl crate::solvers::BruteForceProblem for ComparativeContainment where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.universe_size] + fn num_variables(&self) -> Result { + Ok(self.universe_size) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/consecutive_sets.rs b/src/models/set/consecutive_sets.rs index 9fc0aa171..a15167f65 100644 --- a/src/models/set/consecutive_sets.rs +++ b/src/models/set/consecutive_sets.rs @@ -71,6 +71,7 @@ inventory::submit! { /// .unwrap()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ConsecutiveSetsData")] pub struct ConsecutiveSets { /// Size of the alphabet (elements are 0..alphabet_size-1). alphabet_size: usize, @@ -80,6 +81,21 @@ pub struct ConsecutiveSets { bound_k: usize, } +#[derive(Deserialize)] +struct ConsecutiveSetsData { + alphabet_size: usize, + subsets: Vec>, + bound_k: usize, +} + +impl TryFrom for ConsecutiveSets { + type Error = crate::registry::ConstructionError; + + fn try_from(data: ConsecutiveSetsData) -> Result { + Self::try_new(data.alphabet_size, data.subsets, data.bound_k) + } +} + impl ConsecutiveSets { /// Create a new Consecutive Sets problem. /// @@ -88,31 +104,34 @@ impl ConsecutiveSets { /// Panics if `bound_k` is zero, if any subset contains duplicate elements, /// or if any element is outside the alphabet. pub fn new(alphabet_size: usize, subsets: Vec>, bound_k: usize) -> Self { - assert!(bound_k > 0, "bound_k must be positive, got 0"); - let mut subsets = subsets; - for (i, subset) in subsets.iter_mut().enumerate() { + Self::try_new(alphabet_size, subsets, bound_k).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + alphabet_size: usize, + mut subsets: Vec>, + bound_k: usize, + ) -> Result { + if bound_k == 0 { + return Err("bound_k must be positive, got 0".into()); + } + for (index, subset) in subsets.iter_mut().enumerate() { let mut seen = HashSet::with_capacity(subset.len()); - for &elem in subset.iter() { - assert!( - elem < alphabet_size, - "Subset {} contains element {} which is outside alphabet of size {}", - i, - elem, - alphabet_size - ); - assert!( - seen.insert(elem), - "Subset {} contains duplicate elements", - i - ); + for &element in subset.iter() { + if element >= alphabet_size { + return Err(format!("subset {index} contains element {element} outside alphabet of size {alphabet_size}").into()); + } + if !seen.insert(element) { + return Err(format!("subset {index} contains duplicate elements").into()); + } } subset.sort(); } - Self { + Ok(Self { alphabet_size, subsets, bound_k, - } + }) } /// Get the alphabet size. @@ -244,9 +263,14 @@ impl Problem for ConsecutiveSets { } impl crate::solvers::BruteForceProblem for ConsecutiveSets { - fn dimensions(&self) -> Vec { - // Each position can be any symbol (0..alphabet_size-1) or "unused" (alphabet_size) - vec![self.alphabet_size + 1; self.bound_k] + fn num_variables(&self) -> Result { + Ok(self.bound_k) + } + + fn dimension(&self, _variable: usize) -> Result { + (self.alphabet_size).checked_add(1usize).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing a coordinate cardinality".into()) + }) } } diff --git a/src/models/set/exact_cover_by_3_sets.rs b/src/models/set/exact_cover_by_3_sets.rs index ac6f64852..9db5cb5e9 100644 --- a/src/models/set/exact_cover_by_3_sets.rs +++ b/src/models/set/exact_cover_by_3_sets.rs @@ -52,6 +52,7 @@ inventory::submit! { /// assert!(problem.evaluate(&solutions[0]).unwrap()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ExactCoverBy3SetsCreateSpec")] pub struct ExactCoverBy3Sets { /// Size of the universe (elements are 0..universe_size, must be divisible by 3). universe_size: usize, @@ -68,28 +69,8 @@ struct ExactCoverBy3SetsCreateSpec { impl TryFrom for ExactCoverBy3Sets { type Error = crate::registry::ConstructionError; - fn try_from(mut spec: ExactCoverBy3SetsCreateSpec) -> Result { - if !spec.universe_size.is_multiple_of(3) { - return Err("universe_size must be divisible by 3".into()); - } - for (index, subset) in spec.subsets.iter_mut().enumerate() { - if subset[0] == subset[1] || subset[0] == subset[2] || subset[1] == subset[2] { - return Err(format!("subset {index} contains duplicate elements").into()); - } - if let Some(&element) = subset - .iter() - .find(|&&element| element >= spec.universe_size) - { - return Err( - format!("subset {index} contains out-of-range element {element}").into(), - ); - } - subset.sort(); - } - Ok(Self { - universe_size: spec.universe_size, - subsets: spec.subsets, - }) + fn try_from(spec: ExactCoverBy3SetsCreateSpec) -> Result { + Self::try_new(spec.universe_size, spec.subsets) } } @@ -101,34 +82,33 @@ impl ExactCoverBy3Sets { /// Panics if `universe_size` is not divisible by 3, or if any subset /// contains duplicate elements or elements outside the universe. pub fn new(universe_size: usize, subsets: Vec<[usize; 3]>) -> Self { - assert!( - universe_size.is_multiple_of(3), - "Universe size must be divisible by 3, got {}", - universe_size - ); - let mut subsets = subsets; - for (i, subset) in subsets.iter_mut().enumerate() { - assert!( - subset[0] != subset[1] && subset[0] != subset[2] && subset[1] != subset[2], - "Subset {} contains duplicate elements: {:?}", - i, - subset + Self::try_new(universe_size, subsets).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + universe_size: usize, + mut subsets: Vec<[usize; 3]>, + ) -> Result { + if !universe_size.is_multiple_of(3) { + return Err( + format!("Universe size must be divisible by 3, got {universe_size}").into(), ); - for &elem in subset.iter() { - assert!( - elem < universe_size, - "Subset {} contains element {} which is outside universe of size {}", - i, - elem, - universe_size + } + for (index, subset) in subsets.iter_mut().enumerate() { + if subset[0] == subset[1] || subset[0] == subset[2] || subset[1] == subset[2] { + return Err(format!("subset {index} contains duplicate elements").into()); + } + if let Some(&element) = subset.iter().find(|&&element| element >= universe_size) { + return Err( + format!("Subset {index} contains element {element} which is outside universe of size {universe_size}").into(), ); } subset.sort(); } - Self { + Ok(Self { universe_size, subsets, - } + }) } /// Get the universe size. @@ -239,8 +219,12 @@ impl Problem for ExactCoverBy3Sets { } impl crate::solvers::BruteForceProblem for ExactCoverBy3Sets { - fn dimensions(&self) -> Vec { - vec![2; self.subsets.len()] + fn num_variables(&self) -> Result { + Ok(self.subsets.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/integer_knapsack.rs b/src/models/set/integer_knapsack.rs index 6080cbdff..a0a38ad05 100644 --- a/src/models/set/integer_knapsack.rs +++ b/src/models/set/integer_knapsack.rs @@ -5,7 +5,6 @@ use crate::registry::ConstructionError; use crate::registry::{FieldInfo, ProblemSchemaEntry}; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Max; use serde::{Deserialize, Serialize}; @@ -115,16 +114,6 @@ impl Problem for IntegerKnapsack { "multiplicity-vector length does not match the items".into(), )); } - let dims = self.dimensions(); - if config - .iter() - .zip(&dims) - .any(|(&count, &dimension)| count >= dimension) - { - return Err(crate::traits::EvaluationError::InvalidConfiguration( - "multiplicity vector contains an out-of-range count".into(), - )); - } let total_size = config .iter() .enumerate() @@ -174,15 +163,14 @@ impl Problem for IntegerKnapsack { } impl crate::solvers::BruteForceProblem for IntegerKnapsack { - fn dimensions(&self) -> Vec { - self.sizes - .iter() - .map(|&s| { - let dimension = i128::from(self.capacity) / i128::from(s) + 1; - usize::try_from(dimension) - .expect("validated integer-knapsack dimension must fit usize") - }) - .collect() + fn num_variables(&self) -> Result { + Ok(self.num_items()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(usize::try_from( + i128::from(self.capacity / self.sizes[variable]) + 1, + )?) } } @@ -239,15 +227,6 @@ impl TryFrom for IntegerKnapsack { raw.capacity ))); } - for &size in &raw.sizes { - let dimension = i128::from(raw.capacity) / i128::from(size) + 1; - usize::try_from(dimension).map_err(|_| { - ConstructionError::IntegerOverflow(format!( - "knapsack dimension for capacity {} and item size {size} does not fit usize", - raw.capacity - )) - })?; - } Ok(IntegerKnapsack { sizes: raw.sizes, values: raw.values, diff --git a/src/models/set/maximum_set_packing.rs b/src/models/set/maximum_set_packing.rs index 40542427c..798220bf5 100644 --- a/src/models/set/maximum_set_packing.rs +++ b/src/models/set/maximum_set_packing.rs @@ -225,8 +225,12 @@ impl crate::solvers::BruteForceProblem for MaximumSetPacking where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.sets.len()] + fn num_variables(&self) -> Result { + Ok(self.sets.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/minimum_cardinality_key.rs b/src/models/set/minimum_cardinality_key.rs index 89b3a21ac..896bc039e 100644 --- a/src/models/set/minimum_cardinality_key.rs +++ b/src/models/set/minimum_cardinality_key.rs @@ -31,6 +31,7 @@ inventory::submit! { /// find a subset `K ⊆ A` of minimum cardinality such that the closure of `K` /// under `F` equals `A` (i.e., `K` is a key). #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumCardinalityKeyData")] pub struct MinimumCardinalityKey { /// Number of attributes (elements are `0..num_attributes`). num_attributes: usize, @@ -38,6 +39,20 @@ pub struct MinimumCardinalityKey { dependencies: Vec<(Vec, Vec)>, } +#[derive(Deserialize)] +struct MinimumCardinalityKeyData { + num_attributes: usize, + dependencies: Vec<(Vec, Vec)>, +} + +impl TryFrom for MinimumCardinalityKey { + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumCardinalityKeyData) -> Result { + Self::try_new(data.num_attributes, data.dependencies) + } +} + impl MinimumCardinalityKey { /// Create a new Minimum Cardinality Key instance. /// @@ -45,27 +60,30 @@ impl MinimumCardinalityKey { /// /// Panics if any attribute index in a dependency lies outside the attribute set. pub fn new(num_attributes: usize, dependencies: Vec<(Vec, Vec)>) -> Self { - let mut dependencies = dependencies; - for (dep_index, (lhs, rhs)) in dependencies.iter_mut().enumerate() { + Self::try_new(num_attributes, dependencies).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_attributes: usize, + mut dependencies: Vec<(Vec, Vec)>, + ) -> Result { + for (index, (lhs, rhs)) in dependencies.iter_mut().enumerate() { lhs.sort_unstable(); lhs.dedup(); rhs.sort_unstable(); rhs.dedup(); - for &attr in lhs.iter().chain(rhs.iter()) { - assert!( - attr < num_attributes, - "Dependency {} contains attribute {} which is outside attribute set of size {}", - dep_index, - attr, - num_attributes - ); + if let Some(attribute) = lhs + .iter() + .chain(rhs.iter()) + .find(|&&attribute| attribute >= num_attributes) + { + return Err(format!("dependency {index} contains attribute {attribute} outside attribute set of size {num_attributes}").into()); } } - - Self { + Ok(Self { num_attributes, dependencies, - } + }) } /// Return the number of attributes. @@ -156,8 +174,12 @@ impl Problem for MinimumCardinalityKey { } impl crate::solvers::BruteForceProblem for MinimumCardinalityKey { - fn dimensions(&self) -> Vec { - vec![2; self.num_attributes] + fn num_variables(&self) -> Result { + Ok(self.num_attributes) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/minimum_hitting_set.rs b/src/models/set/minimum_hitting_set.rs index ca2aa38d2..5b2e35a6f 100644 --- a/src/models/set/minimum_hitting_set.rs +++ b/src/models/set/minimum_hitting_set.rs @@ -26,11 +26,26 @@ inventory::submit! { /// Given a universe `U` and a collection of subsets of `U`, find a minimum-size /// subset `H ⊆ U` such that `H` intersects every set in the collection. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MinimumHittingSetData")] pub struct MinimumHittingSet { universe_size: usize, sets: Vec>, } +#[derive(Deserialize)] +struct MinimumHittingSetData { + universe_size: usize, + sets: Vec>, +} + +impl TryFrom for MinimumHittingSet { + type Error = crate::registry::ConstructionError; + + fn try_from(data: MinimumHittingSetData) -> Result { + Self::try_new(data.universe_size, data.sets) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumHittingSetCreateSpec { /// Size of the universe U. @@ -52,7 +67,7 @@ impl TryFrom for MinimumHittingSet { .into()); } } - Ok(Self::new(spec.universe_size, spec.subsets)) + Self::try_new(spec.universe_size, spec.subsets) } } @@ -63,22 +78,24 @@ impl MinimumHittingSet { /// /// Panics if any set contains an element outside `0..universe_size`. pub fn new(universe_size: usize, sets: Vec>) -> Self { - let mut sets = sets; - for (set_index, set) in sets.iter_mut().enumerate() { + Self::try_new(universe_size, sets).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + universe_size: usize, + mut sets: Vec>, + ) -> Result { + for (index, set) in sets.iter_mut().enumerate() { set.sort_unstable(); set.dedup(); - for &element in set.iter() { - assert!( - element < universe_size, - "Set {set_index} contains element {element} which is outside universe of size {universe_size}" - ); + 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()); } } - - Self { + Ok(Self { universe_size, sets, - } + }) } /// Get the universe size. @@ -168,8 +185,12 @@ impl Problem for MinimumHittingSet { } impl crate::solvers::BruteForceProblem for MinimumHittingSet { - fn dimensions(&self) -> Vec { - vec![2; self.universe_size] + fn num_variables(&self) -> Result { + Ok(self.universe_size) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs index 81748fc00..5fd1b3d08 100644 --- a/src/models/set/minimum_set_covering.rs +++ b/src/models/set/minimum_set_covering.rs @@ -55,7 +55,7 @@ inventory::submit! { /// assert!(problem.evaluate(&sol).unwrap().is_valid()); /// } /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MinimumSetCovering { /// Size of the universe (elements are 0..universe_size). universe_size: usize, @@ -65,6 +65,21 @@ pub struct MinimumSetCovering { weights: Vec, } +#[derive(Deserialize)] +struct MinimumSetCoveringData { + universe_size: usize, + sets: Vec>, + weights: Vec, +} + +impl<'de, W: Clone + Default + Deserialize<'de>> Deserialize<'de> for MinimumSetCovering { + fn deserialize>(deserializer: D) -> Result { + let data = MinimumSetCoveringData::deserialize(deserializer)?; + Self::try_with_weights(data.universe_size, data.sets, data.weights) + .map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct MinimumSetCoveringCreateSpec { /// Size of the universe U. @@ -96,11 +111,7 @@ impl TryFrom for MinimumSetCovering { .into()); } } - Ok(Self::with_weights( - spec.universe_size, - spec.subsets, - spec.weights, - )) + Self::try_with_weights(spec.universe_size, spec.subsets, spec.weights) } } @@ -110,23 +121,49 @@ impl MinimumSetCovering { where W: WeightElement, { - let num_sets = sets.len(); - let weights = vec![W::unit(); num_sets]; - Self { - universe_size, - sets, - weights, - } + Self::try_new(universe_size, sets).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + universe_size: usize, + sets: Vec>, + ) -> Result + where + W: WeightElement, + { + let weights = vec![W::unit(); sets.len()]; + Self::try_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. @@ -223,8 +260,12 @@ impl crate::solvers::BruteForceProblem for MinimumSetCovering where W: WeightElement + crate::variant::VariantParam, { - fn dimensions(&self) -> Vec { - vec![2; self.sets.len()] + fn num_variables(&self) -> Result { + Ok(self.sets.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/prime_attribute_name.rs b/src/models/set/prime_attribute_name.rs index 42a3c1839..8258ab39d 100644 --- a/src/models/set/prime_attribute_name.rs +++ b/src/models/set/prime_attribute_name.rs @@ -60,6 +60,7 @@ inventory::submit! { /// assert!(solution.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "PrimeAttributeNameData")] pub struct PrimeAttributeName { /// Number of attributes (elements are 0..num_attributes). num_attributes: usize, @@ -69,6 +70,21 @@ pub struct PrimeAttributeName { query_attribute: usize, } +#[derive(Deserialize)] +struct PrimeAttributeNameData { + num_attributes: usize, + dependencies: Vec<(Vec, Vec)>, + query_attribute: usize, +} + +impl TryFrom for PrimeAttributeName { + type Error = crate::registry::ConstructionError; + + fn try_from(data: PrimeAttributeNameData) -> Result { + Self::try_new(data.num_attributes, data.dependencies, data.query_attribute) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct PrimeAttributeNameCreateSpec { /// Number of attributes. @@ -107,11 +123,7 @@ impl TryFrom for PrimeAttributeName { ).into()); } } - Ok(Self::new( - spec.universe_size, - spec.dependencies, - spec.query_attribute, - )) + Self::try_new(spec.universe_size, spec.dependencies, spec.query_attribute) } } @@ -127,29 +139,35 @@ impl PrimeAttributeName { dependencies: Vec<(Vec, Vec)>, query_attribute: usize, ) -> Self { - assert!( - query_attribute < num_attributes, - "Query attribute {} is outside attribute set of size {}", - query_attribute, - num_attributes - ); - for (i, (lhs, rhs)) in dependencies.iter().enumerate() { - assert!(!lhs.is_empty(), "Dependency {} has empty LHS", i); - for &attr in lhs.iter().chain(rhs.iter()) { - assert!( - attr < num_attributes, - "Dependency {} references attribute {} which is outside attribute set of size {}", - i, - attr, - num_attributes - ); + Self::try_new(num_attributes, dependencies, query_attribute) + .unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + num_attributes: usize, + dependencies: Vec<(Vec, Vec)>, + query_attribute: usize, + ) -> Result { + if query_attribute >= num_attributes { + return Err(format!("Query attribute {query_attribute} is outside attribute set of size {num_attributes}").into()); + } + for (index, (lhs, rhs)) in dependencies.iter().enumerate() { + if lhs.is_empty() { + return Err(format!("Dependency {index} has empty LHS").into()); + } + if let Some(attribute) = lhs + .iter() + .chain(rhs.iter()) + .find(|&&attribute| attribute >= num_attributes) + { + return Err(format!("dependency {index} contains attribute {attribute} outside attribute set of size {num_attributes}").into()); } } - Self { + Ok(Self { num_attributes, dependencies, query_attribute, - } + }) } /// Get the number of attributes. @@ -255,8 +273,12 @@ impl Problem for PrimeAttributeName { } impl crate::solvers::BruteForceProblem for PrimeAttributeName { - fn dimensions(&self) -> Vec { - vec![2; self.num_attributes] + fn num_variables(&self) -> Result { + Ok(self.num_attributes) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/rooted_tree_storage_assignment.rs b/src/models/set/rooted_tree_storage_assignment.rs index 1593976d0..c609f70af 100644 --- a/src/models/set/rooted_tree_storage_assignment.rs +++ b/src/models/set/rooted_tree_storage_assignment.rs @@ -238,8 +238,12 @@ impl Problem for RootedTreeStorageAssignment { } impl crate::solvers::BruteForceProblem for RootedTreeStorageAssignment { - fn dimensions(&self) -> Vec { - vec![self.universe_size; self.universe_size] + fn num_variables(&self) -> Result { + Ok(self.universe_size) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.universe_size) } } diff --git a/src/models/set/set_basis.rs b/src/models/set/set_basis.rs index be76ee298..6a53f43c9 100644 --- a/src/models/set/set_basis.rs +++ b/src/models/set/set_basis.rs @@ -28,6 +28,7 @@ inventory::submit! { /// `S` such that every set in `C` can be expressed as the union of some /// subcollection of `B`. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "SetBasisData")] pub struct SetBasis { /// Size of the universe (elements are `0..universe_size`). universe_size: usize, @@ -37,6 +38,21 @@ pub struct SetBasis { k: usize, } +#[derive(Deserialize)] +struct SetBasisData { + universe_size: usize, + collection: Vec>, + k: usize, +} + +impl TryFrom for SetBasis { + type Error = crate::registry::ConstructionError; + + fn try_from(data: SetBasisData) -> Result { + Self::try_new(data.universe_size, data.collection, data.k) + } +} + #[derive(Debug, Deserialize, crate::CreateSpec)] struct SetBasisCreateSpec { /// Size of the ground set S. @@ -60,7 +76,7 @@ impl TryFrom for SetBasis { .into()); } } - Ok(Self::new(spec.universe_size, spec.subsets, spec.k)) + Self::try_new(spec.universe_size, spec.subsets, spec.k) } } @@ -71,26 +87,26 @@ impl SetBasis { /// /// Panics if any element in `collection` lies outside the universe. pub fn new(universe_size: usize, collection: Vec>, k: usize) -> Self { - let mut collection = collection; - for (set_index, set) in collection.iter_mut().enumerate() { + Self::try_new(universe_size, collection, k).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + universe_size: usize, + mut collection: Vec>, + k: usize, + ) -> Result { + for (index, set) in collection.iter_mut().enumerate() { set.sort_unstable(); set.dedup(); - for &element in set.iter() { - assert!( - element < universe_size, - "Set {} contains element {} which is outside universe of size {}", - set_index, - element, - universe_size - ); + 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()); } } - - Self { + Ok(Self { universe_size, collection, k, - } + }) } /// Return the universe size. @@ -201,8 +217,14 @@ impl Problem for SetBasis { } impl crate::solvers::BruteForceProblem for SetBasis { - fn dimensions(&self) -> Vec { - vec![2; self.k * self.universe_size] + fn num_variables(&self) -> Result { + (self.k).checked_mul(self.universe_size).ok_or_else(|| { + crate::solvers::SolveError::IntegerOverflow("computing the coordinate count".into()) + }) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/set_splitting.rs b/src/models/set/set_splitting.rs index 1c7f46a76..7cc984333 100644 --- a/src/models/set/set_splitting.rs +++ b/src/models/set/set_splitting.rs @@ -68,6 +68,9 @@ fn normalize_subsets(universe_size: usize, subsets: &[Vec]) -> (usize, Ve for subset in subsets { let mut remainder = subset.clone(); + // Multiplicity has no meaning in a set, including before decomposition. + remainder.sort_unstable(); + remainder.dedup(); while remainder.len() > 3 { let positive_aux = next_element; let negative_aux = next_element + 1; @@ -154,7 +157,7 @@ impl SetSplitting { (universe_size, size2, size3) } - /// Universe size after decomposing all subsets to size 2 or 3. + /// Universe size after deduplicating subsets and decomposing sizes above 3. pub fn normalized_universe_size(&self) -> usize { self.normalized_stats().0 } @@ -210,8 +213,12 @@ impl Problem for SetSplitting { } impl crate::solvers::BruteForceProblem for SetSplitting { - fn dimensions(&self) -> Vec { - vec![2; self.universe_size] + fn num_variables(&self) -> Result { + Ok(self.universe_size) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/three_dimensional_matching.rs b/src/models/set/three_dimensional_matching.rs index 6b7fb2641..5dbd3fd06 100644 --- a/src/models/set/three_dimensional_matching.rs +++ b/src/models/set/three_dimensional_matching.rs @@ -56,6 +56,7 @@ inventory::submit! { /// assert!(problem.evaluate(&solutions[0]).unwrap()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "ThreeDimensionalMatchingData")] pub struct ThreeDimensionalMatching { /// Size of each set W, X, Y (elements are 0..universe_size). universe_size: usize, @@ -63,6 +64,20 @@ pub struct ThreeDimensionalMatching { triples: Vec<(usize, usize, usize)>, } +#[derive(Deserialize)] +struct ThreeDimensionalMatchingData { + universe_size: usize, + triples: Vec<(usize, usize, usize)>, +} + +impl TryFrom for ThreeDimensionalMatching { + type Error = crate::registry::ConstructionError; + + fn try_from(data: ThreeDimensionalMatchingData) -> Result { + Self::try_new(data.universe_size, data.triples) + } +} + impl ThreeDimensionalMatching { /// Create a new 3DM problem. /// @@ -70,33 +85,25 @@ impl ThreeDimensionalMatching { /// /// Panics if any triple contains an element outside 0..universe_size. pub fn new(universe_size: usize, triples: Vec<(usize, usize, usize)>) -> Self { - for (i, &(w, x, y)) in triples.iter().enumerate() { - assert!( - w < universe_size, - "Triple {} has w-coordinate {} which is outside 0..{}", - i, - w, - universe_size - ); - assert!( - x < universe_size, - "Triple {} has x-coordinate {} which is outside 0..{}", - i, - x, - universe_size - ); - assert!( - y < universe_size, - "Triple {} has y-coordinate {} which is outside 0..{}", - i, - y, - universe_size - ); + Self::try_new(universe_size, triples).unwrap_or_else(|error| panic!("{error}")) + } + + fn try_new( + universe_size: usize, + triples: Vec<(usize, usize, usize)>, + ) -> Result { + for (index, &(w, x, y)) in triples.iter().enumerate() { + if w >= universe_size || x >= universe_size || y >= universe_size { + return Err(format!( + "triple {index} contains a coordinate outside 0..{universe_size}" + ) + .into()); + } } - Self { + Ok(Self { universe_size, triples, - } + }) } /// Get the universe size (q). @@ -179,8 +186,12 @@ impl Problem for ThreeDimensionalMatching { } impl crate::solvers::BruteForceProblem for ThreeDimensionalMatching { - fn dimensions(&self) -> Vec { - vec![2; self.triples.len()] + fn num_variables(&self) -> Result { + Ok(self.triples.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } diff --git a/src/models/set/two_dimensional_consecutive_sets.rs b/src/models/set/two_dimensional_consecutive_sets.rs index e14af706f..d0bde6b50 100644 --- a/src/models/set/two_dimensional_consecutive_sets.rs +++ b/src/models/set/two_dimensional_consecutive_sets.rs @@ -224,8 +224,12 @@ impl Problem for TwoDimensionalConsecutiveSets { } impl crate::solvers::BruteForceProblem for TwoDimensionalConsecutiveSets { - fn dimensions(&self) -> Vec { - vec![self.alphabet_size; self.alphabet_size] + fn num_variables(&self) -> Result { + Ok(self.alphabet_size) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(self.alphabet_size) } } diff --git a/src/registry/dyn_problem.rs b/src/registry/dyn_problem.rs index e5938bdc4..8459e2142 100644 --- a/src/registry/dyn_problem.rs +++ b/src/registry/dyn_problem.rs @@ -1,11 +1,9 @@ -use serde::Serialize; use serde_json::Value; use std::any::Any; use std::collections::BTreeMap; use std::fmt; -use crate::traits::{EvaluationError, Problem}; -use crate::types::SolutionAggregate; +use crate::traits::EvaluationError; /// Format a metric for CLI- and registry-facing dynamic dispatch. /// @@ -19,13 +17,10 @@ where /// Type-erased problem interface for dynamic dispatch. /// -/// Implemented for serializable problems whose values support solution witnesses. +/// Generated for concrete variants at the registration boundary. pub trait DynProblem: Any { - /// 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. - /// This validates feasibility, not global optimality. - fn evaluate_witness_dyn(&self, solution: &Value) -> Result, EvaluationError>; + /// Evaluate once and return the display value and whether the configuration is feasible. + fn evaluate_dyn(&self, solution: &Value) -> Result<(String, bool), EvaluationError>; /// Evaluate a configuration and return the result as a serializable JSON value. fn evaluate_json(&self, solution: &Value) -> Result; /// Serialize the problem to a JSON value. @@ -42,57 +37,73 @@ pub trait DynProblem: Any { fn parameters_dyn(&self) -> crate::types::ProblemParameters; } -impl DynProblem for T -where - T: Problem + Serialize + 'static, - T::Solution: serde::de::DeserializeOwned, - T::Value: SolutionAggregate + fmt::Display + Serialize, -{ - fn evaluate_dyn(&self, solution: &Value) -> Result { - let solution = serde::Deserialize::deserialize(solution).map_err(|error| { - EvaluationError::InvalidConfiguration(format!("invalid solution JSON: {error}")) - })?; - Ok(format_metric(&self.evaluate(&solution)?)) - } - - fn evaluate_json(&self, solution: &Value) -> Result { - 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")) - } - - fn evaluate_witness_dyn(&self, solution: &Value) -> Result, EvaluationError> { - let solution = serde::Deserialize::deserialize(solution).map_err(|error| { - EvaluationError::InvalidConfiguration(format!("invalid solution JSON: {error}")) - })?; - let value = self.evaluate(&solution)?; - Ok(T::Value::contributes_to_solution(&value, &value).then(|| format_metric(&value))) - } - - fn serialize_json(&self) -> Value { - serde_json::to_value(self).expect("serialize failed") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn problem_name(&self) -> &'static str { - T::NAME - } - - fn variant_map(&self) -> BTreeMap { - crate::export::variant_to_map(T::variant()) - } - - fn parameter_names_dyn(&self) -> &'static [&'static str] { - T::parameter_names() - } - - fn parameters_dyn(&self) -> crate::types::ProblemParameters { - self.parameters() - } +/// Implement the existing dynamic transport boundary for a concrete problem type. +/// +/// Concrete value semantics determine feasibility; no solver capability is required. +/// Registration therefore requires `Problem::Value: EvaluationValue`; problems with +/// fold-only values such as `Sum` or `And` stay unregistered. +#[macro_export] +macro_rules! impl_dyn_problem { + ($ty:ty) => { + impl $crate::registry::DynProblem for $ty { + fn evaluate_dyn( + &self, + solution: &serde_json::Value, + ) -> Result<(String, bool), $crate::traits::EvaluationError> { + let solution = serde::Deserialize::deserialize(solution).map_err(|error| { + $crate::traits::EvaluationError::InvalidConfiguration(format!( + "invalid solution JSON: {error}" + )) + })?; + let value = <$ty as $crate::traits::Problem>::evaluate(self, &solution)?; + Ok(( + $crate::registry::format_metric(&value), + $crate::traits::EvaluationValue::is_valid(&value), + )) + } + + fn evaluate_json( + &self, + solution: &serde_json::Value, + ) -> Result { + let solution = serde::Deserialize::deserialize(solution).map_err(|error| { + $crate::traits::EvaluationError::InvalidConfiguration(format!( + "invalid solution JSON: {error}" + )) + })?; + Ok( + serde_json::to_value(<$ty as $crate::traits::Problem>::evaluate( + self, &solution, + )?) + .expect("serialize metric failed"), + ) + } + + fn serialize_json(&self) -> serde_json::Value { + serde_json::to_value(self).expect("serialize failed") + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn problem_name(&self) -> &'static str { + <$ty as $crate::traits::Problem>::NAME + } + + fn variant_map(&self) -> std::collections::BTreeMap { + $crate::export::variant_to_map(<$ty as $crate::traits::Problem>::variant()) + } + + fn parameter_names_dyn(&self) -> &'static [&'static str] { + <$ty as $crate::traits::Problem>::parameter_names() + } + + fn parameters_dyn(&self) -> $crate::types::ProblemParameters { + <$ty as $crate::traits::Problem>::parameters(self) + } + } + }; } /// A loaded type-erased problem. diff --git a/src/registry/info.rs b/src/registry/info.rs index d39ca69c7..eb078537c 100644 --- a/src/registry/info.rs +++ b/src/registry/info.rs @@ -4,7 +4,9 @@ //! //! - [`ComplexityClass`] - Computational complexity (P, NP-complete, etc.) //! - [`ProblemInfo`] - Rich metadata about a problem type -//! - [`ProblemMetadata`] - Trait for problems to provide their metadata +//! +//! Registered models are queried through [`super::ProblemType`]; [`ProblemInfo`] +//! is a standalone description and does not register a model. //! //! # Example //! @@ -19,6 +21,15 @@ //! assert!(info.is_np_complete()); //! assert_eq!(info.all_names().len(), 3); //! ``` +//! +//! # Query a registered model +//! +//! ```rust +//! use problemreductions::registry::find_problem_type; +//! +//! let info = find_problem_type("MaximumIndependentSet").unwrap(); +//! assert_eq!(info.canonical_name, "MaximumIndependentSet"); +//! ``` use std::fmt; @@ -217,38 +228,6 @@ pub struct FieldInfo { pub description: &'static str, } -/// Trait for problems that provide static metadata. -/// -/// Implement this trait to enable introspection and discovery for problem types. -/// -/// # Example -/// -/// ```rust -/// use problemreductions::registry::{ -/// ProblemMetadata, ProblemInfo, ComplexityClass -/// }; -/// -/// struct MyProblem; -/// -/// impl ProblemMetadata for MyProblem { -/// fn problem_info() -> ProblemInfo { -/// ProblemInfo::new("My Problem", "Description") -/// .with_complexity(ComplexityClass::NpComplete) -/// } -/// } -/// -/// // Get problem metadata -/// let info = MyProblem::problem_info(); -/// assert_eq!(info.name, "My Problem"); -/// ``` -pub trait ProblemMetadata { - /// Returns the problem info for this problem type. - /// - /// This includes the problem name, description, aliases, complexity class, - /// and known reductions. - fn problem_info() -> ProblemInfo; -} - #[cfg(test)] #[path = "../unit_tests/registry/info.rs"] mod tests; diff --git a/src/registry/mod.rs b/src/registry/mod.rs index b76eb4199..0b0606f41 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -5,7 +5,7 @@ //! # Overview //! //! - [`ProblemInfo`] - Rich metadata (name, description, complexity, reductions) -//! - [`ProblemMetadata`] - Trait for problems to provide their own metadata +//! - [`ProblemType`] - Catalog metadata for registered models //! - [`ComplexityClass`] - Computational complexity classification //! //! # Example @@ -22,26 +22,17 @@ //! assert!(info.is_np_complete()); //! ``` //! -//! # Implementing for Custom Problems +//! # Querying Registered Problems //! -//! Problems can implement [`ProblemMetadata`] to provide introspection: +//! Models declare their catalog metadata through [`ProblemSchemaEntry`]. +//! Query those entries by name or alias: //! //! ```rust -//! use problemreductions::registry::{ -//! ProblemMetadata, ProblemInfo, ComplexityClass -//! }; +//! use problemreductions::registry::find_problem_type; //! -//! struct MyProblem; -//! -//! impl ProblemMetadata for MyProblem { -//! fn problem_info() -> ProblemInfo { -//! ProblemInfo::new("My Problem", "Description") -//! .with_complexity(ComplexityClass::NpComplete) -//! } -//! } -//! -//! let info = MyProblem::problem_info(); -//! println!("Problem: {}", info.name); +//! let info = find_problem_type("MaximumIndependentSet").unwrap(); +//! assert_eq!(info.canonical_name, "MaximumIndependentSet"); +//! println!("Problem: {}", info.display_name); //! ``` mod dyn_problem; @@ -52,7 +43,7 @@ mod schema; pub mod variant; pub use dyn_problem::{format_metric, DynProblem, LoadedDynProblem}; -pub use info::{ComplexityClass, FieldInfo, ProblemInfo, ProblemMetadata}; +pub use info::{ComplexityClass, FieldInfo, ProblemInfo}; pub use problem_ref::{parse_catalog_problem_ref, require_graph_variant, ProblemRef}; pub use problem_type::{find_problem_type, find_problem_type_by_alias, problem_types, ProblemType}; pub use schema::{ diff --git a/src/rules/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index df8b781e2..91f96e346 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::AcyclicPartition; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionAcyclicPartitionToILP { @@ -25,13 +26,28 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { } /// One-hot decode: for each vertex v, output the unique c with x_{v,c} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) +impl ReductionAcyclicPartitionToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.n, + self.n, + 0, + )) } } @@ -129,6 +145,7 @@ impl ReduceTo> for AcyclicPartition { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; use crate::topology::DirectedGraph; vec![crate::example_db::specs::RuleExampleSpec { id: "acyclicpartition_to_ilp", @@ -146,7 +163,13 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/rules/balancedcompletebipartitesubgraph_ilp.rs index 8c220ba38..d94eab139 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::BalancedCompleteBipartiteSubgraph; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use std::collections::HashSet; #[derive(Debug, Clone)] @@ -24,12 +25,22 @@ impl ReductionResult for ReductionBCBSToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionBCBSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/bicliquecover_bmf.rs b/src/rules/bicliquecover_bmf.rs index bcd5bd822..ae0bee102 100644 --- a/src/rules/bicliquecover_bmf.rs +++ b/src/rules/bicliquecover_bmf.rs @@ -15,7 +15,8 @@ use crate::models::algebraic::BMF; use crate::models::graph::BicliqueCover; use crate::reduction; use crate::rules::bmf_bicliquecover::config_bmf_to_bc; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing BicliqueCover to BMF. #[derive(Debug, Clone)] @@ -36,13 +37,14 @@ impl ReductionResult for ReductionBicliqueCoverToBMF { /// Map a BMF config (B row-major, C row-major) to a BicliqueCover /// config (vertex-major) via the inverse transpose. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(config_bmf_to_bc(target_solution, self.m, self.n, self.k)) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(config_bmf_to_bc(solution, self.m, self.n, self.k)) + }) } } diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index 6fc7dd3bc..c00cdd8d3 100644 --- a/src/rules/biconnectivityaugmentation_ilp.rs +++ b/src/rules/biconnectivityaugmentation_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{IntegerVariable, LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::BiconnectivityAugmentation; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -54,19 +55,22 @@ impl ReductionResult for ReductionBiconnAugToILP { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionBiconnAugToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_candidates] .iter() .map(|&value| value == 1) @@ -235,6 +239,7 @@ impl ReduceTo> for BiconnectivityAugmentation { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "biconnectivityaugmentation_to_ilp", build: || { @@ -250,7 +255,13 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/binpacking_ilp.rs b/src/rules/binpacking_ilp.rs index 24d3cc5a8..8cd8f5995 100644 --- a/src/rules/binpacking_ilp.rs +++ b/src/rules/binpacking_ilp.rs @@ -10,7 +10,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::BinPacking; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode_rows; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing BinPacking to ILP. /// @@ -37,13 +38,14 @@ impl ReductionResult for ReductionBPToILP { /// Extract solution from ILP back to BinPacking. /// /// For each item i, find the unique bin j where x_{ij} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - one_hot_decode_rows(target_solution, self.n, self.n, 0) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(one_hot_decode_rows(solution, self.n, self.n, 0)) + }) } } diff --git a/src/rules/bmf_bicliquecover.rs b/src/rules/bmf_bicliquecover.rs index 8f9790636..30dfc69b2 100644 --- a/src/rules/bmf_bicliquecover.rs +++ b/src/rules/bmf_bicliquecover.rs @@ -11,13 +11,14 @@ //! //! Variable-layout mapping: BMF stores `B` row-major followed by `C` //! row-major, while BicliqueCover stores vertex memberships vertex-major. -//! `extract_solution` transposes the right-vertex half so the extracted +//! `recover_result` transposes the right-vertex half so the extracted //! BMF config matches `B` and `C`. use crate::models::algebraic::BMF; use crate::models::graph::BicliqueCover; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::BipartiteGraph; /// Convert one vertex-membership row per biclique into BMF factors. @@ -82,13 +83,14 @@ impl ReductionResult for ReductionBMFToBicliqueCover { } /// Map a BicliqueCover config (vertex-major) back to a BMF config (B row-major, then C row-major). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(config_bc_to_bmf(target_solution, self.m, self.n, self.k)) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(config_bc_to_bmf(solution, self.m, self.n, self.k)) + }) } } diff --git a/src/rules/bmf_ilp.rs b/src/rules/bmf_ilp.rs index 8944c45e5..67e028069 100644 --- a/src/rules/bmf_ilp.rs +++ b/src/rules/bmf_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, BMF, ILP}; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionBMFToILP { @@ -25,12 +26,22 @@ impl ReductionResult for ReductionBMFToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionBMFToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let b = (0..self.m) .map(|i| { (0..self.k) diff --git a/src/rules/bottlenecktravelingsalesman_ilp.rs b/src/rules/bottlenecktravelingsalesman_ilp.rs index 6951b619a..8f5b6b81d 100644 --- a/src/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/rules/bottlenecktravelingsalesman_ilp.rs @@ -3,7 +3,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::BottleneckTravelingSalesman; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::Graph; /// A tour is encoded by positions and distinct directed uses of source edges. @@ -23,17 +24,22 @@ impl ReductionResult for ReductionBTSPToILP { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionBTSPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { 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..6844ba24c 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::BoundedComponentSpanningForest; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode_rows; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -27,13 +28,14 @@ impl ReductionResult for ReductionBCSFToILP { } /// One-hot decode: for each vertex v, output the unique component c with x_{v,c} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - one_hot_decode_rows(target_solution, self.n, self.k, 0) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(one_hot_decode_rows(solution, self.n, self.k, 0)) + }) } } @@ -189,6 +191,7 @@ impl ReduceTo> for BoundedComponentSpanningForest { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "boundedcomponentspanningforest_to_ilp", build: || { @@ -204,7 +207,13 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/capacityassignment_ilp.rs b/src/rules/capacityassignment_ilp.rs index 1b348b59a..c5b5b027a 100644 --- a/src/rules/capacityassignment_ilp.rs +++ b/src/rules/capacityassignment_ilp.rs @@ -10,7 +10,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::CapacityAssignment; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing CapacityAssignment to ILP. /// @@ -34,18 +35,28 @@ impl ReductionResult for ReductionCAToILP { } /// Extract solution: for each link l, find the unique capacity c where x_{l,c} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionCAToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_links, self.num_capacities, 0, - ) + )) } } diff --git a/src/rules/circuit_ilp.rs b/src/rules/circuit_ilp.rs index 6fa63d452..a2826353c 100644 --- a/src/rules/circuit_ilp.rs +++ b/src/rules/circuit_ilp.rs @@ -17,7 +17,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::formula::{BooleanExpr, BooleanOp, CircuitSAT}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use std::collections::HashMap; /// Result of reducing CircuitSAT to ILP. @@ -36,19 +37,22 @@ impl ReductionResult for ReductionCircuitToILP { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionCircuitToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ self.source_variables .iter() diff --git a/src/rules/circuit_sat.rs b/src/rules/circuit_sat.rs index 34811903a..e7d5a7350 100644 --- a/src/rules/circuit_sat.rs +++ b/src/rules/circuit_sat.rs @@ -5,7 +5,8 @@ use crate::models::formula::{ }; use crate::reduction; use crate::rules::sat_helpers::SatVariableAllocator; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use std::collections::HashMap; #[derive(Debug, Clone, PartialEq, Eq)] @@ -289,13 +290,14 @@ impl ReductionResult for ReductionCircuitSATToSAT { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution[..self.source_var_count].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.source_var_count].to_vec()) + }) } } @@ -338,6 +340,7 @@ fn issue_example_source() -> CircuitSAT { pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; use crate::solvers::BruteForce; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "circuitsat_to_satisfiability", @@ -350,7 +353,17 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Satisfiability example must be satisfiable"); crate::example_db::specs::assemble_rule_example( diff --git a/src/rules/circuit_spinglass.rs b/src/rules/circuit_spinglass.rs index 92ee614d4..6a25e1ac4 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -6,10 +6,12 @@ //! 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; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; use crate::types::WeightElement; use num_traits::Zero; @@ -209,35 +211,37 @@ 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 } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionCircuitToSG { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(self .source_variables .iter() @@ -246,19 +250,6 @@ 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)) - } -} - /// Builder for constructing the combined SpinGlass from circuit gadgets. struct SpinGlassBuilder { /// Current number of spins. @@ -491,13 +482,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 { @@ -506,23 +496,19 @@ impl ReduceTo> for CircuitSAT { // Process each assignment in the circuit for assignment in &self.circuit().assignments { process_assignment(assignment, &mut builder).map_err( - crate::rules::ReductionError::construction::< - CircuitSAT, - SpinGlass, - >, + >>>::target_construction, )?; } let (target, variable_map, zero_penalty_energy) = builder.build().map_err( - crate::rules::ReductionError::construction::>, + >>>::target_construction, )?; 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 +547,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/closeststring_ilp.rs b/src/rules/closeststring_ilp.rs index 1f7d2a922..4fd355ea4 100644 --- a/src/rules/closeststring_ilp.rs +++ b/src/rules/closeststring_ilp.rs @@ -24,7 +24,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ClosestString; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ClosestString to ILP. /// @@ -51,30 +52,28 @@ impl ReductionResult for ReductionClosestStringToILP { /// /// For every position `j`, choose the unique alphabet symbol `a` with /// `x_{j, a} = 1`. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - let q = self.alphabet_size; - let mut center = Vec::with_capacity(self.string_length); - for position in 0..self.string_length { - let block = &target_solution[position * q..(position + 1) * q]; - let mut selected = block.iter().enumerate().filter(|(_, value)| **value == 1); - let symbol = selected.next().map(|(symbol, _)| symbol).ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "center position {position} has no selected symbol" - )) - })?; - if selected.next().is_some() || block.iter().any(|&value| value > 1) { - return Err(crate::rules::ExtractionError::invalid(format!( - "center position {position} is not one-hot" - ))); - } - center.push(symbol); - } - Ok(center) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionClosestStringToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.string_length, + self.alphabet_size, + 0, + )) } } diff --git a/src/rules/closestsubstring_ilp.rs b/src/rules/closestsubstring_ilp.rs index 65416c54c..4fe72379b 100644 --- a/src/rules/closestsubstring_ilp.rs +++ b/src/rules/closestsubstring_ilp.rs @@ -32,7 +32,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ClosestSubstring; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ClosestSubstring to ILP. /// @@ -71,53 +72,47 @@ impl ReductionResult for ReductionClosestSubstringToILP { /// are per-string window starts. For each center position `r`, we pick the /// unique alphabet symbol `a` with `x_{r, a} = 1`; for each input string /// `s_i`, we pick the unique window start `p` with `y_{i, p} = 1`. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionClosestSubstringToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let q = self.alphabet_size; let ell = self.substring_length; let y_base = q * ell; let mut out = Vec::with_capacity(ell + self.window_counts.len()); - for position in 0..ell { - let block = &target_solution[position * q..(position + 1) * q]; - out.push(decode_one_hot(block, "center position", position)?); - } + out.extend(crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + ell, + q, + 0, + )); for (string, &window_count) in self.window_counts.iter().enumerate() { let start = y_base + self.window_offsets[string]; - out.push(decode_one_hot( - &target_solution[start..start + window_count], - "string window", - string, - )?); + out.extend(crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + 1, + window_count, + start, + )); } Ok(out) } } -fn decode_one_hot( - block: &[i64], - block_name: &str, - block_index: usize, -) -> crate::rules::ExtractionResult { - let mut selected = block.iter().enumerate().filter(|(_, value)| **value == 1); - let index = selected.next().map(|(index, _)| index).ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "{block_name} {block_index} has no selected value" - )) - })?; - if selected.next().is_some() || block.iter().any(|&value| value > 1) { - return Err(crate::rules::ExtractionError::invalid(format!( - "{block_name} {block_index} is not one-hot" - ))); - } - Ok(index) -} - #[reduction( transform = exact { num_vars = "alphabet_size * substring_length + total_num_windows + 1", @@ -166,11 +161,8 @@ impl ReduceTo> for ClosestSubstring { } // Tight upper bound on R: the worst-case Hamming distance over a - // length-ell window is at most ell. Added as a single-term `<=` - // constraint so the solver's bound-tightening pass (which scans for - // exactly this pattern) picks it up. Without this, R defaults to the - // full i64 domain, which severely degrades HiGHS performance even on - // tiny instances. + // length-ell window is at most ell. Restricting R to this range + // preserves every optimal solution. constraints.push(LinearConstraint::le(vec![(r_idx, 1)], ell_i64)); // Window-choice constraints: exactly one window per input string. 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..fa492a88d 100644 --- a/src/rules/closestvectorproblem_qubo.rs +++ b/src/rules/closestvectorproblem_qubo.rs @@ -1,4 +1,4 @@ -//! Reduction from integer-target CVP to QUBO. +//! Reduction from integer CVP to QUBO. //! //! The reduction derives a finite coefficient box from the lattice basis and //! target, then expands the squared Euclidean distance over exact-range binary @@ -8,11 +8,12 @@ use crate::export::SolutionPair; use crate::models::algebraic::{ClosestVectorProblem, QUBO}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use num_bigint::BigInt; use num_traits::Zero; -type Source = ClosestVectorProblem; +type Source = ClosestVectorProblem; type Target = QUBO; #[derive(Debug, Clone)] @@ -22,7 +23,7 @@ struct EncodingSpan { lower: i64, } -/// Result of reducing an integer-target CVP instance to QUBO. +/// Result of reducing an integer CVP instance to QUBO. #[derive(Debug, Clone)] pub struct ReductionCVPToQUBO { target: Target, @@ -37,32 +38,33 @@ impl ReductionResult for ReductionCVPToQUBO { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionCVPToQUBO { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { self.encodings .iter() .map(|encoding| { - let offset = encoding.weights.iter().enumerate().try_fold( - 0_i64, - |offset, (index, &weight)| { - if target_solution[encoding.start + index] { - offset.checked_add(weight) - } else { - Some(offset) - } - }, - ); - offset - .and_then(|offset| encoding.lower.checked_add(offset)) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "decoded closest-vector coefficient overflows i64", - ) - }) + let offset: i64 = encoding + .weights + .iter() + .enumerate() + .filter(|(index, _)| target_solution[encoding.start + index]) + .map(|(_, &weight)| weight) + .sum(); + Ok(encoding.lower + offset) }) .collect() } @@ -110,9 +112,7 @@ fn determinant(matrix: &[Vec]) -> Result } fn coefficient_bounds(problem: &Source) -> Result, crate::rules::ReductionError> { - let rows = problem - .independent_rows() - .map_err(crate::rules::ReductionError::construction::)?; + let rows = problem.independent_rows(); let size = problem.num_basis_vectors(); if size == 0 { return Ok(Vec::new()); @@ -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 { @@ -293,7 +293,7 @@ impl ReduceTo> for ClosestVectorProblem { .map(move |&weight| (coefficient, weight)) }) .collect::>(); - let mut integer_matrix = vec![vec![0_i64; total_bits]; total_bits]; + let mut integer_matrix = vec![std::collections::BTreeMap::new(); total_bits]; for u in 0..total_bits { let (coefficient_u, weight_u) = bit_terms[u]; let quadratic = gram[coefficient_u][coefficient_u] @@ -304,22 +304,27 @@ impl ReduceTo> for ClosestVectorProblem { .checked_mul(weight_u) .and_then(|value| value.checked_mul(2)) .ok_or_else(|| overflow("computing a closest-vector QUBO diagonal"))?; - integer_matrix[u][u] = quadratic - .checked_add(linear_term) - .ok_or_else(|| overflow("computing a closest-vector QUBO diagonal"))?; + integer_matrix[u].insert( + u, + quadratic + .checked_add(linear_term) + .ok_or_else(|| overflow("computing a closest-vector QUBO diagonal"))?, + ); - for v in (u + 1)..total_bits { - let (coefficient_v, weight_v) = bit_terms[v]; - integer_matrix[u][v] = gram[coefficient_u][coefficient_v] + for (v, &(coefficient_v, weight_v)) in bit_terms.iter().enumerate().skip(u + 1) { + let coefficient = gram[coefficient_u][coefficient_v] .checked_mul(weight_u) .and_then(|value| value.checked_mul(weight_v)) .and_then(|value| value.checked_mul(2)) .ok_or_else(|| overflow("computing a closest-vector QUBO interaction"))?; + if coefficient != 0 { + integer_matrix[u].insert(v, coefficient); + } } } Ok(ReductionCVPToQUBO { - target: QUBO::from_matrix(integer_matrix) + target: QUBO::from_rows(integer_matrix) .map_err(crate::rules::ReductionError::construction::)?, encodings, }) diff --git a/src/rules/clustering_ilp.rs b/src/rules/clustering_ilp.rs index bb3149ae0..19ff99343 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::Clustering; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Clustering to ILP. #[derive(Debug, Clone)] @@ -26,18 +27,28 @@ impl ReductionResult for ReductionClusteringToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionClusteringToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_elements, self.num_clusters, 0, - ) + )) } } diff --git a/src/rules/coloring_ilp.rs b/src/rules/coloring_ilp.rs index 37c6f5944..adfde3a0b 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::KColoring; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode_rows; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::variant::{KValue, K1, K2, K3, K4, KN}; @@ -44,13 +45,31 @@ where /// /// The ILP solution has num_vertices * K binary variables. /// For each vertex, we find which color has value 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode_rows(target_solution, self.num_vertices, self.num_colors, 0) +impl ReductionKColoringToILP +where + G: Graph + crate::variant::VariantParam, +{ + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode_rows( + target_solution, + self.num_vertices, + self.num_colors, + 0, + )) } } diff --git a/src/rules/coloring_qubo.rs b/src/rules/coloring_qubo.rs index c29893620..ac50bf60a 100644 --- a/src/rules/coloring_qubo.rs +++ b/src/rules/coloring_qubo.rs @@ -9,71 +9,58 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; 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 } - /// Decode one-hot: for each vertex, find which color bit is 1. - fn extract_solution( + /// Decode a target witness at `feasible_energy` into a proper coloring. + /// At that energy all nonnegative penalties vanish, including one-hot. + /// The target decision bound selects exactly zero-penalty colorings. + /// An uncolorable source produces an infeasible decision target. + fn recover_result( &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", - )); - } - - (0..self.num_vertices) - .map(|vertex| { - let mut selected = (0..self.num_colors) - .filter(|&color| target_solution[vertex * self.num_colors + color]); - match (selected.next(), selected.next()) { - (Some(color), None) => Ok(color), - (None, _) => Err(crate::rules::ExtractionError::invalid(format!( - "assignment row {vertex} has no selected color" - ))), - (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( - "assignment row {vertex} has multiple selected colors" - ))), - } - }) - .collect() + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) } } -impl crate::rules::AggregateReductionResult for ReductionKColoringToQUBO { - type Source = KColoring; - 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.feasible_energy)) +impl ReductionKColoringToQUBO { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok((0..self.num_vertices) + .map(|vertex| { + (0..self.num_colors) + .find(|&color| target_solution[vertex * self.num_colors + color]) + .unwrap() + }) + .collect()) } } @@ -83,9 +70,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 +100,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)?; @@ -125,7 +114,7 @@ fn reduce_kcoloring_to_qubo( .checked_mul(4) .ok_or_else(|| overflow("computing a one-hot interaction coefficient"))?; - let mut matrix = vec![vec![0i64; nq]; nq]; + let mut matrix = vec![std::collections::BTreeMap::new(); nq]; // Twice the former half-integral objective keeps every coefficient integral. // One-hot penalty: 2P*sum_v (1 - sum_c x_{v,c})^2 @@ -136,7 +125,8 @@ fn reduce_kcoloring_to_qubo( for c in 0..k { let idx = v * k + c; // Diagonal: -2P - matrix[idx][idx] = matrix[idx][idx] + let coefficient = matrix[idx].entry(idx).or_insert(0i64); + *coefficient = coefficient .checked_add(diagonal_penalty) .ok_or_else(|| overflow("adding a coloring diagonal coefficient"))?; } @@ -145,7 +135,8 @@ fn reduce_kcoloring_to_qubo( for c2 in (c1 + 1)..k { let idx1 = v * k + c1; let idx2 = v * k + c2; - matrix[idx1][idx2] = matrix[idx1][idx2] + let coefficient = matrix[idx1].entry(idx2).or_insert(0i64); + *coefficient = coefficient .checked_add(one_hot_interaction) .ok_or_else(|| overflow("adding a one-hot interaction coefficient"))?; } @@ -162,33 +153,36 @@ fn reduce_kcoloring_to_qubo( } else { (idx_v, idx_u) }; - matrix[i][j] = matrix[i][j] + let coefficient = matrix[i].entry(j).or_insert(0i64); + *coefficient = coefficient .checked_add(penalty) .ok_or_else(|| overflow("adding an edge-conflict coefficient"))?; } } Ok(ReductionKColoringToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { - crate::rules::ReductionError::construction::, QUBO>( - message, - ) - })?, + target: Decision::new( + QUBO::from_rows(matrix).map_err( + crate::rules::ReductionError::construction::< + KColoring, + Decision>, + >, + )?, + 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 +193,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 +214,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..48d41a176 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{ }; use crate::reduction; use crate::rules::ilp_helpers::{one_hot_assignment_constraints, one_hot_decode}; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionCBMToILP { @@ -24,13 +25,28 @@ impl ReductionResult for ReductionCBMToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) +impl ReductionCBMToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode( + target_solution, + self.num_cols, + self.num_cols, + 0, + )) } } diff --git a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs index 0941ff0a7..cbf507470 100644 --- a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{ }; use crate::reduction; use crate::rules::ilp_helpers::{one_hot_assignment_constraints, one_hot_decode}; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionCOMAToILP { @@ -25,13 +26,28 @@ impl ReductionResult for ReductionCOMAToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) +impl ReductionCOMAToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode( + target_solution, + self.num_cols, + self.num_cols, + 0, + )) } } @@ -187,6 +203,7 @@ impl ReduceTo> for ConsecutiveOnesMatrixAugmentation { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "consecutiveonesmatrixaugmentation_to_ilp", build: || { @@ -204,7 +221,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/consecutiveonessubmatrix_ilp.rs b/src/rules/consecutiveonessubmatrix_ilp.rs index d279e0913..bc41cdea7 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{ConsecutiveOnesSubmatrix, LinearConstraint, ObjectiveSense, ILP}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionCOSToILP { @@ -22,12 +23,22 @@ impl ReductionResult for ReductionCOSToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionCOSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Output the selection bits s_c (first num_cols variables) target_solution[..self.num_cols] @@ -206,6 +217,7 @@ impl ReduceTo> for ConsecutiveOnesSubmatrix { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "consecutiveonessubmatrix_to_ilp", build: || { @@ -224,7 +236,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/rules/consistencyofdatabasefrequencytables_ilp.rs index 71710293c..c06bb870a 100644 --- a/src/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -10,7 +10,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ConsistencyOfDatabaseFrequencyTables; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ConsistencyOfDatabaseFrequencyTables to ILP. #[derive(Debug, Clone)] @@ -91,34 +92,33 @@ impl ReductionResult for ReductionCDFTToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionCDFTToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let mut source_solution = Vec::with_capacity(self.source.num_assignment_variables()); for object in 0..self.source.num_objects() { for (attribute, &domain_size) in self.source.attribute_domains().iter().enumerate() { - let mut selected = (0..domain_size).filter(|&candidate| { - target_solution[self.assignment_var_index(object, attribute, candidate)] - == 1 - }); - let value = match (selected.next(), selected.next()) { - (Some(value), None) => value, - (None, _) => { - return Err(crate::rules::ExtractionError::invalid(format!( - "object {object}, attribute {attribute} has no selected value" - ))) - } - (Some(_), Some(_)) => { - return Err(crate::rules::ExtractionError::invalid(format!( - "object {object}, attribute {attribute} has multiple selected values" - ))) - } - }; + let value = (0..domain_size) + .filter(|&candidate| { + target_solution[self.assignment_var_index(object, attribute, candidate)] + == 1 + }) + .sum(); source_solution.push(value); } } diff --git a/src/rules/decisionmaximumindependentset_integralflowbundles.rs b/src/rules/decisionmaximumindependentset_integralflowbundles.rs index 7fdeab6b4..87511ec93 100644 --- a/src/rules/decisionmaximumindependentset_integralflowbundles.rs +++ b/src/rules/decisionmaximumindependentset_integralflowbundles.rs @@ -8,7 +8,8 @@ use crate::models::decision::Decision; use crate::models::graph::{IntegralFlowBundles, MaximumIndependentSet}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::One; @@ -27,20 +28,16 @@ impl ReductionResult for ReductionDecisionMISToIFB { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } - Ok((0..self.num_source_vertices) - .map(|i| target_solution[2 * i + 1] == 1) - .collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok((0..self.num_source_vertices) + .map(|i| solution[2 * i + 1] == 1) + .collect()) + }) } } @@ -129,6 +126,7 @@ impl ReduceTo for Decision Vec { use crate::export::SolutionPair; use crate::solvers::BruteForce; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "decisionmaximumindependentset_to_integralflowbundles", build: || { @@ -142,7 +140,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + 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 } - fn extract_solution( + fn recover_result( &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", - )); - } - // Original vertices precede the auxiliary isolated vertices. - Ok(target_solution[..self.source_num_vertices].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) } } -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)) +impl ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + // Original vertices precede the auxiliary isolated vertices. + Ok(target_solution[..self.source_num_vertices].to_vec()) } } #[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 +69,8 @@ impl ReduceTo> ); Ok( ReductionDecisionMinimumDominatingSetToMinimumSumMulticenter { - target, + target: Decision::new(target, threshold), source_num_vertices: n, - threshold, }, ) } @@ -95,7 +83,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 +112,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..f8afe385e 100644 --- a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -7,63 +7,44 @@ use crate::models::decision::Decision; use crate::models::graph::{MinMaxMulticenter, MinimumDominatingSet}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 } - fn extract_solution( + fn recover_result( &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", - )); - } - Ok(target_solution[..self.source_num_vertices].to_vec()) - } -} - -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)) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.source_num_vertices].to_vec()) + }) } } #[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 +60,7 @@ impl ReduceTo> centers, ); Ok(ReductionDecisionMinimumDominatingSetToMinMaxMulticenter { - target, + target: Decision::new(target, 1), source_num_vertices: n, }) } @@ -91,7 +72,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 +96,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + Decision>, >( Decision::new( MinimumDominatingSet::new( diff --git a/src/rules/decisionminimumvertexcover_casts.rs b/src/rules/decisionminimumvertexcover_casts.rs new file mode 100644 index 000000000..41fb04710 --- /dev/null +++ b/src/rules/decisionminimumvertexcover_casts.rs @@ -0,0 +1,23 @@ +//! Variant reductions for Decision. + +use crate::impl_variant_reduction; +use crate::models::decision::Decision; +use crate::models::graph::MinimumVertexCover; +use crate::topology::SimpleGraph; +use crate::types::One; + +// Unit-to-integer weight reduction; the cover-cost bound is unchanged. +impl_variant_reduction!( + Decision, + > => >, + fields: [num_vertices, num_edges], + + |src| Decision::new( + MinimumVertexCover::new( + src.inner().graph().clone(), vec![1_i64; src.num_vertices()]), + *src.bound()) +); + +#[cfg(test)] +#[path = "../unit_tests/rules/decisionminimumvertexcover_casts.rs"] +mod tests; diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 201a2c437..2468d23e3 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -1,26 +1,26 @@ //! 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::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; -use crate::traits::Problem; +use crate::types::One; use std::collections::BTreeSet; #[derive(Debug, Clone)] enum ConstructionKind { - FixedYes { source_cover: Vec }, - FixedNo, + Fixed { source_cover: Vec }, Theorem(TheoremConstruction), } #[derive(Debug, Clone)] struct TheoremConstruction { - num_source_vertices: usize, + forced_cover: Vec, selector_count: usize, edges: Vec<(usize, usize)>, incident_edges: Vec>, @@ -65,6 +65,7 @@ impl TheoremConstruction { )) } + #[cfg(any(test, feature = "example-db"))] fn covers_all_edges(&self, selected: &[bool]) -> bool { self.edges .iter() @@ -73,7 +74,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 +185,12 @@ 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,19 +211,12 @@ impl TheoremConstruction { } } - let selected_count = source_cover.iter().filter(|&&x| x).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", - )); - } - source_cover }) } } -/// Result of reducing Decision> to +/// Result of reducing Decision> to /// HamiltonianCircuit. #[derive(Debug, Clone)] pub struct ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { @@ -246,8 +228,7 @@ impl ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { #[cfg(any(test, feature = "example-db"))] fn build_target_witness(&self, source_cover: &[bool]) -> Vec { match &self.construction { - ConstructionKind::FixedYes { .. } => vec![0, 1, 2], - ConstructionKind::FixedNo => Vec::new(), + ConstructionKind::Fixed { .. } => vec![0, 1, 2], ConstructionKind::Theorem(construction) => { construction.build_target_witness(source_cover) } @@ -256,37 +237,34 @@ impl ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { } impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { - type Source = Decision>; + type Source = Decision>; type Target = HamiltonianCircuit; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { 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::FixedNo => { - return Err(crate::rules::ExtractionError::invalid( - "the fixed negative target instance has no extractable witness", - )) - } + ConstructionKind::Fixed { source_cover } => source_cover.clone(), ConstructionKind::Theorem(construction) => { - construction.decode_solution(&self.target, target_solution)? + construction.decode_solution(target_solution)? } } }) @@ -299,6 +277,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 } @@ -313,31 +292,32 @@ fn insert_edge(edges: &mut BTreeSet<(usize, usize)>, a: usize, b: usize) { 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(); + // Every loop forces its vertex into every cover. Apply the loopless + // theorem only to edges not already covered by these forced 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)), - construction: ConstructionKind::FixedNo, + construction: ConstructionKind::Fixed { + source_cover: vec![false; num_source_vertices], + }, }); } - let k = self.k(); - let edges = normalize_edges(self.inner().graph().edges()); let mut incident_edges = vec![Vec::new(); num_source_vertices]; for (edge_idx, &(u, v)) in edges.iter().enumerate() { incident_edges[u].push(edge_idx); @@ -352,27 +332,30 @@ impl ReduceTo> 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; } return Ok(ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { target: HamiltonianCircuit::new(SimpleGraph::cycle(3)), - construction: ConstructionKind::FixedYes { source_cover }, + construction: ConstructionKind::Fixed { source_cover }, }); } - if k == 0 { + if raw_bound == 0 { return Ok(ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { target: HamiltonianCircuit::new(SimpleGraph::path(3)), - construction: ConstructionKind::FixedNo, + construction: ConstructionKind::Fixed { + source_cover: vec![false; num_source_vertices], + }, }); } let construction = TheoremConstruction { - num_source_vertices, - selector_count: k, + forced_cover, + selector_count: usize::try_from(raw_bound) + .expect("nonnegative bound is smaller than the active vertex count"), edges, incident_edges, }; @@ -426,7 +409,7 @@ impl ReduceTo> for Decision>, + Decision>, HamiltonianCircuit, >("active source vertex has no Hamiltonian gadget path endpoints") })?; @@ -457,7 +440,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)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionDirectedHamiltonianPathToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_vertices; // Decode one-hot assignment: permutation[k] = v where x_{v,k} = 1 - one_hot_decode(target_solution, n, n, 0)? + one_hot_decode(target_solution, n, n, 0) }) } } diff --git a/src/rules/directedtwocommodityintegralflow_ilp.rs b/src/rules/directedtwocommodityintegralflow_ilp.rs index 6e644d21f..b0845d575 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -15,7 +15,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::DirectedTwoCommodityIntegralFlow; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing DirectedTwoCommodityIntegralFlow to `ILP`. /// @@ -37,12 +38,22 @@ impl ReductionResult for ReductionD2CIFToILP { } /// Extract flow solution: all 2*|A| variables directly encode the flow. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionD2CIFToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(&target_solution[..2 * self.num_arcs]) } } diff --git a/src/rules/disjointconnectingpaths_ilp.rs b/src/rules/disjointconnectingpaths_ilp.rs index 5d1269c13..d3f81e52c 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::DisjointConnectingPaths; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; use std::collections::VecDeque; @@ -36,12 +37,22 @@ impl ReductionResult for ReductionDCPToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionDCPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut result = vec![false; self.edges.len()]; for (k, &(source, sink)) in self.terminal_pairs.iter().enumerate() { let offset = k * self.num_edge_vars_per_commodity; @@ -71,12 +82,7 @@ impl ReductionResult for ReductionDCPToILP { } } let mut vertex = sink; - while vertex != source { - let (previous, edge) = predecessor[vertex].ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "commodity flow does not connect its terminal pair", - ) - })?; + while let Some((previous, edge)) = predecessor[vertex] { result[edge] = true; vertex = previous; } diff --git a/src/rules/ensemblecomputation_ilp.rs b/src/rules/ensemblecomputation_ilp.rs index 2ed359613..a61199023 100644 --- a/src/rules/ensemblecomputation_ilp.rs +++ b/src/rules/ensemblecomputation_ilp.rs @@ -3,7 +3,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::EnsembleComputation; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionEnsembleComputationToILP { @@ -38,34 +39,36 @@ impl ReductionResult for ReductionEnsembleComputationToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionEnsembleComputationToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut config = Vec::with_capacity(2 * self.budget); - let mut inactive = false; for step in 0..self.budget { let active = target_solution[self.activity_base + step]; if active == 0 { - inactive = true; - continue; - } - if active != 1 || inactive { - return Err(crate::rules::ExtractionError::invalid( - "active ensemble-operation slots must form a binary prefix", - )); + break; } for left in [true, false] { - let selected = (0..self.universe_size + step) - .filter(|&operand| target_solution[self.selector_var(left, step, operand)] == 1) - .collect::>(); - if selected.len() != 1 { - return Err(crate::rules::ExtractionError::invalid( - "each active ensemble operation must select exactly one operand per side", - )); - } - config.push(selected[0]); + config.push( + (0..self.universe_size + step) + .filter(|&operand| { + target_solution[self.selector_var(left, step, operand)] == 1 + }) + .sum(), + ); } } let filler = if self.universe_size >= 2 { diff --git a/src/rules/eulerianpath_ilp.rs b/src/rules/eulerianpath_ilp.rs index ce8ec318b..24c5b20b3 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -26,7 +26,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::EulerianPath; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing EulerianPath to `ILP`. /// @@ -68,56 +69,42 @@ impl ReductionResult for ReductionEulerianPathToILP { /// /// Reads the unique active start arc (`s_a = 1`) and walks the active /// successor relation (`y_{a,b} = 1`) one step at a time, producing an arc - /// permutation of length `m`. Malformed assignments return an extraction - /// error instead of fabricating an ordering. - fn extract_solution( + /// permutation of length `m` under the target path constraints. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionEulerianPathToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let m = self.num_arcs; if m == 0 { return Ok(Vec::new()); } - // Find the unique active start arc. - let mut current = match (0..m).find(|&a| target_solution[self.s_idx(a)] == 1) { - Some(a) => a, - None => { - return Err(crate::rules::ExtractionError::invalid( - "ILP witness has no active Eulerian-path start arc", - )); - } - }; - - // Walk the active successor relation, recording each visited arc. + let mut current = (0..m) + .filter(|&a| target_solution[self.s_idx(a)] == 1) + .sum(); let mut order = Vec::with_capacity(m); - let mut visited = vec![false; m]; - order.push(current); - visited[current] = true; - - for _ in 1..m { - let next = self + for _ in 0..m { + order.push(current); + current = self .pairs .iter() .enumerate() - .find(|&(k, &(a, _))| a == current && target_solution[k] == 1) - .map(|(_, &(_, b))| b); - - match next { - Some(b) if !visited[b] => { - order.push(b); - visited[b] = true; - current = b; - } - _ => { - return Err(crate::rules::ExtractionError::invalid(format!( - "ILP witness has no unvisited successor for arc {current}", - ))); - } - } + .filter(|&(k, &(a, _))| a == current && target_solution[k] == 1) + .map(|(_, &(_, b))| b) + .sum(); } order }) diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs index ace09c92b..d29fa0533 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -3,7 +3,8 @@ use crate::models::algebraic::AlgebraicEquationsOverGF2; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionX3CToAlgebraicEquationsOverGF2 { @@ -18,13 +19,12 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index f87ba0c2a..4bf1bd7c0 100644 --- a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -34,7 +34,8 @@ use crate::models::graph::BoundedDiameterSpanningTree; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; use std::collections::HashSet; @@ -94,18 +95,22 @@ impl ReductionResult for ReductionX3CToBoundedDiameterSpanningTree { /// 2..2+m (right after the forced-center path edges). For a YES-instance, /// the optimal target witness selects exactly q of these edges, which /// correspond to the q chosen subsets. - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionX3CToBoundedDiameterSpanningTree { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let m = self.source_num_subsets; let root_to_set_offset = 2; @@ -159,12 +164,12 @@ impl ReduceTo> for ExactCoverBy3Se edges.push((0, s_index(i))); weights.push(2); } - // Invariant: extract_solution reads edges[2..2 + m] to recover the + // Invariant: the reverse mapping reads edges[2..2 + m] to recover the // selected subsets. If this loop is ever reordered or moved, the // extractor must be updated to match. debug_assert!( (0..m).all(|i| edges[2 + i] == (0, s_index(i))), - "root-to-set edges must occupy indices 2..2+m for extract_solution to work" + "root-to-set edges must occupy indices 2..2+m for solution recovery to work" ); // Set-to-element edges. Subsets are already sorted in `ExactCoverBy3Sets::new`. diff --git a/src/rules/exactcoverby3sets_ilp.rs b/src/rules/exactcoverby3sets_ilp.rs index b42f93864..ef0a968ce 100644 --- a/src/rules/exactcoverby3sets_ilp.rs +++ b/src/rules/exactcoverby3sets_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionX3CToILP { @@ -21,13 +22,14 @@ impl ReductionResult for ReductionX3CToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/exactcoverby3sets_maximumsetpacking.rs b/src/rules/exactcoverby3sets_maximumsetpacking.rs index 60536abc0..1d70476e2 100644 --- a/src/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/rules/exactcoverby3sets_maximumsetpacking.rs @@ -7,7 +7,8 @@ use crate::models::set::{ExactCoverBy3Sets, MaximumSetPacking}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::types::One; /// Result of reducing ExactCoverBy3Sets to MaximumSetPacking. @@ -29,12 +30,22 @@ impl ReductionResult for ReductionXC3SToMaximumSetPacking { /// The configuration is identity (same binary selection vector). /// A packing of q disjoint 3-sets over a 3q-element universe is necessarily /// an exact cover, so no additional checking is needed. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionXC3SToMaximumSetPacking { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_minimumaxiomset.rs b/src/rules/exactcoverby3sets_minimumaxiomset.rs index 5c9827351..acf7aef60 100644 --- a/src/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/rules/exactcoverby3sets_minimumaxiomset.rs @@ -6,7 +6,8 @@ use crate::models::misc::MinimumAxiomSet; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ExactCoverBy3Sets to MinimumAxiomSet. #[derive(Debug, Clone)] @@ -27,14 +28,25 @@ 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. - fn extract_solution( + /// q set-sentences, which form an exact cover. If a target optimum does not + /// decode to an exact cover, the source result is `Infeasible`. Without + /// optimality, failure to decode a cover is insufficient evidence of NO. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionXC3SToMinimumAxiomSet { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let set_offset = self.source_universe_size; (0..self.source_num_subsets) diff --git a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index eb2846373..7a3c1d319 100644 --- a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -8,7 +8,8 @@ use crate::models::misc::MinimumFaultDetectionTestSet; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ExactCoverBy3Sets to MinimumFaultDetectionTestSet. #[derive(Debug, Clone)] @@ -24,12 +25,25 @@ impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { &self.target } - fn extract_solution( + /// Each input-output pair covers exactly its subset's three element vertices, so an + /// exact cover is a test set of size `q`, and every optimum then selects `q` disjoint + /// triples; any other optimum proves NO. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionXC3SToMinimumFaultDetectionTestSet { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.iter().map(|row| row[0]).collect()) } } diff --git a/src/rules/exactcoverby3sets_staffscheduling.rs b/src/rules/exactcoverby3sets_staffscheduling.rs index 3721b7fb9..8d0f8a6f4 100644 --- a/src/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/rules/exactcoverby3sets_staffscheduling.rs @@ -13,7 +13,8 @@ use crate::models::misc::StaffScheduling; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ExactCoverBy3Sets to StaffScheduling. #[derive(Debug, Clone)] @@ -33,13 +34,14 @@ impl ReductionResult for ReductionXC3SToStaffScheduling { /// /// StaffScheduling config[j] = number of workers assigned to schedule j. /// XC3S config[j] = 1 if subset j is selected, 0 otherwise. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&count| count > 0).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&count| count > 0).collect()) + }) } } diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index 4084f1a40..146c886df 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -9,7 +9,8 @@ use crate::models::formula::ksat::first_n_odd_primes; use crate::models::misc::SubsetProduct; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use num_bigint::BigUint; use num_traits::One; @@ -26,13 +27,12 @@ impl ReductionResult for ReductionX3CToSubsetProduct { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/expectedretrievalcost_ilp.rs b/src/rules/expectedretrievalcost_ilp.rs index a2d76cefb..bf365e7aa 100644 --- a/src/rules/expectedretrievalcost_ilp.rs +++ b/src/rules/expectedretrievalcost_ilp.rs @@ -18,19 +18,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ExpectedRetrievalCost; use crate::reduction; use crate::rules::ilp_helpers::{mccormick_product, one_hot_decode_rows}; -use crate::rules::traits::{ReduceTo, ReductionResult}; - -/// Compute the latency distance between sectors on a circular device. -/// -/// Returns the number of sectors between source and target (not counting source itself), -/// wrapping around. This matches the `latency_distance` function in the model. -fn latency_distance(num_sectors: usize, source: usize, target: usize) -> usize { - if source < target { - target - source - 1 - } else { - num_sectors - source + target - 1 - } -} +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ExpectedRetrievalCost to ILP. /// @@ -66,13 +55,28 @@ impl ReductionResult for ReductionERCToILP { } /// Extract solution: for each record r, find the unique sector s where x_{r,s} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode_rows(target_solution, self.num_records, self.num_sectors, 0) +impl ReductionERCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode_rows( + target_solution, + self.num_records, + self.num_sectors, + 0, + )) } } @@ -135,7 +139,7 @@ impl ReduceTo> for ExpectedRetrievalCost { for s in 0..num_sectors { for r2 in 0..num_records { for s2 in 0..num_sectors { - let lat = latency_distance(num_sectors, s, s2) as f64; + let lat = self.latency_distance(s, s2) as f64; if lat > 0.0 { let coeff = lat * probabilities[r] * probabilities[r2]; if coeff.abs() > 0.0 { diff --git a/src/rules/factoring_circuit.rs b/src/rules/factoring_circuit.rs index 330406035..caab8403b 100644 --- a/src/rules/factoring_circuit.rs +++ b/src/rules/factoring_circuit.rs @@ -10,7 +10,8 @@ use crate::models::formula::{Assignment, BooleanExpr, Circuit, CircuitSAT}; use crate::models::misc::Factoring; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use num_bigint::BigUint; use num_traits::{One, Zero}; /// Result of reducing Factoring to CircuitSAT. @@ -43,18 +44,22 @@ impl ReductionResult for ReductionFactoringToCircuit { /// Extract a Factoring solution from a CircuitSAT solution. /// /// Returns the decoded factors in ascending order. - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionFactoringToCircuit { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let var_names = self.target.variable_names(); @@ -69,21 +74,16 @@ impl ReductionResult for ReductionFactoringToCircuit { names .iter() .enumerate() - .try_fold(BigUint::zero(), |value, (index, name)| { - let bit = var_map.get(name.as_str()).copied().ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "target circuit does not contain factor variable {name}" - )) - })?; - Ok::(if bit { + .fold(BigUint::zero(), |value, (index, name)| { + if var_map[name.as_str()] { value + (BigUint::one() << index) } else { value - }) + } }) }; - let left = decode(&self.p_vars)?; - let right = decode(&self.q_vars)?; + let left = decode(&self.p_vars); + let right = decode(&self.q_vars); if left <= right { (left, right) } else { diff --git a/src/rules/factoring_ilp.rs b/src/rules/factoring_ilp.rs index eef11666f..c89568aca 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -23,7 +23,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::Factoring; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use std::cmp::min; /// Result of reducing Factoring to ILP. @@ -76,12 +77,22 @@ impl ReductionResult for ReductionFactoringToILP { /// The first m variables are p_i (first factor bits). /// The next n variables are q_j (second factor bits). /// Returns the decoded factors in ascending order. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionFactoringToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Extract p bits (first factor) let p = (0..self.m) diff --git a/src/rules/feasibleregisterassignment_ilp.rs b/src/rules/feasibleregisterassignment_ilp.rs index 69b181c5d..6139482de 100644 --- a/src/rules/feasibleregisterassignment_ilp.rs +++ b/src/rules/feasibleregisterassignment_ilp.rs @@ -13,7 +13,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::FeasibleRegisterAssignment; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionFeasibleRegisterAssignmentToILP { @@ -29,12 +30,22 @@ impl ReductionResult for ReductionFeasibleRegisterAssignmentToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionFeasibleRegisterAssignmentToILP { + pub(crate) fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(&target_solution[..self.num_vertices]) } } diff --git a/src/rules/flowshopscheduling_ilp.rs b/src/rules/flowshopscheduling_ilp.rs index d5a4d60ee..4dbbb2a8d 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::FlowShopScheduling; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing FlowShopScheduling to `ILP`. /// @@ -35,12 +36,22 @@ impl ReductionResult for ReductionFSSToILP { } /// Extract solution by sorting jobs by final-machine completion time C_{j,m-1}. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionFSSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_jobs; let m = self.num_machines; diff --git a/src/rules/graph.rs b/src/rules/graph.rs index f89ffbb49..fe00ebeb5 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -11,10 +11,9 @@ //! - JSON export for documentation and visualization use crate::rules::registry::{ - AggregateReduceFn, EdgeCapabilities, ParameterContractError, ReduceFn, ReductionEntry, + EdgeCapabilities, ExecutedStep, ParameterContractError, ReduceFn, ReductionEntry, ReductionParameterContract, }; -use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; use crate::types::ProblemParameters; use petgraph::algo::all_simple_paths; use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex}; @@ -22,7 +21,6 @@ use petgraph::visit::EdgeRef; use serde::Serialize; use std::any::Any; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; -use std::rc::Rc; type NodePathOrderKey<'a> = (usize, Vec<(&'static str, &'a BTreeMap)>); @@ -43,13 +41,12 @@ pub struct ReductionEdgeInfo { pub(crate) struct ReductionEdgeData { pub parameter_contract: Result, pub reduce_fn: Option, - pub reduce_aggregate_fn: Option, pub turing: bool, } impl ReductionEdgeData { fn capabilities(&self) -> EdgeCapabilities { - EdgeCapabilities::from_executors(self.reduce_fn, self.reduce_aggregate_fn, self.turing) + EdgeCapabilities::from_executors(self.reduce_fn, self.turing) } } @@ -121,8 +118,6 @@ pub(crate) struct EdgeJson { pub(crate) doc_path: String, /// Whether the edge supports witness/config workflows. pub(crate) witness: bool, - /// Whether the edge supports aggregate/value workflows. - pub(crate) aggregate: bool, /// Whether the edge is a Turing (multi-query) reduction. pub(crate) turing: bool, } @@ -330,9 +325,9 @@ pub enum TraversalFlow { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ReductionMode { Witness, - Aggregate, /// Multi-query (Turing) reductions: solving the source requires multiple /// adaptive queries to the target (e.g., binary search over a bound). + /// Queries theoretical graph relationships only; these paths are not executable. Turing, } @@ -454,7 +449,7 @@ impl ReductionGraph { ReductionEdgeData { parameter_contract, reduce_fn: entry.reduce_fn, - reduce_aggregate_fn: entry.reduce_aggregate_fn, + turing: entry.turing, }, ); @@ -497,7 +492,6 @@ impl ReductionGraph { fn edge_supports_mode(edge: &ReductionEdgeData, mode: ReductionMode) -> bool { match mode { ReductionMode::Witness => edge.reduce_fn.is_some(), - ReductionMode::Aggregate => edge.reduce_aggregate_fn.is_some(), ReductionMode::Turing => edge.turing, } } @@ -1407,7 +1401,6 @@ impl ReductionGraph { parameter_contract_error, doc_path, witness: capabilities.witness, - aggregate: capabilities.aggregate, turing: capabilities.turing, }); } @@ -1528,21 +1521,54 @@ pub struct MatchedEntry { pub parameter_contract: Result, } +/// Recover each intermediate result with its corresponding source instance. +fn recover_steps( + steps: &[ExecutedStep], + source: &dyn Any, + mut target: crate::solvers::ErasedOutcome, +) -> crate::rules::ExtractionResult { + for index in (0..steps.len()).rev() { + let input = if index == 0 { + source + } else { + steps[index - 1].witness.target_problem_any() + }; + target = steps[index].witness.recover_result_dyn(input, target)?; + } + Ok(target) +} + /// 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. pub struct ReductionChain { - steps: Vec>, + pub(crate) steps: Vec, } impl ReductionChain { + pub(crate) fn execute( + source: &dyn Any, + reducers: &[ReduceFn], + ) -> Result { + let mut steps: Vec = Vec::with_capacity(reducers.len()); + for reduce in reducers { + let input = steps + .last() + .map(|step| step.witness.target_problem_any()) + .unwrap_or(source); + steps.push(reduce(input)?); + } + Ok(Self { steps }) + } + /// Get the final target problem as a type-erased reference. pub fn target_problem_any(&self) -> &dyn Any { self.steps .last() .expect("ReductionChain has no steps") + .witness .target_problem_any() } @@ -1555,87 +1581,56 @@ impl ReductionChain { .expect("ReductionChain target type mismatch") } - /// Extract a solution from target space back to source space. - pub fn extract_solution( + /// Recover a typed result through every executed step. + pub fn recover_result< + S: crate::traits::Problem + 'static, + T: crate::traits::Problem + 'static, + >( &self, - target_solution: &T, - ) -> crate::rules::ExtractionResult { - let mut steps = self.steps.iter().rev(); - let first = steps.next().expect("ReductionChain has no steps"); - let mut solution = first.extract_solution_dyn(target_solution)?; - for step in steps { - solution = step.extract_solution_dyn(solution.as_ref())?; - } - solution - .downcast::() - .map(|solution| *solution) - .map_err(|_| crate::rules::ExtractionError::invalid("source solution type mismatch")) + source: &S, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> + where + S::Solution: 'static, + S::Value: 'static, + T::Solution: 'static, + T::Value: 'static, + { + crate::solvers::downcast_outcome(recover_steps( + &self.steps, + source, + crate::solvers::erase_outcome(target), + )?) } - /// Extract a JSON target witness into a JSON source witness. - pub fn extract_solution_json( + pub(crate) fn recover_erased( &self, - target_solution: serde_json::Value, - ) -> crate::rules::ExtractionResult { - let last = self.steps.last().expect("ReductionChain has no steps"); - let mut solution = last.target_solution_from_json(target_solution)?; - for step in self.steps.iter().rev() { - solution = step.extract_solution_dyn(solution.as_ref())?; - } - self.steps[0].source_solution_json(solution.as_ref()) - } -} - -/// A composed aggregate reduction chain produced by -/// [`ReductionGraph::reduce_aggregate_along_path`]. -pub struct AggregateReductionChain { - steps: Vec>, -} - -impl AggregateReductionChain { - /// Get the final target problem as a type-erased reference. - pub fn target_problem_any(&self) -> &dyn Any { - self.steps - .last() - .expect("AggregateReductionChain has no steps") - .target_problem_any() - } - - /// Get a typed reference to the final target problem. - /// - /// Panics if the actual target type does not match `T`. - pub fn target_problem(&self) -> &T { - self.target_problem_any() - .downcast_ref::() - .expect("AggregateReductionChain target type mismatch") + source: &dyn Any, + target: crate::solvers::ErasedOutcome, + ) -> crate::rules::ExtractionResult { + recover_steps(&self.steps, source, target) } - /// 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 { - self.steps - .iter() - .rev() - .fold(target_value, |value, step| step.extract_value_dyn(value)) + /// Recover the source result and return it with the validated target result. + /// The returned pair is `(source, target)`; target evaluation is computed once + /// from the model. An optional incoming evaluation must match that value. + pub fn recover_result_json( + &self, + source: &dyn Any, + target: serde_json::Value, + ) -> crate::rules::ExtractionResult<(crate::solvers::SolveOutcome, crate::solvers::SolveOutcome)> + { + let last = self.steps.last().expect("ReductionChain has no steps"); + let (target, target_json) = last.witness.target_result_from_json(target)?; + let source = recover_steps(&self.steps, source, target)?; + Ok(( + self.steps[0].witness.source_result_json(source)?, + target_json, + )) } } impl ReductionGraph { - fn execute_aggregate_edge( - &self, - edge_idx: EdgeIndex, - 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); - }; - reduce(input).map(Some) - } - /// Execute a reduction path on a source problem instance. /// /// Looks up each edge's `reduce_fn`, chains them, and returns the @@ -1649,7 +1644,8 @@ impl ReductionGraph { /// return Err("path is not witness-executable".into()); /// }; /// let target: &QUBO = chain.target_problem(); - /// let source_solution = chain.extract_solution(&target_solution); + /// let target_result = SolveOutcome::optimal(target, target_solution)?; + /// let source_result = chain.recover_result::>(&source_problem, target_result)?; /// ``` pub fn reduce_along_path( &self, @@ -1679,60 +1675,7 @@ impl ReductionGraph { }; edge_fns.push(reduce); } - // Execute the chain - let mut steps: Vec> = Vec::new(); - let step = (edge_fns[0])(source)?; - steps.push(step); - for edge_fn in &edge_fns[1..] { - let step = { - let prev_target = steps.last().unwrap().target_problem_any(); - edge_fn(prev_target)? - }; - steps.push(step); - } - Ok(Some(ReductionChain { steps })) - } - - /// Execute an aggregate-value reduction path on a source problem instance. - pub fn reduce_aggregate_along_path( - &self, - path: &ReductionPath, - source: &dyn Any, - ) -> Result, crate::rules::ReductionError> { - if path.steps.len() < 2 { - return Ok(None); - } - - let mut edge_indices = 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); - }; - let Some(dst) = self.lookup_node(&window[1].name, &window[1].variant) else { - return Ok(None); - }; - let Some(edge_idx) = self.graph.find_edge(src, dst) else { - return Ok(None); - }; - edge_indices.push(edge_idx); - } - - let mut steps: Vec> = Vec::new(); - let Some(step) = self.execute_aggregate_edge(edge_indices[0], source)? else { - return Ok(None); - }; - steps.push(step); - for &edge_idx in &edge_indices[1..] { - let step = { - let prev_target = steps.last().unwrap().target_problem_any(); - let Some(step) = self.execute_aggregate_edge(edge_idx, prev_target)? else { - return Ok(None); - }; - step - }; - steps.push(step); - } - Ok(Some(AggregateReductionChain { steps })) + Ok(Some(ReductionChain::execute(source, &edge_fns)?)) } } @@ -1744,7 +1687,7 @@ pub struct ExecutedPath { /// The variant-level path. pub path: ReductionPath, /// The executed reduction steps (one per hop), shared via `Rc`. - steps: Vec>, + steps: Vec, } impl ExecutedPath { @@ -1753,6 +1696,7 @@ impl ExecutedPath { self.steps .last() .expect("ExecutedPath has no steps") + .witness .target_problem_any() } @@ -1765,27 +1709,32 @@ impl ExecutedPath { ReductionGraph::compute_problem_parameters( &target.name, &target.variant, - result.target_problem_any(), + result.witness.target_problem_any(), ) }) .collect() } - /// Extract a solution from target space back to source space. - pub fn extract_solution( + /// Recover a typed result through the shared executed prefix. + pub fn recover_result< + S: crate::traits::Problem + 'static, + T: crate::traits::Problem + 'static, + >( &self, - target_solution: &T, - ) -> crate::rules::ExtractionResult { - let mut steps = self.steps.iter().rev(); - let first = steps.next().expect("ExecutedPath has no steps"); - let mut solution = first.extract_solution_dyn(target_solution)?; - for step in steps { - solution = step.extract_solution_dyn(solution.as_ref())?; - } - solution - .downcast::() - .map(|solution| *solution) - .map_err(|_| crate::rules::ExtractionError::invalid("source solution type mismatch")) + source: &S, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> + where + S::Solution: 'static, + S::Value: 'static, + T::Solution: 'static, + T::Value: 'static, + { + crate::solvers::downcast_outcome(recover_steps( + &self.steps, + source, + crate::solvers::erase_outcome(target), + )?) } } @@ -1796,8 +1745,7 @@ impl ReductionGraph { paths: &[ReductionPath], source_instance: &dyn Any, ) -> Result, ExecutePathsError> { - let mut prefixes: HashMap, Vec>> = - HashMap::new(); + let mut prefixes: HashMap, ExecutedStep> = HashMap::new(); let mut executed = Vec::with_capacity(paths.len()); let mut batch_source: Option<&ReductionStep> = None; for (path_index, path) in paths.iter().enumerate() { @@ -1815,14 +1763,12 @@ impl ReductionGraph { } else { batch_source = Some(source); } - let source_prefix = vec![source.clone()]; - let mut chain = prefixes.get(&source_prefix).cloned().unwrap_or_default(); - prefixes.entry(source_prefix.clone()).or_default(); - let mut prefix = source_prefix; + let mut chain: Vec = Vec::with_capacity(path.len()); + let mut prefix = vec![source.clone()]; for pair in path.steps.windows(2) { prefix.push(pair[1].clone()); if let Some(cached) = prefixes.get(&prefix) { - chain = cached.clone(); + chain.push(cached.clone()); continue; } let source_node = self @@ -1857,12 +1803,12 @@ impl ReductionGraph { }; let current = chain .last() - .map(|step| step.target_problem_any()) + .map(|step| step.witness.target_problem_any()) .unwrap_or(source_instance); let result = reduce_fn(current) .map_err(|cause| ExecutePathsError::Reduction { path_index, cause })?; - chain.push(Rc::from(result)); - prefixes.insert(prefix.clone(), chain.clone()); + prefixes.insert(prefix.clone(), result.clone()); + chain.push(result); } executed.push(ExecutedPath { path: path.clone(), diff --git a/src/rules/graph_helpers.rs b/src/rules/graph_helpers.rs index 288c734c1..f9e74619e 100644 --- a/src/rules/graph_helpers.rs +++ b/src/rules/graph_helpers.rs @@ -2,82 +2,28 @@ use crate::topology::{Graph, SimpleGraph}; -/// Extract a Hamiltonian cycle vertex ordering from edge-selection configs on complete graphs. -/// -/// Given a graph and a binary `target_solution` over its edges (1 = selected), -/// walks the selected edges to produce a vertex permutation representing the cycle. -/// Returns an error if the selection does not form a valid Hamiltonian cycle. -pub(crate) fn edges_to_cycle_order( - graph: &G, - target_solution: &[bool], -) -> crate::rules::ExtractionResult> { +/// Order the vertices of a selected Hamiltonian cycle. +/// Target feasibility and the reduction's premises establish a single cycle. +pub(crate) fn edges_to_cycle_order(graph: &G, target_solution: &[bool]) -> Vec { let n = graph.num_vertices(); - if n == 0 { - return Ok(vec![]); - } - - let edges = graph.edges(); - if target_solution.len() != edges.len() { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} edge-selection values, got {}", - edges.len(), - target_solution.len() - ))); - } - let mut adjacency = vec![Vec::new(); n]; - let mut selected_count = 0usize; - for (idx, &selected) in target_solution.iter().enumerate() { - if !selected { - continue; + for ((u, v), &selected) in graph.edges().into_iter().zip(target_solution) { + if selected { + adjacency[u].push(v); + adjacency[v].push(u); } - let (u, v) = edges[idx]; - adjacency[u].push(v); - adjacency[v].push(u); - selected_count += 1; } - - if selected_count != n || adjacency.iter().any(|neighbors| neighbors.len() != 2) { - return Err(crate::rules::ExtractionError::invalid( - "selected edges do not form a Hamiltonian cycle", - )); - } - let mut order = Vec::with_capacity(n); - let mut visited = vec![false; n]; - let mut prev = None; - let mut current = 0usize; - + let mut previous = n; + let mut current = 0; for _ in 0..n { - if visited[current] { - return Err(crate::rules::ExtractionError::invalid( - "selected edges contain multiple disjoint cycles", - )); - } - visited[current] = true; order.push(current); let neighbors = &adjacency[current]; - let next = match prev { - Some(previous) => { - if neighbors[0] == previous { - neighbors[1] - } else { - neighbors[0] - } - } - None => neighbors[0], - }; - prev = Some(current); + let next = neighbors[usize::from(neighbors[0] == previous)]; + previous = current; current = next; } - - if current != 0 || visited.iter().any(|seen| !seen) { - return Err(crate::rules::ExtractionError::invalid( - "selected edges do not form one Hamiltonian cycle", - )); - } - - Ok(order) + order } /// Build the complement graph edges: edges between all non-adjacent vertex pairs. @@ -93,15 +39,3 @@ pub(crate) fn complement_edges(graph: &SimpleGraph) -> Vec<(usize, usize)> { } edges } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rejects_disjoint_selected_cycles() { - let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]); - - assert!(edges_to_cycle_order(&graph, &[true; 6]).is_err()); - } -} diff --git a/src/rules/graphpartitioning_ilp.rs b/src/rules/graphpartitioning_ilp.rs index 3186696a2..dfb46b391 100644 --- a/src/rules/graphpartitioning_ilp.rs +++ b/src/rules/graphpartitioning_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::GraphPartitioning; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing GraphPartitioning to ILP. @@ -30,12 +31,22 @@ impl ReductionResult for ReductionGraphPartitioningToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionGraphPartitioningToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs index 658bfe136..b0ef48569 100644 --- a/src/rules/graphpartitioning_maxcut.rs +++ b/src/rules/graphpartitioning_maxcut.rs @@ -3,6 +3,8 @@ use crate::models::graph::{GraphPartitioning, MaxCut}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing GraphPartitioning to MaxCut. @@ -22,13 +24,21 @@ impl ReductionResult for ReductionGPToMaxCut { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + if !source.num_vertices().is_multiple_of(2) { + return Ok(SolveOutcome::Infeasible); + } + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => Ok(SolveOutcome::optimal(source, solution)?), + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } } } diff --git a/src/rules/graphpartitioning_qubo.rs b/src/rules/graphpartitioning_qubo.rs index 9840bc491..0b7e740cb 100644 --- a/src/rules/graphpartitioning_qubo.rs +++ b/src/rules/graphpartitioning_qubo.rs @@ -8,6 +8,8 @@ use crate::models::algebraic::QUBO; use crate::models::graph::GraphPartitioning; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing GraphPartitioning to QUBO. @@ -24,13 +26,21 @@ impl ReductionResult for ReductionGraphPartitioningToQUBO { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + if !source.num_vertices().is_multiple_of(2) { + return Ok(SolveOutcome::Infeasible); + } + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => Ok(SolveOutcome::optimal(source, solution)?), + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } } } @@ -52,7 +62,7 @@ impl ReduceTo> for GraphPartitioning { let penalty = edge_count .checked_add(1) .ok_or_else(|| overflow("computing the balance penalty"))?; - let mut matrix = vec![vec![0i64; n]; n]; + let mut matrix = vec![std::collections::BTreeMap::new(); n]; let mut degrees = vec![0usize; n]; let edges = self.graph().edges(); @@ -70,11 +80,11 @@ impl ReduceTo> for GraphPartitioning { .ok_or_else(|| overflow("computing a balance coefficient"))?, ) .ok_or_else(|| overflow("computing a balance coefficient"))?; - row[i] = degree + *row.entry(i).or_insert(0i64) = degree .checked_add(balance_linear) .ok_or_else(|| overflow("combining QUBO diagonal coefficients"))?; - for value in row.iter_mut().skip(i + 1) { - *value = penalty + for j in (i + 1)..n { + *row.entry(j).or_insert(0i64) = penalty .checked_mul(2) .ok_or_else(|| overflow("computing a balance interaction coefficient"))?; } @@ -82,13 +92,14 @@ impl ReduceTo> for GraphPartitioning { for (u, v) in edges { let (lo, hi) = if u < v { (u, v) } else { (v, u) }; - matrix[lo][hi] = matrix[lo][hi] + let coefficient = matrix[lo].entry(hi).or_insert(0i64); + *coefficient = coefficient .checked_sub(2) .ok_or_else(|| overflow("adding a cut interaction coefficient"))?; } Ok(ReductionGraphPartitioningToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { + target: QUBO::from_rows(matrix).map_err(|message| { crate::rules::ReductionError::construction::< GraphPartitioning, QUBO, diff --git a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index 07576fe0e..cf2cef02b 100644 --- a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -23,7 +23,8 @@ use crate::models::graph::{BiconnectivityAugmentation, HamiltonianCircuit}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to BiconnectivityAugmentation. @@ -47,26 +48,24 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionHamiltonianCircuitToBiconnectivityAugmentation { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_vertices; - if n < 3 { - return Err(crate::rules::ExtractionError::invalid( - "a Hamiltonian circuit requires at least three vertices", - )); - } - // Collect selected edges (those with config value 1) let mut adj: Vec> = vec![vec![]; n]; for (i, &(u, v)) in self.potential_edges.iter().enumerate() { @@ -76,43 +75,17 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation } } - // Check that every vertex has exactly degree 2 (Hamiltonian cycle) - if adj.iter().any(|neighbors| neighbors.len() != 2) { - return Err(crate::rules::ExtractionError::invalid( - "selected edges do not give every source vertex degree two", - )); - } - - // Walk the cycle starting from vertex 0 let mut circuit = Vec::with_capacity(n); - circuit.push(0); - let mut prev = 0; - let mut current = adj[0][0]; - while current != 0 { + let mut previous = n; + let mut current = 0; + for _ in 0..n { circuit.push(current); - let next = if adj[current][0] == prev { - adj[current][1] - } else { - adj[current][0] - }; - prev = current; + let neighbors = &adj[current]; + let next = neighbors[usize::from(neighbors[0] == previous)]; + previous = current; current = next; - - // Safety: if we've visited more than n vertices, something is wrong - if circuit.len() > n { - return Err(crate::rules::ExtractionError::invalid( - "selected edges revisit a source vertex", - )); - } - } - - if circuit.len() == n { - circuit - } else { - return Err(crate::rules::ExtractionError::invalid( - "selected edges do not form a spanning circuit", - )); } + circuit }) } } diff --git a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index abe6c3783..182517ffc 100644 --- a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -6,7 +6,8 @@ use crate::models::graph::{BottleneckTravelingSalesman, HamiltonianCircuit}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to BottleneckTravelingSalesman. @@ -23,13 +24,28 @@ impl ReductionResult for ReductionHamiltonianCircuitToBottleneckTravelingSalesma &self.target } - fn extract_solution( + /// A Hamiltonian circuit is a tour of bottleneck 1, the minimum over weights 1 and 2, so + /// every optimal tour then uses only weight-1 (source) edges; any other optimum proves NO. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) +impl ReductionHamiltonianCircuitToBottleneckTravelingSalesman { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::graph_helpers::edges_to_cycle_order( + self.target.graph(), + target_solution, + )) } } diff --git a/src/rules/hamiltoniancircuit_hamiltonianpath.rs b/src/rules/hamiltoniancircuit_hamiltonianpath.rs index 4d15d9227..2c0580048 100644 --- a/src/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -14,7 +14,8 @@ use crate::models::graph::{HamiltonianCircuit, HamiltonianPath}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to HamiltonianPath. @@ -36,44 +37,41 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionHamiltonianCircuitToHamiltonianPath { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_original_vertices; if n == 0 { return Ok(vec![]); } - let v_prime = n; // index of duplicated vertex v' - let s = n + 1; // pendant attached to v=0 - let t = n + 2; // pendant attached to v' - - // The two pendants force any valid witness to have endpoints s and t. - let reversed; - let oriented = match (target_solution.first(), target_solution.last()) { - (Some(&start), Some(&end)) if start == s && end == t => target_solution, - (Some(&start), Some(&end)) if start == t && end == s => { - reversed = target_solution.iter().copied().rev().collect::>(); - reversed.as_slice() - } - _ => { - return Err(crate::rules::ExtractionError::invalid( - "target path does not have the required pendant endpoints", - )) - } - }; - - if oriented.get(1) != Some(&0) || oriented.get(n + 1) != Some(&v_prime) { - return Err(crate::rules::ExtractionError::invalid( - "target path does not traverse the duplicated source vertex correctly", - )); + let s = n + 1; + // Pendant vertices force the path's endpoints; orient from s. + if target_solution[0] == s { + target_solution[1..=n].to_vec() + } else { + target_solution + .iter() + .rev() + .skip(1) + .take(n) + .copied() + .collect() } - - oriented[1..=n].to_vec() }) } } diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs index 34ae58de9..67a490e10 100644 --- a/src/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -4,73 +4,72 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 } - fn extract_solution( + fn recover_result( &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::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(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())), - ) +impl ReductionHamiltonianCircuitToLongestCircuit { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::graph_helpers::edges_to_cycle_order( + self.target.inner().graph(), + target_solution, + )) } } #[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 +81,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..13fcb1802 100644 --- a/src/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -6,63 +6,55 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 } - fn extract_solution( + fn recover_result( &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", - )); - } - - // Zero cost makes this permutation itself a Hamiltonian circuit. - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) } } -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))) +impl ReductionHamiltonianCircuitToQuadraticAssignment { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + // Zero cost makes this permutation itself a Hamiltonian circuit. + Ok(target_solution.to_vec()) } } #[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 +74,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 +88,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..300ef86fb 100644 --- a/src/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -23,15 +23,17 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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,18 +42,28 @@ pub struct ReductionHamiltonianCircuitToRuralPostman { impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { type Source = HamiltonianCircuit; - type Target = RuralPostman; + type Target = Decision>; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionHamiltonianCircuitToRuralPostman { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // The target solution is edge multiplicities. // Required edges are indices 0..n (the {v_i^a, v_i^b} edges). @@ -90,11 +102,6 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { for _ in 0..n { cycle.push(current); let next = successor[current]; - if next == usize::MAX { - return Err(crate::rules::ExtractionError::invalid( - "target tour does not provide one successor for every source vertex", - )); - } current = next; } @@ -110,7 +117,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 +152,17 @@ impl ReduceTo> for HamiltonianCircuit>>>::exact_i64( + 2 * n, + "encoding the route bound", + )? + }, + ), n, source_edges, }) @@ -169,7 +186,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..f262c1108 100644 --- a/src/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -12,69 +12,57 @@ //! 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; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 } - fn extract_solution( + fn recover_result( &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", - )); - } - // Service arc i corresponds to source vertex i. - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) } } -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())), - ) +impl ReductionHamiltonianCircuitToStackerCrane { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + // Service arc i corresponds to source vertex i. + Ok(target_solution.to_vec()) } } #[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 +91,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 +116,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 +125,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 +140,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..fae3b2ffb 100644 --- a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -9,7 +9,8 @@ use crate::models::graph::{HamiltonianCircuit, StrongConnectivityAugmentation}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{DirectedGraph, Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to StrongConnectivityAugmentation. @@ -27,18 +28,24 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionHamiltonianCircuitToStrongConnectivityAugmentation { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.n; - if n == 0 { - return Ok(vec![]); - } - // Build directed adjacency from selected arcs. let candidate_arcs = self.target.candidate_arcs(); let mut successors = vec![Vec::new(); n]; @@ -52,20 +59,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta // Walk the directed cycle starting from vertex 0. let mut order = Vec::with_capacity(n); let mut current = 0; - let mut visited = vec![false; n]; for _ in 0..n { - if visited[current] { - return Err(crate::rules::ExtractionError::invalid( - "selected arcs revisit a source vertex", - )); - } - visited[current] = true; order.push(current); - if successors[current].len() != 1 { - return Err(crate::rules::ExtractionError::invalid( - "selected arcs do not provide one successor for every source vertex", - )); - } current = successors[current][0]; } diff --git a/src/rules/hamiltoniancircuit_travelingsalesman.rs b/src/rules/hamiltoniancircuit_travelingsalesman.rs index 8e90e4ac8..c3f4a3b24 100644 --- a/src/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -6,7 +6,8 @@ use crate::models::graph::{HamiltonianCircuit, TravelingSalesman}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianCircuit to TravelingSalesman. @@ -23,13 +24,28 @@ impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { &self.target } - fn extract_solution( + /// A Hamiltonian circuit is a tour of cost `n`, the minimum over weights 1 and 2, so + /// every optimal tour then uses only weight-1 (source) edges; any other optimum proves NO. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) +impl ReductionHamiltonianCircuitToTravelingSalesman { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::graph_helpers::edges_to_cycle_order( + self.target.graph(), + target_solution, + )) } } diff --git a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index c783517cb..3f4d1938d 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -4,7 +4,8 @@ use crate::models::graph::{DegreeConstrainedSpanningTree, HamiltonianPath}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianPath to DegreeConstrainedSpanningTree. @@ -21,13 +22,26 @@ impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - extract_hamiltonian_order(self.target.graph(), target_solution) +impl ReductionHamiltonianPathToDegreeConstrainedSpanningTree { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(extract_hamiltonian_order( + self.target.graph(), + target_solution, + )) } } @@ -49,13 +63,10 @@ impl ReduceTo> for HamiltonianPath crate::rules::ExtractionResult> { +fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[bool]) -> Vec { let num_vertices = graph.num_vertices(); if num_vertices < 2 { - return Ok((0..num_vertices).collect()); + return (0..num_vertices).collect(); } let edges = graph.edges(); @@ -74,46 +85,24 @@ fn extract_hamiltonian_order( .filter_map(|(vertex, neighbors)| (neighbors.len() == 1).then_some(vertex)) .collect(); endpoints.sort_unstable(); - if endpoints.len() != 2 { - return Err(crate::rules::ExtractionError::invalid( - "selected edges do not form a Hamiltonian path", - )); - } - let mut order = Vec::with_capacity(num_vertices); - let mut visited = vec![false; num_vertices]; let mut previous = None; let mut current = endpoints[0]; - loop { - if visited[current] { - return Err(crate::rules::ExtractionError::invalid( - "selected edges contain a cycle", - )); - } - visited[current] = true; order.push(current); - - let next = adjacency[current] + match adjacency[current] .iter() .copied() - .find(|&neighbor| Some(neighbor) != previous && !visited[neighbor]); - match next { - Some(next_vertex) => { + .find(|&neighbor| Some(neighbor) != previous) + { + Some(next) => { previous = Some(current); - current = next_vertex; + current = next; } None => break, } } - - if order.len() == num_vertices { - Ok(order) - } else { - Err(crate::rules::ExtractionError::invalid( - "selected edges do not span every source vertex", - )) - } + order } #[cfg(feature = "example-db")] diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index 378545040..dd50ef1ca 100644 --- a/src/rules/hamiltonianpath_ilp.rs +++ b/src/rules/hamiltonianpath_ilp.rs @@ -12,7 +12,8 @@ use crate::reduction; use crate::rules::ilp_helpers::{ mccormick_product, one_hot_assignment_constraints, one_hot_decode, }; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HamiltonianPath to ILP. @@ -35,13 +36,28 @@ impl ReductionResult for ReductionHamiltonianPathToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode(target_solution, self.num_vertices, self.num_vertices, 0) +impl ReductionHamiltonianPathToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode( + target_solution, + self.num_vertices, + self.num_vertices, + 0, + )) } } diff --git a/src/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/rules/hamiltonianpath_isomorphicspanningtree.rs index b95b555d4..4b260768a 100644 --- a/src/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -6,7 +6,8 @@ use crate::models::graph::{HamiltonianPath, IsomorphicSpanningTree}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; /// Result of reducing HamiltonianPath to IsomorphicSpanningTree. @@ -28,13 +29,12 @@ impl ReductionResult for ReductionHPToIST { /// The IST config maps tree vertex i to graph vertex config[i]. Since the /// tree is P_n (path 0-1-2-...-n-1), this mapping directly gives the /// vertex ordering of the Hamiltonian path. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index 0a9186ee1..c8c722cfa 100644 --- a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -5,21 +5,23 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; 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 @@ -29,20 +31,27 @@ impl ReductionResult for ReductionHPBTVToLP { /// /// The target solution is a binary vector over edges. We walk the selected /// edges from the source vertex to reconstruct the vertex ordering. - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - let mut adjacency = vec![Vec::new(); self.target.num_vertices()]; - for (&selected, (u, v)) in target_solution.iter().zip(self.target.graph().edges()) { +impl ReductionHPBTVToLP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + 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 +61,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 +77,14 @@ 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) - }), - ) - } -} - #[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 +99,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 +124,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..68986c6ef 100644 --- a/src/rules/highlyconnecteddeletion_ilp.rs +++ b/src/rules/highlyconnecteddeletion_ilp.rs @@ -28,7 +28,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::highly_connected_deletion::{induced_edge_count, is_feasible_cluster}; use crate::models::graph::HighlyConnectedDeletion; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing HighlyConnectedDeletion to ILP. @@ -60,36 +61,31 @@ impl ReductionResult for ReductionHighlyConnectedDeletionToILP { /// For every source edge `(u, v)`, the edge is *kept* iff some chosen /// cluster `S` (i.e. with `x_S = 1`) contains both `u` and `v`; otherwise /// it is deleted (`config[e] = 1`). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionHighlyConnectedDeletionToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut cluster_of: Vec> = vec![None; vertex_count(&self.clusters)]; for (c, cluster) in self.clusters.iter().enumerate() { if target_solution[c] == 1 { for &v in cluster { - if cluster_of[v].is_some() { - return Err(crate::rules::ExtractionError::invalid(format!( - "vertex {v} belongs to multiple selected clusters" - ))); - } cluster_of[v] = Some(c); } - } else if target_solution[c] != 0 { - return Err(crate::rules::ExtractionError::invalid(format!( - "cluster selection {c} is not binary" - ))); } } - if let Some(vertex) = cluster_of.iter().position(Option::is_none) { - return Err(crate::rules::ExtractionError::invalid(format!( - "vertex {vertex} has no selected cluster" - ))); - } - Ok(self .edges .iter() @@ -117,13 +113,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 +131,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 +146,7 @@ fn enumerate_feasible_clusters(graph: &SimpleGraph) -> Vec> { } } - clusters + Ok(clusters) } #[reduction( @@ -165,7 +164,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_bool_ilp_i64.rs b/src/rules/ilp_bool_ilp_i64.rs index c5bb1df86..68d0016d9 100644 --- a/src/rules/ilp_bool_ilp_i64.rs +++ b/src/rules/ilp_bool_ilp_i64.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::ILP; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionBinaryILPToIntILP { @@ -21,13 +22,12 @@ impl ReductionResult for ReductionBinaryILPToIntILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/ilp_casts.rs b/src/rules/ilp_casts.rs deleted file mode 100644 index 586fe6605..000000000 --- a/src/rules/ilp_casts.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Numeric variant reductions for ILP. - -use crate::models::algebraic::{Comparison, LinearConstraint, VariableDomain, ILP}; -use crate::reduction; -use crate::rules::{ReduceTo, ReductionError, ReductionResult}; -use crate::types::i64_to_exact_f64; - -#[derive(Debug, Clone)] -pub struct ReductionILPToFloat { - source: ILP, - target: ILP, -} - -impl ReductionILPToFloat { - fn new(source: &ILP) -> Result { - let convert = |coefficient: i64| { - i64_to_exact_f64(coefficient) - .map_err(ReductionError::inexact_float_conversion::, ILP>) - }; - let constraints = source - .constraints() - .iter() - .map(|constraint| { - let terms = constraint - .terms() - .iter() - .map(|&(variable, coefficient)| Ok((variable, convert(coefficient)?))) - .collect::, ReductionError>>()?; - let rhs = convert(constraint.rhs())?; - Ok(match constraint.comparison() { - Comparison::Le => LinearConstraint::le(terms, rhs), - Comparison::Ge => LinearConstraint::ge(terms, rhs), - Comparison::Eq => LinearConstraint::eq(terms, rhs), - }) - }) - .collect::, ReductionError>>()?; - let objective = source - .objective() - .iter() - .map(|&(variable, coefficient)| Ok((variable, convert(coefficient)?))) - .collect::, ReductionError>>()?; - let target = ILP::with_variables( - source.variables().to_vec(), - constraints, - objective, - source.sense(), - ) - .map_err(ReductionError::construction::, ILP>)?; - Ok(Self { - source: source.clone(), - target, - }) - } -} - -impl ReductionResult for ReductionILPToFloat { - type Source = ILP; - type Target = ILP; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_solution( - &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if !self.source.is_feasible(target_solution)? { - return Err(crate::rules::ExtractionError::invalid( - "the floating-point assignment violates the source integer ILP", - )); - } - Ok(target_solution.clone()) - } -} - -#[reduction( - transform = exact { - num_vars = "num_vars", - num_constraints = "num_constraints", - num_nonzeros = "num_nonzeros", - }, -)] -impl ReduceTo> for ILP { - type Result = ReductionILPToFloat; - - fn reduce_to(&self) -> Result { - ReductionILPToFloat::new(self) - } -} - -#[reduction( - transform = exact { - num_vars = "num_vars", - num_constraints = "num_constraints", - num_nonzeros = "num_nonzeros", - }, -)] -impl ReduceTo> for ILP { - type Result = ReductionILPToFloat; - - fn reduce_to(&self) -> Result { - ReductionILPToFloat::new(self) - } -} - -#[cfg(test)] -#[path = "../unit_tests/rules/ilp_casts.rs"] -mod tests; diff --git a/src/rules/ilp_helpers.rs b/src/rules/ilp_helpers.rs index 4f982bdc5..ac80fd75a 100644 --- a/src/rules/ilp_helpers.rs +++ b/src/rules/ilp_helpers.rs @@ -3,7 +3,7 @@ use crate::models::algebraic::LinearConstraint; /// Convert exact ILP integer values into a source model's `usize` representation. -pub fn decode_usize_values(values: &[i64]) -> crate::rules::ExtractionResult> { +pub(crate) fn decode_usize_values(values: &[i64]) -> crate::rules::ExtractionResult> { values .iter() .enumerate() @@ -18,7 +18,7 @@ pub fn decode_usize_values(values: &[i64]) -> crate::rules::ExtractionResult>( +pub(crate) fn mccormick_product>( y_idx: usize, x_a: usize, x_b: usize, @@ -43,69 +43,41 @@ pub fn mccormick_product>( ] } -/// Decode one selected item from each slot of a column-major one-hot matrix. -pub fn one_hot_decode( +/// Decode a column-major assignment whose constraints select one item per slot. +pub(crate) fn one_hot_decode( solution: &[i64], num_items: usize, num_slots: usize, var_offset: usize, -) -> crate::rules::ExtractionResult> { - let assignment: Vec = (0..num_slots) +) -> Vec { + (0..num_slots) .map(|slot| { - let mut selected = - (0..num_items).filter(|&item| solution[var_offset + item * num_slots + slot] == 1); - let item = selected.next().ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "assignment slot {slot} has no selected item" - )) - })?; - if selected.next().is_some() { - return Err(crate::rules::ExtractionError::invalid(format!( - "assignment slot {slot} has multiple selected items" - ))); - } - Ok(item) + (0..num_items) + .filter(|&item| solution[var_offset + item * num_slots + slot] == 1) + .sum() }) - .collect::>()?; - - let mut assigned = vec![false; num_items]; - for &item in &assignment { - if std::mem::replace(&mut assigned[item], true) { - return Err(crate::rules::ExtractionError::invalid(format!( - "item {item} is selected for multiple assignment slots" - ))); - } - } - Ok(assignment) + .collect() } -/// Decode one selected column from each row of a row-major one-hot matrix. -pub fn one_hot_decode_rows( +/// Decode a row-major assignment whose constraints select one column per row. +pub(crate) fn one_hot_decode_rows( solution: &[i64], num_rows: usize, num_columns: usize, var_offset: usize, -) -> crate::rules::ExtractionResult> { +) -> Vec { (0..num_rows) .map(|row| { - let mut selected = (0..num_columns) - .filter(|&column| solution[var_offset + row * num_columns + column] == 1); - match (selected.next(), selected.next()) { - (Some(column), None) => Ok(column), - (None, _) => Err(crate::rules::ExtractionError::invalid(format!( - "assignment row {row} has no selected column" - ))), - (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( - "assignment row {row} has multiple selected columns" - ))), - } + (0..num_columns) + .filter(|&column| solution[var_offset + row * num_columns + column] == 1) + .sum() }) .collect() } /// Convert a permutation to Lehmer code. #[cfg(test)] -pub fn permutation_to_lehmer(permutation: &[usize]) -> Vec { +pub(crate) fn permutation_to_lehmer(permutation: &[usize]) -> Vec { (0..permutation.len()) .map(|index| { (index + 1..permutation.len()) @@ -116,7 +88,7 @@ pub fn permutation_to_lehmer(permutation: &[usize]) -> Vec { } /// Constrain each item to exactly one slot and each slot to at most one item. -pub fn one_hot_assignment_constraints( +pub(crate) fn one_hot_assignment_constraints( num_items: usize, num_slots: usize, var_offset: usize, diff --git a/src/rules/ilp_i64_ilp_bool.rs b/src/rules/ilp_i64_ilp_bool.rs index ea783e12f..88b1a8e8a 100644 --- a/src/rules/ilp_i64_ilp_bool.rs +++ b/src/rules/ilp_i64_ilp_bool.rs @@ -2,8 +2,9 @@ use crate::models::algebraic::{Comparison, LinearConstraint, ILP}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; use crate::rules::ReductionError; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] struct VarEncoding { @@ -80,33 +81,36 @@ impl ReductionResult for ReductionIntILPToBinaryILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - self.encodings + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionIntILPToBinaryILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(self + .encodings .iter() .map(|encoding| { - encoding.weights.iter().enumerate().try_fold( - encoding.lower_bound, - |value, (offset, &weight)| { - let term = weight - .checked_mul(target_solution[encoding.start + offset]) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "binary ILP decoding multiplication overflowed i64", - ) - })?; - value.checked_add(term).ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "binary ILP decoding sum overflowed i64", - ) - }) - }, - ) + let offset: i64 = encoding + .weights + .iter() + .enumerate() + .filter(|(offset, _)| target_solution[encoding.start + offset] == 1) + .map(|(_, &weight)| weight) + .sum(); + encoding.lower_bound + offset }) - .collect() + .collect()) } } diff --git a/src/rules/ilp_i64_ilp_f64.rs b/src/rules/ilp_i64_ilp_f64.rs new file mode 100644 index 000000000..8098a115d --- /dev/null +++ b/src/rules/ilp_i64_ilp_f64.rs @@ -0,0 +1,86 @@ +//! Exact integer-to-floating coefficient reductions for ILP. +//! +//! Preserve variable domains and every formal linear expression by converting +//! coefficients and right-hand sides exactly within the supported numeric range. +//! Solution extraction is the identity map. Numerical backend capabilities are +//! independent of this reduction. + +use crate::models::algebraic::{Comparison, LinearConstraint, VariableDomain, ILP}; +use crate::reduction; +use crate::rules::{ReduceTo, ReductionError, VariantReductionResult}; +use crate::types::i64_to_exact_f64; + +pub type ReductionILPToFloat = VariantReductionResult, ILP>; + +fn reduce_coefficients( + source: &ILP, +) -> Result, ReductionError> { + let convert = |coefficient: i64| { + i64_to_exact_f64(coefficient) + .map_err(ReductionError::inexact_float_conversion::, ILP>) + }; + let constraints = source + .constraints() + .iter() + .map(|constraint| { + let terms = constraint + .terms() + .iter() + .map(|&(variable, coefficient)| Ok((variable, convert(coefficient)?))) + .collect::, ReductionError>>()?; + let rhs = convert(constraint.rhs())?; + Ok(match constraint.comparison() { + Comparison::Le => LinearConstraint::le(terms, rhs), + Comparison::Ge => LinearConstraint::ge(terms, rhs), + Comparison::Eq => LinearConstraint::eq(terms, rhs), + }) + }) + .collect::, ReductionError>>()?; + let objective = source + .objective() + .iter() + .map(|&(variable, coefficient)| Ok((variable, convert(coefficient)?))) + .collect::, ReductionError>>()?; + let target = ILP::with_variables( + source.variables().to_vec(), + constraints, + objective, + source.sense(), + ) + .map_err(ReductionError::construction::, ILP>)?; + Ok(VariantReductionResult::new(target)) +} + +#[reduction( + transform = exact { + num_vars = "num_vars", + num_constraints = "num_constraints", + num_nonzeros = "num_nonzeros", + }, +)] +impl ReduceTo> for ILP { + type Result = ReductionILPToFloat; + + fn reduce_to(&self) -> Result { + reduce_coefficients(self) + } +} + +#[reduction( + transform = exact { + num_vars = "num_vars", + num_constraints = "num_constraints", + num_nonzeros = "num_nonzeros", + }, +)] +impl ReduceTo> for ILP { + type Result = ReductionILPToFloat; + + fn reduce_to(&self) -> Result { + reduce_coefficients(self) + } +} + +#[cfg(test)] +#[path = "../unit_tests/rules/ilp_i64_ilp_f64.rs"] +mod tests; diff --git a/src/rules/ilp_qubo.rs b/src/rules/ilp_qubo.rs index fbe885057..1117b913c 100644 --- a/src/rules/ilp_qubo.rs +++ b/src/rules/ilp_qubo.rs @@ -14,6 +14,8 @@ use crate::models::algebraic::{Comparison, ObjectiveSense, ILP, QUBO}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; /// Result of reducing binary ILP to QUBO. #[derive(Debug, Clone)] @@ -35,18 +37,44 @@ impl ReductionResult for ReductionILPToQUBO { } /// Extract only the original variables (discard slack). - fn extract_solution( + fn recover_result( &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", - )); + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Ok(SolveOutcome::Infeasible); + } + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::optimal(source, solution)?) + } + SolveOutcome::Feasible { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Err(crate::rules::ExtractionError::InsufficientSolutionQuality); + } + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::feasible(source, solution)?) + } } + } +} +impl ReductionILPToQUBO { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_original_vars] .iter() .map(|&value| i64::from(value)) @@ -54,15 +82,8 @@ impl ReductionResult for ReductionILPToQUBO { } } -impl crate::rules::AggregateReductionResult for ReductionILPToQUBO { - type Source = ILP; - type Target = QUBO; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_value(&self, value: crate::types::Min) -> crate::types::Extremum { +impl ReductionILPToQUBO { + fn map_value(&self, value: crate::types::Min) -> crate::types::Extremum { let objective = value .0 .filter(|&energy| { @@ -79,7 +100,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", } @@ -252,7 +272,7 @@ impl ReduceTo> for ILP { feasible_energy_range(&c_vec, &b_vec, penalty)?; // QUBO = -diag(c + 2·P·b·A) + P·A^T·A - let mut matrix = vec![vec![0_i64; nq]; nq]; + let mut matrix = vec![std::collections::BTreeMap::new(); nq]; // Compute b·A (b_vec dot each column of a_ext) let mut ba = vec![0_i64; nq]; @@ -281,14 +301,17 @@ impl ReduceTo> for ILP { "computing a QUBO diagonal penalty", ) })?; - matrix[j][j] = c_vec[j] - .checked_add(penalty_term) - .and_then(i64::checked_neg) - .ok_or_else(|| { - crate::rules::ReductionError::integer_overflow::, QUBO>( - "computing a QUBO diagonal coefficient", - ) - })?; + matrix[j].insert( + j, + c_vec[j] + .checked_add(penalty_term) + .and_then(i64::checked_neg) + .ok_or_else(|| { + crate::rules::ReductionError::integer_overflow::, QUBO>( + "computing a QUBO diagonal coefficient", + ) + })?, + ); } // A^T·A contribution (upper-triangular convention) @@ -308,13 +331,17 @@ impl ReduceTo> for ILP { "computing a quadratic QUBO diagonal penalty", ) })?; - row_i[i] = row_i[i].checked_add(diagonal).ok_or_else(|| { + let coefficient = row_i.entry(i).or_insert(0i64); + *coefficient = coefficient.checked_add(diagonal).ok_or_else(|| { crate::rules::ReductionError::integer_overflow::, QUBO>( "adding a quadratic QUBO diagonal penalty", ) })?; // Off-diagonal for j in (i + 1)..nq { + if row[j] == 0 { + continue; + } let interaction = penalty .checked_mul(row[i]) .and_then(|value| value.checked_mul(row[j])) @@ -324,7 +351,8 @@ impl ReduceTo> for ILP { "computing a quadratic QUBO interaction penalty", ) })?; - row_i[j] = row_i[j].checked_add(interaction).ok_or_else(|| { + let coefficient = row_i.entry(j).or_insert(0i64); + *coefficient = coefficient.checked_add(interaction).ok_or_else(|| { crate::rules::ReductionError::integer_overflow::, QUBO>( "adding a quadratic QUBO interaction penalty", ) @@ -334,7 +362,7 @@ impl ReduceTo> for ILP { } Ok(ReductionILPToQUBO { - target: QUBO::from_matrix(matrix) + target: QUBO::from_rows(matrix) .map_err(crate::rules::ReductionError::construction::, QUBO>)?, num_original_vars: n, sense: self.sense(), diff --git a/src/rules/integerknapsack_ilp.rs b/src/rules/integerknapsack_ilp.rs index 8eb5b2aca..c2c25b876 100644 --- a/src/rules/integerknapsack_ilp.rs +++ b/src/rules/integerknapsack_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::IntegerKnapsack; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionIntegerKnapsackToILP { @@ -22,12 +23,22 @@ impl ReductionResult for ReductionIntegerKnapsackToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionIntegerKnapsackToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(target_solution) } } diff --git a/src/rules/integralflowbundles_ilp.rs b/src/rules/integralflowbundles_ilp.rs index d4220c41e..c5644f7e6 100644 --- a/src/rules/integralflowbundles_ilp.rs +++ b/src/rules/integralflowbundles_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::IntegralFlowBundles; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing IntegralFlowBundles to ILP. #[derive(Debug, Clone)] @@ -23,12 +24,22 @@ impl ReductionResult for ReductionIFBToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionIFBToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(target_solution) } } diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 006ecdb56..c4e98cae2 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::IntegralFlowHomologousArcs; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing IntegralFlowHomologousArcs to ILP. #[derive(Debug, Clone)] @@ -22,12 +23,22 @@ impl ReductionResult for ReductionIFHAToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionIFHAToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(target_solution) } } diff --git a/src/rules/integralflowwithmultipliers_ilp.rs b/src/rules/integralflowwithmultipliers_ilp.rs index 6c700a3ba..f8f73f6cd 100644 --- a/src/rules/integralflowwithmultipliers_ilp.rs +++ b/src/rules/integralflowwithmultipliers_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::IntegralFlowWithMultipliers; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing IntegralFlowWithMultipliers to ILP. #[derive(Debug, Clone)] @@ -22,12 +23,22 @@ impl ReductionResult for ReductionIFWMToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionIFWMToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(target_solution) } } diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index e2d9e2ec4..34de4e431 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::IsomorphicSpanningTree; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -24,13 +25,28 @@ impl ReductionResult for ReductionISTToILP { } /// For each tree vertex u, output the unique graph vertex v with x_{u,v} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) +impl ReductionISTToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.n, + self.n, + 0, + )) } } diff --git a/src/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/rules/kclique_balancedcompletebipartitesubgraph.rs index b8285dbf6..cc076a650 100644 --- a/src/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -7,7 +7,8 @@ use crate::models::graph::{BalancedCompleteBipartiteSubgraph, KClique}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{BipartiteGraph, Graph, SimpleGraph}; /// Result of reducing KClique to BalancedCompleteBipartiteSubgraph. @@ -34,12 +35,22 @@ impl ReductionResult for ReductionKCliqueToBCBS { /// The k-clique is S = {v in V : v not in A'}, i.e., the original vertices /// NOT selected on the left side. For each original vertex v (0..n-1), /// the source selection is the negation of the target's left-side selection. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionKCliqueToBCBS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ (0..self.num_original_vertices) .map(|v| !target_solution[v]) diff --git a/src/rules/kclique_conjunctivebooleanquery.rs b/src/rules/kclique_conjunctivebooleanquery.rs index e0c66f76b..7882dc6d3 100644 --- a/src/rules/kclique_conjunctivebooleanquery.rs +++ b/src/rules/kclique_conjunctivebooleanquery.rs @@ -11,7 +11,8 @@ use crate::models::graph::KClique; use crate::models::misc::{CbqRelation, ConjunctiveBooleanQuery, QueryArg}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing KClique to ConjunctiveBooleanQuery. @@ -34,12 +35,22 @@ impl ReductionResult for ReductionKCliqueToCBQ { /// CBQ config: vec of length k, each value is a domain element (vertex index). /// KClique config: binary vec of length n; set config[v]=1 for each v in /// the CBQ assignment. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionKCliqueToCBQ { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(KClique::::config_from_vertices( self.num_vertices, target_solution, diff --git a/src/rules/kclique_ilp.rs b/src/rules/kclique_ilp.rs index 1c3e0f962..dbcf83d6d 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -12,7 +12,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::KClique; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing KClique to ILP. @@ -39,13 +40,14 @@ impl ReductionResult for ReductionKCliqueToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/kclique_subgraphisomorphism.rs b/src/rules/kclique_subgraphisomorphism.rs index f39f27561..2d97aebd8 100644 --- a/src/rules/kclique_subgraphisomorphism.rs +++ b/src/rules/kclique_subgraphisomorphism.rs @@ -7,7 +7,8 @@ use crate::models::graph::{KClique, SubgraphIsomorphism}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; /// Result of reducing KClique to SubgraphIsomorphism. @@ -34,12 +35,22 @@ impl ReductionResult for ReductionKCliqueToSubIso { /// The SubgraphIsomorphism config maps each pattern vertex (0..k-1) to a /// host vertex. We create a binary vector of length n and set positions /// f(0), f(1), ..., f(k-1) to 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionKCliqueToSubIso { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(KClique::::config_from_vertices( self.num_source_vertices, target_solution, diff --git a/src/rules/kcoloring_bicliquecover.rs b/src/rules/kcoloring_bicliquecover.rs index 46be9a726..238e78519 100644 --- a/src/rules/kcoloring_bicliquecover.rs +++ b/src/rules/kcoloring_bicliquecover.rs @@ -31,7 +31,8 @@ use crate::models::graph::{BicliqueCover, KColoring}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{BipartiteGraph, Graph, SimpleGraph}; use crate::variant::KN; use std::collections::BTreeSet; @@ -40,13 +41,10 @@ use std::collections::BTreeSet; #[derive(Debug, Clone)] pub struct ReductionKColoringToBicliqueCover { target: BicliqueCover, - /// Number of source vertices `n`. Stored so `extract_solution` can locate + /// Number of source vertices `n`. Stored so `recover_result` can locate /// the diagonal indices of each source vertex without re-reading the /// reduction parameters. num_vertices: usize, - /// Number of source colors `q`. Used as the upper bound on the number of - /// color bicliques recovered during extraction. - num_colors: usize, } impl ReductionResult for ReductionKColoringToBicliqueCover { @@ -68,18 +66,22 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { /// cover yields at most `q` such distinct bicliques, so the result is a /// proper `q`-coloring of the source. /// - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionKColoringToBicliqueCover { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_vertices; let k = self.target.k(); @@ -91,14 +93,11 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { for v in 0..n { let a_v = v; let b_v = left_size + v; - let biclique = (0..k) - .find(|&r| target_solution[r][a_v] && target_solution[r][b_v]) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "target cover leaves diagonal gadget edge {v} uncovered" - )) - })?; - diagonal_biclique.push(biclique); + diagonal_biclique.extend( + (0..k) + .filter(|&r| target_solution[r][a_v] && target_solution[r][b_v]) + .take(1), + ); } // Compact distinct biclique indices into colors 0..q-1 in first-seen order. @@ -108,12 +107,6 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { for biclique in diagonal_biclique { let next_color = color_of_biclique.len(); let color = *color_of_biclique.entry(biclique).or_insert(next_color); - if color >= self.num_colors { - return Err(crate::rules::ExtractionError::invalid(format!( - "target cover uses more than {} diagonal bicliques", - self.num_colors - ))); - } coloring.push(color); } coloring @@ -145,7 +138,6 @@ impl ReduceTo for KColoring { return Ok(ReductionKColoringToBicliqueCover { target: BicliqueCover::new(BipartiteGraph::new(1, 1, vec![(0, 0)]), 0), num_vertices: n, - num_colors: q, }); } @@ -216,7 +208,6 @@ impl ReduceTo for KColoring { Ok(ReductionKColoringToBicliqueCover { target, num_vertices: n, - num_colors: q, }) } } diff --git a/src/rules/kcoloring_casts.rs b/src/rules/kcoloring_casts.rs index 15b848cee..8af0d2143 100644 --- a/src/rules/kcoloring_casts.rs +++ b/src/rules/kcoloring_casts.rs @@ -9,6 +9,6 @@ impl_variant_reduction!( KColoring, => , fields: [num_vertices, num_edges, num_colors], - aggregate: identity, + |src| KColoring::with_k(src.graph().clone(), src.num_colors()) ); diff --git a/src/rules/kcoloring_clustering.rs b/src/rules/kcoloring_clustering.rs index 6311eb3af..25bf3ade2 100644 --- a/src/rules/kcoloring_clustering.rs +++ b/src/rules/kcoloring_clustering.rs @@ -7,7 +7,8 @@ use crate::models::graph::KColoring; use crate::models::misc::Clustering; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::variant::K3; @@ -28,13 +29,14 @@ impl ReductionResult for ReductionKColoringToClustering { /// Cluster labels are color labels. The empty-graph corner case uses one /// dummy target element because Clustering forbids empty instances. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution[..self.source_num_vertices].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.source_num_vertices].to_vec()) + }) } } diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs index bb3dd69bd..9c553e9de 100644 --- a/src/rules/kcoloring_partitionintocliques.rs +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -6,7 +6,8 @@ use crate::models::graph::{KColoring, PartitionIntoCliques}; use crate::reduction; use crate::rules::graph_helpers::complement_edges; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::variant::KN; @@ -25,13 +26,12 @@ impl ReductionResult for ReductionKColoringToPartitionIntoCliques { } /// Solution extraction is the identity: color classes become clique classes. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs index da2a2318e..2c3554180 100644 --- a/src/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -15,7 +15,8 @@ use crate::models::graph::KColoring; use crate::models::set::TwoDimensionalConsecutiveSets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::variant::K3; @@ -41,18 +42,22 @@ impl ReductionResult for ReductionKColoringToTDCS { /// The first `num_vertices` symbols correspond to graph vertices, /// so their group assignments directly give a valid 3-coloring /// (after remapping to colors 0, 1, 2). - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionKColoringToTDCS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // The target solution is config[symbol] = group_index. // Vertex symbols are indices 0..num_vertices. diff --git a/src/rules/knapsack_ilp.rs b/src/rules/knapsack_ilp.rs index 2601ce5f9..31fd9be36 100644 --- a/src/rules/knapsack_ilp.rs +++ b/src/rules/knapsack_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::Knapsack; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Knapsack to ILP. #[derive(Debug, Clone)] @@ -24,13 +25,14 @@ impl ReductionResult for ReductionKnapsackToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/knapsack_qubo.rs b/src/rules/knapsack_qubo.rs index 63a0e208d..0a8926710 100644 --- a/src/rules/knapsack_qubo.rs +++ b/src/rules/knapsack_qubo.rs @@ -14,6 +14,8 @@ use crate::models::algebraic::QUBO; use crate::models::misc::Knapsack; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; fn overflow(operation: &'static str) -> crate::rules::ReductionError { crate::rules::ReductionError::integer_overflow::>(operation) @@ -34,12 +36,31 @@ impl ReductionResult for ReductionKnapsackToQUBO { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::optimal(source, solution)?) + } + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } + } +} +impl ReductionKnapsackToQUBO { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_items].to_vec()) } } @@ -108,7 +129,7 @@ impl ReduceTo> for Knapsack { coeffs[n + j] = weight; } - let mut matrix = vec![vec![0_i64; total]; total]; + let mut matrix = vec![std::collections::BTreeMap::new(); total]; // Diagonal: P * a_k^2 - 2P * C * a_k - v_k (for items) for k in 0..total { @@ -121,29 +142,33 @@ impl ReduceTo> for Knapsack { .and_then(|value| value.checked_mul(coeffs[k])) .and_then(|value| value.checked_mul(2)) .ok_or_else(|| overflow("computing a knapsack QUBO linear penalty"))?; - matrix[k][k] = square + let mut diagonal = square .checked_sub(linear) .ok_or_else(|| overflow("combining knapsack QUBO diagonal penalties"))?; if k < n { - matrix[k][k] = matrix[k][k] + diagonal = diagonal .checked_sub(values[k]) .ok_or_else(|| overflow("adding a knapsack value to the QUBO objective"))?; } + matrix[k].insert(k, diagonal); } // Off-diagonal (upper triangular): 2P * a_i * a_j for i in 0..total { for j in (i + 1)..total { - matrix[i][j] = penalty - .checked_mul(coeffs[i]) - .and_then(|value| value.checked_mul(coeffs[j])) - .and_then(|value| value.checked_mul(2)) - .ok_or_else(|| overflow("computing a knapsack QUBO interaction"))?; + matrix[i].insert( + j, + penalty + .checked_mul(coeffs[i]) + .and_then(|value| value.checked_mul(coeffs[j])) + .and_then(|value| value.checked_mul(2)) + .ok_or_else(|| overflow("computing a knapsack QUBO interaction"))?, + ); } } Ok(ReductionKnapsackToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { + target: QUBO::from_rows(matrix).map_err(|message| { crate::rules::ReductionError::construction::>(message) })?, num_items: n, diff --git a/src/rules/ksatisfiability_acyclicpartition.rs b/src/rules/ksatisfiability_acyclicpartition.rs index 257516807..554843ceb 100644 --- a/src/rules/ksatisfiability_acyclicpartition.rs +++ b/src/rules/ksatisfiability_acyclicpartition.rs @@ -9,7 +9,8 @@ use crate::models::formula::KSatisfiability; use crate::models::graph::{AcyclicPartition, KClique}; use crate::reduction; use crate::rules::ksatisfiability_kclique::Reduction3SATToKClique; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{DirectedGraph, Graph, SimpleGraph}; use crate::variant::K3; @@ -29,23 +30,28 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToAcyclicPartition { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let source_label = target_solution[self.source_vertex]; let selected = target_solution[..self.sat_to_clique.target_problem().num_vertices()] .iter() .map(|&label| label == source_label) .collect(); - self.sat_to_clique.extract_solution(&selected) + self.sat_to_clique.map_solution(&selected) } } diff --git a/src/rules/ksatisfiability_bicliquecover.rs b/src/rules/ksatisfiability_bicliquecover.rs index 5caf355a0..514a8ebf7 100644 --- a/src/rules/ksatisfiability_bicliquecover.rs +++ b/src/rules/ksatisfiability_bicliquecover.rs @@ -48,7 +48,8 @@ use crate::models::formula::CNFClause; use crate::models::formula::KSatisfiability; use crate::models::graph::BicliqueCover; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::BipartiteGraph; use crate::variant::K3; use std::collections::BTreeSet; @@ -63,9 +64,6 @@ pub struct ReductionKSatisfiabilityToBicliqueCover { target: BicliqueCover, /// Number of variables in the source 3-CNF formula. source_num_vars: usize, - /// Number of normalized variables `n = 2^ell` (a power of two and - /// at least twice the number of appearing variables). Zero for sentinels. - normalized_n: usize, /// Bipartite-local offset of the `S_1` block on the left side. /// Used to locate vertex `s_11^u` for B_1 identification. s1_left_offset: usize, @@ -89,18 +87,23 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { /// The rank budget forces a unique row covering the first domino anchor. /// Its left crown memberships give the normalized truth assignment. /// Map appearing variables back to their original indices and assign false - /// to variables absent from the formula. Infeasible covers are rejected. - fn extract_solution( + /// to variables absent from the formula. + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionKSatisfiabilityToBicliqueCover { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { // 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]; @@ -111,18 +114,14 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { let s11_v = self.target.left_size() + self.s1_right_offset; // The Y matching and the important induced matching use the entire // rank budget. Exactly one row covers this important anchor edge. - let b1_index = target_solution + for row in target_solution .iter() - .position(|row| row[s11_u] && row[s11_v]); - - let b1_index = b1_index.ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "target configuration has no important-edge biclique B_1", - ) - })?; - // Pair i corresponds to source_variables[i]; its t variable is 2*i. - for (i, &source_index) in self.source_variables.iter().enumerate() { - source_assignment[source_index] = target_solution[b1_index][2 * i]; + .filter(|row| row[s11_u] && row[s11_v]) + { + // Pair i corresponds to source_variables[i]; its t variable is 2*i. + for (i, &source_index) in self.source_variables.iter().enumerate() { + source_assignment[source_index] = row[2 * i]; + } } Ok(source_assignment) } @@ -266,7 +265,6 @@ impl ReduceTo for KSatisfiability { return Ok(ReductionKSatisfiabilityToBicliqueCover { target: BicliqueCover::new(BipartiteGraph::new(size, size, edges), 0), source_num_vars, - normalized_n: 0, s1_left_offset: 0, s1_right_offset: 0, source_variables: vec![], @@ -503,7 +501,6 @@ impl ReduceTo for KSatisfiability { Ok(ReductionKSatisfiabilityToBicliqueCover { target, source_num_vars, - normalized_n: n, s1_left_offset: s_offset, s1_right_offset: s_offset, source_variables, @@ -763,7 +760,8 @@ fn forward_witness_single_variable_single_clause(source: &KSatisfiability) - let mut config = vec![vec![false; num_vertices]; k]; // Bipartite-local helpers, mirroring `reduce_to`. - let n = reduction.normalized_n; + let (n, _) = normalize(source, &reduction.source_variables) + .expect("canonical normalization must succeed"); let ell = ceil_log2(n).max(1); let m = 3usize; // hard-coded for the canonical case let k_f = free_edge_budget(ell, m).expect("canonical free-edge budget must fit usize"); diff --git a/src/rules/ksatisfiability_casts.rs b/src/rules/ksatisfiability_casts.rs index 659c4d5b9..d965c2619 100644 --- a/src/rules/ksatisfiability_casts.rs +++ b/src/rules/ksatisfiability_casts.rs @@ -8,7 +8,7 @@ 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 +16,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()) ); diff --git a/src/rules/ksatisfiability_cyclicordering.rs b/src/rules/ksatisfiability_cyclicordering.rs index 704a26f66..eae615b82 100644 --- a/src/rules/ksatisfiability_cyclicordering.rs +++ b/src/rules/ksatisfiability_cyclicordering.rs @@ -20,7 +20,8 @@ use crate::models::formula::KSatisfiability; use crate::models::misc::CyclicOrdering; use crate::reduction; use crate::rules::sat_helpers::SatVariableAllocator; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::variant::K3; use std::collections::BTreeSet; @@ -39,17 +40,22 @@ impl ReductionResult for Reduction3SATToCyclicOrdering { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToCyclicOrdering { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { 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); diff --git a/src/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/rules/ksatisfiability_decisionminimumvertexcover.rs index 3bb329f47..2943ce778 100644 --- a/src/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -1,38 +1,56 @@ -//! 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::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 } - fn extract_solution( + /// 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. A cover meeting the n + 2m bound contains exactly one of these two + /// for each variable. If u_i is in the cover, set x_i = 1; + /// if not-u_i is in the cover, set x_i = 0. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - self.base_reduction.extract_solution(target_solution) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok((0..self.source_num_vars).map(|i| solution[2 * i]).collect()) + }) } } @@ -42,29 +60,60 @@ impl ReductionResult for Reduction3SATToDecisionMVC { num_edges = "num_vars + 6 * num_clauses", } )] -impl ReduceTo>> for KSatisfiability { +impl ReduceTo>> for KSatisfiability { type Result = Reduction3SATToDecisionMVC; fn reduce_to(&self) -> Result { - let base_reduction = as ReduceTo< - MinimumVertexCover, - >>::reduce_to(self)?; - let bound = self - .num_clauses() + let n = self.num_vars(); + let m = self.num_clauses(); + let target_bound = m .checked_mul(2) - .and_then(|value| value.checked_add(self.num_vars())) + .and_then(|value| value.checked_add(n)) .and_then(|value| i64::try_from(value).ok()) .ok_or_else(|| { crate::rules::ReductionError::integer_overflow::< KSatisfiability, - Decision>, + Decision>, >("computing the target cover bound") })?; - let target = Decision::new(base_reduction.target_problem().clone(), bound); + 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![One; total_vertices]; + let target = MinimumVertexCover::new(graph, weights); Ok(Reduction3SATToDecisionMVC { - target, - base_reduction, + target: Decision::new(target, target_bound), + source_num_vars: n, }) } } @@ -86,11 +135,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..accd44459 100644 --- a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -10,7 +10,8 @@ use crate::models::formula::KSatisfiability; use crate::models::graph::DirectedTwoCommodityIntegralFlow; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::DirectedGraph; use crate::variant::K3; @@ -171,12 +172,22 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl Reduction3SATToDirectedTwoCommodityIntegralFlow { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ self.variable_paths .iter() diff --git a/src/rules/ksatisfiability_feasibleregisterassignment.rs b/src/rules/ksatisfiability_feasibleregisterassignment.rs index 23176c5ed..cb5ea2228 100644 --- a/src/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/rules/ksatisfiability_feasibleregisterassignment.rs @@ -15,7 +15,8 @@ use crate::models::formula::KSatisfiability; use crate::models::misc::FeasibleRegisterAssignment; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::variant::K3; use std::collections::BTreeSet; @@ -74,17 +75,22 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToFeasibleRegisterAssignment { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut assignment = vec![false; self.num_vars]; let compact_vars = self.source_variables.len(); for (compact, &original) in self.source_variables.iter().enumerate() { @@ -232,6 +238,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec::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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToKClique { + pub(crate) fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { // 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()] diff --git a/src/rules/ksatisfiability_kernel.rs b/src/rules/ksatisfiability_kernel.rs index d426e0025..83281275b 100644 --- a/src/rules/ksatisfiability_kernel.rs +++ b/src/rules/ksatisfiability_kernel.rs @@ -8,7 +8,8 @@ use crate::models::formula::KSatisfiability; use crate::models::graph::Kernel; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::DirectedGraph; use crate::variant::K3; use std::collections::BTreeSet; @@ -29,17 +30,22 @@ impl ReductionResult for Reduction3SatToKernel { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SatToKernel { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut assignment = vec![false; self.source_num_vars]; for (compact, &original) in self.source_variables.iter().enumerate() { assignment[original] = target_solution[2 * compact]; 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..e1a2ec547 100644 --- a/src/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/rules/ksatisfiability_monochromatictriangle.rs @@ -13,7 +13,8 @@ use crate::models::graph::MonochromaticTriangle; use crate::reduction; use crate::rules::sat_helpers::SatVariableAllocator; use crate::rules::satisfiability_naesatisfiability::ReductionSATToNAESAT; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; use crate::variant::K3; @@ -51,17 +52,28 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToMonochromaticTriangle { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let nae_solution = (0..self.nae_reduction.target_problem().num_vars()) .map(|index| target_solution[2 * index]) .collect(); // Reuse the formal SAT -> NAE extraction (including sentinel // normalization); no assignment search or speculative complement. - self.nae_reduction.extract_solution(&nae_solution) + self.nae_reduction.map_solution(&nae_solution) } } @@ -167,6 +179,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec::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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToOneInThreeSAT { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut assignment = vec![false; self.source_num_vars]; for (compact, &original) in self.source_variables.iter().enumerate() { assignment[original] = target_solution[compact]; diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index e994b9cda..a03deda55 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 conjunctions and empty clauses use +//! constant instances; shorter nonempty clauses repeat literals. //! //! Reference: Jeffrey D. Ullman, "NP-complete scheduling problems", JCSS 10, //! 1975; Garey & Johnson, Appendix A5.2. @@ -13,6 +14,10 @@ use crate::models::formula::KSatisfiability; use crate::models::misc::PreemptiveScheduling; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; +#[cfg(any(test, feature = "example-db"))] +use crate::traits::Problem; use crate::variant::K3; #[derive(Debug, Clone)] @@ -38,10 +43,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 +73,11 @@ 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); + // Ullman's clock requires a nonempty filler layer in EVERY slot. + // A chain through all T layers forces layer i into slot i in any + // T-slot schedule, leaving exactly capacities[i] slots for original jobs. + let num_processors = capacities.iter().max().unwrap() + 1; let mut next_job = 0usize; @@ -149,8 +153,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 disjunction for short clauses. + 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 +197,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 +208,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 +225,13 @@ 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 source + .evaluate(&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; @@ -335,27 +339,54 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { + solution, + evaluation, + } => { + if evaluation.0.expect("valid schedule has a makespan") > self.threshold as i64 { + return Ok(SolveOutcome::Infeasible); + } + // A threshold schedule must decode to a satisfying assignment. + // A mapping failure is an error, never evidence of infeasibility. + Ok(SolveOutcome::optimal(source, self.map_solution(&solution))?) + } + SolveOutcome::Feasible { + solution, + evaluation, + } => { + if evaluation.0.expect("valid schedule has a makespan") > self.threshold as i64 { + return Err(crate::rules::ExtractionError::InsufficientSolutionQuality); + } + Ok(SolveOutcome::feasible( + source, + self.map_solution(&solution), + )?) + } + } + } +} - Ok({ - let d_max = self.target.d_max(); - self.positive_start_jobs - .iter() - .map(|&job| task_slot(target_solution, job, d_max) == Some(0)) - .collect() - }) +impl Reduction3SATToPreemptiveScheduling { + fn map_solution(&self, target_solution: &[Vec]) -> Vec { + self.positive_start_jobs + .iter() + .map(|&job| target_solution[job][0]) + .collect() } } #[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 +396,19 @@ impl ReduceTo for KSatisfiability { type Result = Reduction3SATToPreemptiveScheduling; fn reduce_to(&self) -> Result { + let has_empty_clause = self + .clauses() + .iter() + .any(|clause| clause.literals.is_empty()); + if self.num_clauses() == 0 || has_empty_clause { + // The empty conjunction is true; any empty disjunction is false. + 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..8f4b83ce7 100644 --- a/src/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/rules/ksatisfiability_quadraticcongruences.rs @@ -6,12 +6,13 @@ //! slack equation. Squaring uses twice the linear modulus, and extraction //! orients every sign by the distinguished odd coordinate. +use crate::solvers::ProblemOutcome; use std::collections::{BTreeMap, BTreeSet}; use crate::models::algebraic::QuadraticCongruences; use crate::models::formula::KSatisfiability; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; use crate::variant::K3; use num_bigint::{BigInt, BigUint}; #[cfg(any(test, feature = "example-db"))] @@ -36,17 +37,22 @@ impl ReductionResult for Reduction3SATToQuadraticCongruences { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToQuadraticCongruences { + pub(crate) fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { // 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. diff --git a/src/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/rules/ksatisfiability_quadraticdiophantineequations.rs index e8e4053c8..d4973c318 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{QuadraticCongruences, QuadraticDiophantineEquatio use crate::models::formula::KSatisfiability; use crate::reduction; use crate::rules::ksatisfiability_quadraticcongruences::Reduction3SATToQuadraticCongruences; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::variant::K3; use num_bigint::BigUint; use num_traits::One; @@ -28,16 +29,23 @@ impl ReductionResult for Reduction3SATToQuadraticDiophantineEquations { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - Ok({ - self.congruence_reduction - .extract_solution(target_solution)? - }) +impl Reduction3SATToQuadraticDiophantineEquations { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + self.congruence_reduction.map_solution(target_solution) } } diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index 4a7b8b21c..eb472d622 100644 --- a/src/rules/ksatisfiability_qubo.rs +++ b/src/rules/ksatisfiability_qubo.rs @@ -13,69 +13,61 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 } - fn extract_solution( + fn recover_result( &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", - )); - } - Ok(target_solution[..self.source_num_vars].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.source_num_vars].to_vec()) + }) } } /// 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 } - fn extract_solution( + fn recover_result( &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", - )); - } - Ok(target_solution[..self.source_num_vars].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.source_num_vars].to_vec()) + }) } } @@ -84,19 +76,20 @@ impl ReductionResult for Reduction3SATToQUBO { /// For clause (l_i ∨ l_j), the penalty for the clause being unsatisfied is /// the product of the complemented literals. fn add_coefficient( - matrix: &mut [Vec], + matrix: &mut [std::collections::BTreeMap], row: usize, column: usize, coefficient: i64, ) -> Result<(), &'static str> { - matrix[row][column] = matrix[row][column] + let entry = matrix[row].entry(column).or_insert(0i64); + *entry = entry .checked_add(coefficient) .ok_or("adding a SAT QUBO coefficient")?; Ok(()) } fn add_2sat_clause_penalty( - matrix: &mut [Vec], + matrix: &mut [std::collections::BTreeMap], lits: &[(usize, bool)], ) -> Result<(), &'static str> { assert_eq!(lits.len(), 2, "Expected 2-literal clause"); @@ -151,7 +144,7 @@ fn add_2sat_clause_penalty( /// /// `aux_var` is the 0-indexed auxiliary variable. fn add_3sat_clause_penalty( - matrix: &mut [Vec], + matrix: &mut [std::collections::BTreeMap], lits: &[(usize, bool)], aux_var: usize, ) -> Result<(), &'static str> { @@ -176,7 +169,7 @@ fn add_3sat_clause_penalty( // Helper: add coefficient * yi * yj to the matrix // where yi depends on variable vi and negation ni - let add_yy = |matrix: &mut [Vec], + let add_yy = |matrix: &mut [std::collections::BTreeMap], vi: usize, ni: bool, vj: usize, @@ -241,7 +234,7 @@ fn add_3sat_clause_penalty( // Helper: add coefficient * yi * a to the matrix // where yi depends on variable vi and negation ni, a is aux variable - let add_ya = |matrix: &mut [Vec], + let add_ya = |matrix: &mut [std::collections::BTreeMap], vi: usize, ni: bool, a: usize, @@ -280,6 +273,8 @@ fn add_3sat_clause_penalty( Ok(()) } +type CoefficientRows = Vec>; + /// Expand clause penalties and retain the constant omitted by QUBO. /// K3 reserves one auxiliary per clause, including free auxiliaries for short /// clauses; K2 reserves none. The source constructor validates clause widths. @@ -287,14 +282,11 @@ fn build_qubo_matrix( num_vars: usize, clauses: &[crate::models::formula::CNFClause], num_aux: usize, -) -> Result<(Vec>, i64), &'static str> { +) -> Result<(CoefficientRows, i64), &'static str> { let total = num_vars .checked_add(num_aux) .ok_or("computing the number of SAT QUBO variables")?; - total - .checked_mul(total) - .ok_or("computing the SAT QUBO dense matrix entry count")?; - let mut matrix = vec![vec![0; total]; total]; + let mut matrix = vec![std::collections::BTreeMap::new(); total]; let mut constant = 0i64; for (idx, clause) in clauses.iter().enumerate() { let literals: Vec<_> = clause @@ -326,83 +318,60 @@ 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)) - } -} - -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)) - } -} - #[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_rows(matrix) + .map_err(>>>::target_construction)?, + -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_rows(matrix) + .map_err(>>>::target_construction)?, + -constant, + ), source_num_vars: n, - zero_penalty_energy: -constant, }) } } @@ -426,7 +395,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 +419,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..bb18621f2 100644 --- a/src/rules/ksatisfiability_registersufficiency.rs +++ b/src/rules/ksatisfiability_registersufficiency.rs @@ -12,7 +12,8 @@ use crate::models::formula::KSatisfiability; use crate::models::misc::RegisterSufficiency; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::variant::K3; use std::collections::BTreeSet; @@ -292,17 +293,22 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl Reduction3SATToRegisterSufficiency { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { 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. @@ -311,12 +317,6 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { let cutoff = target_solution[layout.w(layout.num_vars - 1)]; for (variable, &original) in self.source_variables.iter().enumerate() { let positive = target_solution[layout.x_pos(variable)] < cutoff; - let negative = target_solution[layout.x_neg(variable)] < cutoff; - if positive && negative { - return Err(crate::rules::ExtractionError::invalid(format!( - "both literals of variable {original} precede the extraction cutoff" - ))); - } assignment[original] = positive; } Ok(assignment) @@ -513,6 +513,7 @@ impl ReduceTo for KSatisfiability { pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; use crate::models::formula::CNFClause; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "ksatisfiability_to_registersufficiency", @@ -533,7 +534,15 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl Reduction3SATToSimultaneousIncongruences { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ - let x = u64::try_from(*target_solution).map_err(|_| { - crate::rules::ExtractionError::invalid( - "target value cannot be represented in the CRT implementation domain", - ) - })?; + let x = *target_solution as u64; self.variable_primes .iter() .map(|&prime| x % prime == 1) diff --git a/src/rules/ksatisfiability_subsetsum.rs b/src/rules/ksatisfiability_subsetsum.rs index 3c62e5d14..3f11fb0ea 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -15,7 +15,8 @@ use crate::models::formula::KSatisfiability; use crate::models::misc::SubsetSum; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::variant::K3; use num_bigint::BigUint; use num_traits::Zero; @@ -35,12 +36,22 @@ impl ReductionResult for Reduction3SATToSubsetSum { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl Reduction3SATToSubsetSum { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Variable integers are the first 2n elements in 0-based indexing: // for variable i (0 <= i < n), y_i is stored at index 2*i and z_i at index 2*i + 1. diff --git a/src/rules/ksatisfiability_timetabledesign.rs b/src/rules/ksatisfiability_timetabledesign.rs index 112eabbb6..e00805f0d 100644 --- a/src/rules/ksatisfiability_timetabledesign.rs +++ b/src/rules/ksatisfiability_timetabledesign.rs @@ -24,7 +24,8 @@ use crate::models::formula::{CNFClause, KSatisfiability}; use crate::models::misc::TimetableDesign; use crate::reduction; use crate::rules::sat_helpers::SatVariableAllocator; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::variant::K3; use std::collections::VecDeque; @@ -744,12 +745,22 @@ impl ReductionResult for Reduction3SATToTimetableDesign { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl Reduction3SATToTimetableDesign { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let num_periods = self.target.num_periods(); diff --git a/src/rules/lengthboundeddisjointpaths_ilp.rs b/src/rules/lengthboundeddisjointpaths_ilp.rs index 5745f463e..83fd02427 100644 --- a/src/rules/lengthboundeddisjointpaths_ilp.rs +++ b/src/rules/lengthboundeddisjointpaths_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::LengthBoundedDisjointPaths; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use std::collections::VecDeque; @@ -36,26 +37,28 @@ impl ReductionResult for ReductionLBDPToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionLBDPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let m = self.edges.len(); let flow_vars_per_k = 2 * m; let activation_offset = self.num_paths * flow_vars_per_k; let mut result = vec![vec![false; m]; self.num_paths]; for (k, path) in result.iter_mut().enumerate() { if target_solution[activation_offset + k] == 0 { - if target_solution[k * flow_vars_per_k..(k + 1) * flow_vars_per_k] - .iter() - .any(|&flow| flow != 0) - { - return Err(crate::rules::ExtractionError::invalid( - "inactive path slot contains flow", - )); - } continue; } let mut adjacency = vec![Vec::new(); self.num_vertices]; @@ -86,12 +89,7 @@ impl ReductionResult for ReductionLBDPToILP { } } let mut vertex = self.sink; - while vertex != self.source { - let (previous, edge) = predecessor[vertex].ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "active path flow does not connect source to sink", - ) - })?; + while let Some((previous, edge)) = predecessor[vertex] { path[edge] = true; vertex = previous; } diff --git a/src/rules/longestcircuit_ilp.rs b/src/rules/longestcircuit_ilp.rs index c67c9a2e7..7b4330a94 100644 --- a/src/rules/longestcircuit_ilp.rs +++ b/src/rules/longestcircuit_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::LongestCircuit; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing LongestCircuit to ILP. @@ -36,12 +37,22 @@ impl ReductionResult for ReductionLongestCircuitToILP { } /// Extract: output the binary edge-selection vector (y_e). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionLongestCircuitToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_edges] .iter() .map(|&value| value == 1) diff --git a/src/rules/longestcommonsubsequence_ilp.rs b/src/rules/longestcommonsubsequence_ilp.rs index 11225f470..f5d4725aa 100644 --- a/src/rules/longestcommonsubsequence_ilp.rs +++ b/src/rules/longestcommonsubsequence_ilp.rs @@ -13,7 +13,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::LongestCommonSubsequence; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing LongestCommonSubsequence to ILP. #[derive(Debug, Clone)] @@ -31,18 +32,28 @@ impl ReductionResult for ReductionLCSToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionLCSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.max_length, self.alphabet_size + 1, 0, - )? + ) .into_iter() .map(|symbol| (symbol < self.alphabet_size).then_some(symbol)) .collect()) diff --git a/src/rules/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs index be5454a14..36fddd49c 100644 --- a/src/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -13,7 +13,8 @@ use crate::models::graph::MaximumIndependentSet; use crate::models::misc::LongestCommonSubsequence; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; use crate::types::One; @@ -46,12 +47,22 @@ impl ReductionResult for ReductionLCSToIS { /// /// Selected vertices correspond to match nodes. Sort by position in /// the first string to get the subsequence order, then pad to `max_length`. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionLCSToIS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Collect selected match nodes with their characters let mut selected: Vec<(usize, usize)> = target_solution diff --git a/src/rules/longestpath_ilp.rs b/src/rules/longestpath_ilp.rs index ef0246564..1c9615d9a 100644 --- a/src/rules/longestpath_ilp.rs +++ b/src/rules/longestpath_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::LongestPath; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -31,12 +32,22 @@ impl ReductionResult for ReductionLongestPathToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionLongestPathToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ (0..self.num_edges) .map(|edge_idx| { diff --git a/src/rules/maxcut_minimumcutintoboundedsets.rs b/src/rules/maxcut_minimumcutintoboundedsets.rs index 795c46ffb..69b7693bc 100644 --- a/src/rules/maxcut_minimumcutintoboundedsets.rs +++ b/src/rules/maxcut_minimumcutintoboundedsets.rs @@ -9,7 +9,8 @@ use crate::models::graph::{MaxCut, MinimumCutIntoBoundedSets}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MaxCut to MinimumCutIntoBoundedSets. @@ -30,13 +31,14 @@ impl ReductionResult for ReductionMaxCutToMinCutBounded { /// Extract the source solution from the target balanced bisection. /// Take only the first `original_n` vertex assignments. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution[..self.original_n].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.original_n].to_vec()) + }) } } diff --git a/src/rules/maxcut_minimummatrixcover.rs b/src/rules/maxcut_minimummatrixcover.rs index a1e8f50c7..3090d45ed 100644 --- a/src/rules/maxcut_minimummatrixcover.rs +++ b/src/rules/maxcut_minimummatrixcover.rs @@ -13,9 +13,9 @@ //! sign encoding (`config[i] = 1 ⇔ f(i) = +1 ⇔ i ∈ S`). //! //! **Precondition:** all edge weights must be nonnegative. The reduction -//! panics on any negative weight, since `MinimumMatrixCover` requires a +//! returns an error on any negative weight, since `MinimumMatrixCover` requires a //! nonnegative integer matrix. Negative-weight `MaxCut` instances are out -//! of scope and must use a different (preprocessing) reduction. +//! of scope for this reduction. //! //! Reference: Garey & Johnson, *Computers and Intractability* (1979), //! Appendix A1.2, MS13 ("Transformation from MAXIMUM CUT"). @@ -23,7 +23,8 @@ use crate::models::algebraic::MinimumMatrixCover; use crate::models::graph::MaxCut; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MaxCut to MinimumMatrixCover. @@ -48,13 +49,12 @@ impl ReductionResult for ReductionMaxCutToMMC { /// vertex `i` in `S`. The complementary assignment is equally optimal /// because the quadratic form (and the cut) is invariant under /// `f -> -f`. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } @@ -79,13 +79,13 @@ impl ReduceTo for MaxCut { "edge ({u}, {v}) has negative weight {w}" ))); } - let w64 = w; - matrix[u][v] = w64; - matrix[v][u] = w64; + matrix[u][v] = w; + matrix[v][u] = w; } Ok(ReductionMaxCutToMMC { - target: MinimumMatrixCover::new(matrix), + target: MinimumMatrixCover::new(matrix) + .map_err(>::target_construction)?, }) } } diff --git a/src/rules/maximalis_ilp.rs b/src/rules/maximalis_ilp.rs index 1d2e3f93c..eeee3258a 100644 --- a/src/rules/maximalis_ilp.rs +++ b/src/rules/maximalis_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximalIS; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -22,13 +23,14 @@ impl ReductionResult for ReductionMxISToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/maximum2satisfiability_ilp.rs b/src/rules/maximum2satisfiability_ilp.rs index cfa12df13..d8d0279ef 100644 --- a/src/rules/maximum2satisfiability_ilp.rs +++ b/src/rules/maximum2satisfiability_ilp.rs @@ -10,7 +10,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::formula::Maximum2Satisfiability; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Maximum2Satisfiability to ILP. #[derive(Debug, Clone)] @@ -27,12 +28,22 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMaximum2SatisfiabilityToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vars] .iter() .map(|&value| value == 1) diff --git a/src/rules/maximum2satisfiability_maxcut.rs b/src/rules/maximum2satisfiability_maxcut.rs index e153b5d4f..525b4ea69 100644 --- a/src/rules/maximum2satisfiability_maxcut.rs +++ b/src/rules/maximum2satisfiability_maxcut.rs @@ -14,7 +14,8 @@ use crate::models::formula::Maximum2Satisfiability; use crate::models::graph::MaxCut; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; use std::collections::BTreeMap; @@ -33,12 +34,22 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToMaxCut { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMaximum2SatisfiabilityToMaxCut { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let reference_side = target_solution[0]; (0..self.source_num_vars) diff --git a/src/rules/maximumclique_ilp.rs b/src/rules/maximumclique_ilp.rs index 7f62e15b1..df9a7e1f9 100644 --- a/src/rules/maximumclique_ilp.rs +++ b/src/rules/maximumclique_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximumClique; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MaximumClique to ILP. @@ -35,13 +36,14 @@ impl ReductionResult for ReductionCliqueToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/maximumclique_maximumindependentset.rs b/src/rules/maximumclique_maximumindependentset.rs index 7a42f27f4..58957aae7 100644 --- a/src/rules/maximumclique_maximumindependentset.rs +++ b/src/rules/maximumclique_maximumindependentset.rs @@ -5,7 +5,8 @@ use crate::models::graph::{MaximumClique, MaximumIndependentSet}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::{One, WeightElement}; @@ -28,13 +29,12 @@ where /// Solution extraction: identity mapping. /// A clique in G is an independent set in the complement, so the configuration is the same. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/maximumcokplex_ilp.rs b/src/rules/maximumcokplex_ilp.rs index 8021bb316..c839676e4 100644 --- a/src/rules/maximumcokplex_ilp.rs +++ b/src/rules/maximumcokplex_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximumCoKPlex; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::{One, WeightElement}; use crate::variant::{VariantParam, KN}; @@ -31,13 +32,14 @@ where &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/maximumcommonedgesubgraph_ilp.rs b/src/rules/maximumcommonedgesubgraph_ilp.rs index 4ab7d8858..de4d5a401 100644 --- a/src/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/rules/maximumcommonedgesubgraph_ilp.rs @@ -16,7 +16,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximumCommonEdgeSubgraph; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MaximumCommonEdgeSubgraph to ILP. /// @@ -43,26 +44,31 @@ impl ReductionResult for ReductionMCESToILP { /// Extract: for each source vertex `u`, output the unique target vertex /// `p` with `x_(u,p) = 1`, or the sentinel `n2` ("bottom") when no /// mapping variable is selected. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMCESToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let n2 = self.num_vertices_2; - (0..self.num_vertices_1) + Ok((0..self.num_vertices_1) .map(|vertex| { - let mut selected = - (0..n2).filter(|&mapped| target_solution[vertex * n2 + mapped] == 1); - match (selected.next(), selected.next()) { - (Some(mapped), None) => Ok(mapped), - (None, _) => Ok(n2), - (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( - "source vertex {vertex} maps to multiple target vertices" - ))), + match (0..n2).find(|&mapped| target_solution[vertex * n2 + mapped] == 1) { + Some(mapped) => mapped, + None => n2, } }) - .collect() + .collect()) } } diff --git a/src/rules/maximumcontactmapoverlap_ilp.rs b/src/rules/maximumcontactmapoverlap_ilp.rs index 709e0053f..eb8e7885d 100644 --- a/src/rules/maximumcontactmapoverlap_ilp.rs +++ b/src/rules/maximumcontactmapoverlap_ilp.rs @@ -17,7 +17,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximumContactMapOverlap; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MaximumContactMapOverlap to ILP. /// @@ -46,26 +47,31 @@ impl ReductionResult for ReductionCMOToILP { /// For each source residue `i in V_1`, find the unique `j` with /// `x_(i,j) = 1` and encode it as `j + 1` (CMO's `bot` is `0`); if no /// `x_(i,*)` is selected, the residue is left unmatched (`0`). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionCMOToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let n2 = self.num_vertices_2; - (0..self.num_vertices_1) + Ok((0..self.num_vertices_1) .map(|residue| { - let mut selected = - (0..n2).filter(|&mapped| target_solution[residue * n2 + mapped] == 1); - match (selected.next(), selected.next()) { - (Some(mapped), None) => Ok(mapped + 1), - (None, _) => Ok(0), - (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( - "source residue {residue} maps to multiple target residues" - ))), + match (0..n2).find(|&mapped| target_solution[residue * n2 + mapped] == 1) { + Some(mapped) => mapped + 1, + None => 0, } }) - .collect() + .collect()) } } diff --git a/src/rules/maximumdomaticnumber_ilp.rs b/src/rules/maximumdomaticnumber_ilp.rs index 3a0221a6e..e3a573531 100644 --- a/src/rules/maximumdomaticnumber_ilp.rs +++ b/src/rules/maximumdomaticnumber_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximumDomaticNumber; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MaximumDomaticNumber to ILP. @@ -36,12 +37,22 @@ impl ReductionResult for ReductionDomaticNumberToILP { /// Extract solution from ILP back to MaximumDomaticNumber. /// /// For each vertex v, find the set index i where x_{v,i} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionDomaticNumberToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.n; let mut config = vec![0; n]; @@ -98,7 +109,7 @@ impl ReduceTo> for MaximumDomaticNumber { // Linking constraints: x_{v,i} <= y_i for each v, i // Forces y_i = 1 whenever any vertex is assigned to set i, - // ensuring extract_solution always yields a valid partition. + // ensuring recovery always yields a valid partition. for v in 0..n { for i in 0..n { constraints.push(LinearConstraint::le( diff --git a/src/rules/maximumedgeweightedkclique_ilp.rs b/src/rules/maximumedgeweightedkclique_ilp.rs index c7909601b..7b50ee252 100644 --- a/src/rules/maximumedgeweightedkclique_ilp.rs +++ b/src/rules/maximumedgeweightedkclique_ilp.rs @@ -26,7 +26,8 @@ use crate::models::algebraic::{ILPCoefficient, LinearConstraint, ObjectiveSense, use crate::models::graph::MaximumEdgeWeightedKClique; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::Graph; use crate::variant::VariantParam; @@ -58,12 +59,25 @@ where /// Extract: take the first `num_vertices` entries of the ILP solution. /// They are exactly the binary `x_v` selection variables. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMaximumEdgeWeightedKCliqueToILP +where + W: ILPCoefficient + VariantParam, +{ + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/maximumindependentset_casts.rs b/src/rules/maximumindependentset_casts.rs index ff7d07906..6f84b7b0f 100644 --- a/src/rules/maximumindependentset_casts.rs +++ b/src/rules/maximumindependentset_casts.rs @@ -11,7 +11,7 @@ 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 +26,7 @@ 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 +41,7 @@ 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 +52,7 @@ 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 +67,7 @@ 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 +78,7 @@ 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 +120,7 @@ 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 +129,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], - aggregate: identity, + |src| MaximumIndependentSet::new( src.graph().clone(), vec![1_i64; src.num_vertices()]) ); diff --git a/src/rules/maximumindependentset_gridgraph.rs b/src/rules/maximumindependentset_gridgraph.rs index 1542a67fb..ec75d4497 100644 --- a/src/rules/maximumindependentset_gridgraph.rs +++ b/src/rules/maximumindependentset_gridgraph.rs @@ -7,6 +7,8 @@ use crate::models::graph::MaximumIndependentSet; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::rules::unitdiskmapping::ksg; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, KingsSubgraph, SimpleGraph}; use crate::types::One; @@ -25,12 +27,31 @@ impl ReductionResult for ReductionISSimpleOneToGridOne { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::optimal(source, solution)?) + } + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } + } +} +impl ReductionISSimpleOneToGridOne { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let encoded = crate::config::bits_to_config(target_solution); let mapped = self.mapping_result.map_config_back(&encoded)?; Ok(crate::config::config_to_bits(&mapped)) diff --git a/src/rules/maximumindependentset_maximumclique.rs b/src/rules/maximumindependentset_maximumclique.rs index 98adc9190..86f1938a1 100644 --- a/src/rules/maximumindependentset_maximumclique.rs +++ b/src/rules/maximumindependentset_maximumclique.rs @@ -5,7 +5,8 @@ use crate::models::graph::{MaximumClique, MaximumIndependentSet}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::{One, WeightElement}; @@ -28,13 +29,12 @@ where /// Solution extraction: identity mapping. /// A vertex selected in the clique (target) is also selected in the independent set (source). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/maximumindependentset_maximumsetpacking.rs b/src/rules/maximumindependentset_maximumsetpacking.rs index 6695e87f4..9fe7a3378 100644 --- a/src/rules/maximumindependentset_maximumsetpacking.rs +++ b/src/rules/maximumindependentset_maximumsetpacking.rs @@ -6,7 +6,8 @@ use crate::models::graph::MaximumIndependentSet; use crate::models::set::MaximumSetPacking; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::{One, WeightElement}; use std::collections::HashSet; @@ -29,13 +30,12 @@ where } /// Solutions map directly: vertex selection = set selection. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } @@ -91,13 +91,12 @@ where } /// Solutions map directly. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/maximumindependentset_triangular.rs b/src/rules/maximumindependentset_triangular.rs index 644c75bae..4f234a01f 100644 --- a/src/rules/maximumindependentset_triangular.rs +++ b/src/rules/maximumindependentset_triangular.rs @@ -9,6 +9,8 @@ use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::rules::unitdiskmapping::ksg; use crate::rules::unitdiskmapping::triangular; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph, TriangularSubgraph}; use crate::types::One; @@ -27,12 +29,31 @@ impl ReductionResult for ReductionISSimpleToTriangular { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::optimal(source, solution)?) + } + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } + } +} +impl ReductionISSimpleToTriangular { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let encoded = crate::config::bits_to_config(target_solution); let mapped = triangular::map_config_back(&self.mapping_result, &encoded)?; Ok(crate::config::config_to_bits(&mapped)) diff --git a/src/rules/maximumleafspanningtree_ilp.rs b/src/rules/maximumleafspanningtree_ilp.rs index 2623018b7..a66f5dfd3 100644 --- a/src/rules/maximumleafspanningtree_ilp.rs +++ b/src/rules/maximumleafspanningtree_ilp.rs @@ -21,7 +21,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximumLeafSpanningTree; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MaximumLeafSpanningTree to ILP. @@ -39,12 +40,22 @@ impl ReductionResult for ReductionMaximumLeafSpanningTreeToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMaximumLeafSpanningTreeToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // First m variables are edge selectors target_solution[..self.num_edges] diff --git a/src/rules/maximumlikelihoodranking_ilp.rs b/src/rules/maximumlikelihoodranking_ilp.rs index 954b09627..5e07fffda 100644 --- a/src/rules/maximumlikelihoodranking_ilp.rs +++ b/src/rules/maximumlikelihoodranking_ilp.rs @@ -13,7 +13,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::MaximumLikelihoodRanking; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MaximumLikelihoodRanking to ILP. #[derive(Debug, Clone)] @@ -39,12 +40,22 @@ impl ReductionResult for ReductionMaximumLikelihoodRankingToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMaximumLikelihoodRankingToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.n; if n == 0 { diff --git a/src/rules/maximummatching_ilp.rs b/src/rules/maximummatching_ilp.rs index a4e27b0d9..45db439c9 100644 --- a/src/rules/maximummatching_ilp.rs +++ b/src/rules/maximummatching_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MaximumMatching; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MaximumMatching to ILP. @@ -35,13 +36,14 @@ impl ReductionResult for ReductionMatchingToILP { /// /// Since the mapping is 1:1 (each edge maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/maximummatching_maximumsetpacking.rs b/src/rules/maximummatching_maximumsetpacking.rs index 1d98ef7a1..433662acc 100644 --- a/src/rules/maximummatching_maximumsetpacking.rs +++ b/src/rules/maximummatching_maximumsetpacking.rs @@ -6,7 +6,8 @@ use crate::models::graph::MaximumMatching; use crate::models::set::MaximumSetPacking; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::WeightElement; @@ -30,13 +31,12 @@ where } /// Solutions map directly: edge i in MaximumMatching = set i in MaximumSetPacking. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/maximumsetpacking_casts.rs b/src/rules/maximumsetpacking_casts.rs index 04314c4df..461ce38b0 100644 --- a/src/rules/maximumsetpacking_casts.rs +++ b/src/rules/maximumsetpacking_casts.rs @@ -9,7 +9,7 @@ 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()]) diff --git a/src/rules/maximumsetpacking_ilp.rs b/src/rules/maximumsetpacking_ilp.rs index 82766a60e..5bd7f07f1 100644 --- a/src/rules/maximumsetpacking_ilp.rs +++ b/src/rules/maximumsetpacking_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::MaximumSetPacking; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MaximumSetPacking to ILP. /// @@ -29,18 +30,19 @@ impl ReductionResult for ReductionSPToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } #[reduction( - transform = exact { + transform = upper_bound { num_vars = "num_sets", num_constraints = "universe_size", }, diff --git a/src/rules/maximumsetpacking_qubo.rs b/src/rules/maximumsetpacking_qubo.rs index 2a7559884..72706f98a 100644 --- a/src/rules/maximumsetpacking_qubo.rs +++ b/src/rules/maximumsetpacking_qubo.rs @@ -15,6 +15,8 @@ use crate::models::algebraic::QUBO; use crate::models::set::MaximumSetPacking; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; /// Result of reducing `MaximumSetPacking` to `QUBO`. #[derive(Debug, Clone)] @@ -30,13 +32,18 @@ impl ReductionResult for ReductionSPToQUBO { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => Ok(SolveOutcome::optimal(source, solution)?), + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } } } @@ -62,21 +69,21 @@ impl ReduceTo> for MaximumSetPacking { >("computing the set-packing conflict penalty")); } - let mut matrix = vec![vec![0.0; n]; n]; + let mut matrix = vec![std::collections::BTreeMap::new(); n]; // Diagonal: -w_i for i in 0..n { - matrix[i][i] = -weights[i]; + matrix[i].insert(i, -weights[i]); } // Off-diagonal: P for overlapping pairs for (i, j) in self.overlapping_pairs() { let (a, b) = if i < j { (i, j) } else { (j, i) }; - matrix[a][b] += penalty; + *matrix[a].entry(b).or_insert(0.0) += penalty; } Ok(ReductionSPToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { + target: QUBO::from_rows(matrix).map_err(|message| { crate::rules::ReductionError::construction::, QUBO>( message, ) diff --git a/src/rules/minimumcapacitatedspanningtree_ilp.rs b/src/rules/minimumcapacitatedspanningtree_ilp.rs index 0886ad247..aaad5073c 100644 --- a/src/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/rules/minimumcapacitatedspanningtree_ilp.rs @@ -27,7 +27,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumCapacitatedSpanningTree; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::WeightElement; @@ -46,12 +47,22 @@ impl ReductionResult for ReductionMinimumCapacitatedSpanningTreeToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMinimumCapacitatedSpanningTreeToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // First m variables are edge selectors target_solution[..self.num_edges] diff --git a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs index 0d6df7ddb..8adc15bbe 100644 --- a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs +++ b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs @@ -17,7 +17,8 @@ use crate::models::graph::{MinimumCostCirculation, MinimumCostMaximumFlow}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::DirectedGraph; /// Result of reducing MinimumCostMaximumFlow to MinimumCostCirculation. @@ -43,13 +44,14 @@ impl ReductionResult for ReductionMCMFToMCC { /// Extract the source flow by discarding the return arc: the first /// `num_original_arcs` entries of the circulation are exactly the /// flow values on the original arcs. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution[..self.num_original_arcs].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.num_original_arcs].to_vec()) + }) } } diff --git a/src/rules/minimumcoveringbycliques_ilp.rs b/src/rules/minimumcoveringbycliques_ilp.rs index fc139660b..9fe221631 100644 --- a/src/rules/minimumcoveringbycliques_ilp.rs +++ b/src/rules/minimumcoveringbycliques_ilp.rs @@ -21,7 +21,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumCoveringByCliques; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -39,25 +40,31 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - (0..self.num_edges) - .map(|edge| { +impl ReductionMinimumCoveringByCliquesToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok((0..self.num_edges) + .flat_map(|edge| { (0..self.num_edges) - .find(|&clique| { + .filter(move |&clique| { target_solution[self.y_offset + edge * self.num_edges + clique] == 1 }) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "edge {edge} is not covered by any clique" - )) - }) + .take(1) }) - .collect() + .collect()) } } diff --git a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index 892f31b03..98125df22 100644 --- a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -6,9 +6,9 @@ use crate::models::graph::{MinimumCoveringByCliques, MinimumIntersectionGraphBasis}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; -use crate::traits::Problem; use std::collections::BTreeMap; #[derive(Debug, Clone)] @@ -16,37 +16,20 @@ pub struct ReductionMinimumCoveringByCliquesToMinimumIntersectionGraphBasis { target: MinimumIntersectionGraphBasis, } -fn extract_edge_clique_cover( - graph: &SimpleGraph, - target_solution: &[Vec], -) -> Option> { - let n = graph.num_vertices(); +fn extract_edge_clique_cover(graph: &SimpleGraph, target_solution: &[Vec]) -> Vec { let m = graph.num_edges(); - - if target_solution.len() != n || target_solution.iter().any(|row| row.len() != m) { - return None; - } - - if m == 0 { - return Some(Vec::new()); - } - let mut label_map = BTreeMap::new(); - let mut next_label = 0usize; let mut source_solution = Vec::with_capacity(m); - for (u, v) in graph.edges() { - let shared_label = - (0..m).find(|&slot| target_solution[u][slot] && target_solution[v][slot])?; - let compressed = *label_map.entry(shared_label).or_insert_with(|| { - let label = next_label; - next_label += 1; - label - }); - source_solution.push(compressed); + for shared_label in (0..m) + .filter(|&slot| target_solution[u][slot] && target_solution[v][slot]) + .take(1) + { + let next_label = label_map.len(); + source_solution.push(*label_map.entry(shared_label).or_insert(next_label)); + } } - - Some(source_solution) + source_solution } #[cfg(any(test, feature = "example-db"))] @@ -82,25 +65,26 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToMinimumIntersectionG &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - 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", - ) - })? - }) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionMinimumCoveringByCliquesToMinimumIntersectionGraphBasis { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(extract_edge_clique_cover( + self.target.graph(), + target_solution, + )) } } diff --git a/src/rules/minimumcutintoboundedsets_ilp.rs b/src/rules/minimumcutintoboundedsets_ilp.rs index bb0331be8..b444ba3ab 100644 --- a/src/rules/minimumcutintoboundedsets_ilp.rs +++ b/src/rules/minimumcutintoboundedsets_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumCutIntoBoundedSets; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -26,12 +27,22 @@ impl ReductionResult for ReductionMinCutBSToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMinCutBSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs index a00026804..a5edce462 100644 --- a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -12,6 +12,8 @@ use crate::models::algebraic::QUBO; use crate::models::misc::MinimumDiscretePlanarInverseKinematics; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; fn block_offsets(block_sizes: &[usize]) -> Vec { let mut offsets = Vec::with_capacity(block_sizes.len()); @@ -29,6 +31,8 @@ pub struct ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { target: QUBO, block_offsets: Vec, block_sizes: Vec, + omitted_constant: f64, + feasible_energy_upper: f64, } impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { @@ -39,32 +43,68 @@ impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { &self.target } - fn extract_solution( + /// Decode a qualifying optimum after the energy relation establishes source + /// feasibility. Such an optimum is one-hot and obeys every allowed pair. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Ok(SolveOutcome::Infeasible); + } + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::optimal(source, solution)?) + } + SolveOutcome::Feasible { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Err(crate::rules::ExtractionError::InsufficientSolutionQuality); + } + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::feasible(source, solution)?) + } + } + } +} - self.block_offsets +impl ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(self + .block_offsets .iter() .zip(&self.block_sizes) - .enumerate() - .map(|(link, (&start, &size))| { - let mut selected = target_solution[start..start + size] + .map(|(&start, &size)| { + target_solution[start..start + size] .iter() - .enumerate() - .filter_map(|(orientation, &bit)| bit.then_some(orientation)); - match (selected.next(), selected.next()) { - (Some(orientation), None) => Ok(orientation), - (None, _) => Err(crate::rules::ExtractionError::invalid(format!( - "link {link} has no selected orientation" - ))), - (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( - "link {link} has multiple selected orientations" - ))), - } + .position(|&bit| bit) + .unwrap() }) - .collect() + .collect()) + } +} + +impl ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { + fn map_value(&self, value: crate::types::Min) -> crate::types::Min { + crate::types::Min( + value + .0 + .filter(|&energy| energy < self.feasible_energy_upper) + .map(|energy| energy + self.omitted_constant), + ) } } @@ -95,12 +135,25 @@ impl ReduceTo> for MinimumDiscretePlanarInverseKinematics { // instance is one-hot and pair-feasible. 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); + // Leave a gap proportional to the scale, rather than adding one to a + // large floating-point number that may round back to itself. + let penalty = 2.0 * (1.0 + distance_bound); + let omitted_constant = gx * gx + gy * gy + penalty * block_sizes.len() as f64; + let feasible_energy_upper = 1.5 * (1.0 + distance_bound) - omitted_constant; + if !omitted_constant.is_finite() || !feasible_energy_upper.is_finite() { + return Err(crate::rules::ReductionError::non_finite_result::< + Self, + QUBO, + >( + "computing the inverse-kinematics energy relation" + )); + } - let mut matrix = vec![vec![0.0; total_vars]; total_vars]; + let mut matrix = vec![std::collections::BTreeMap::new(); total_vars]; let mut add_upper = |i: usize, j: usize, value: f64| { let (lo, hi) = if i <= j { (i, j) } else { (j, i) }; - matrix[lo][hi] += value; + *matrix[lo].entry(hi).or_insert(0.0) += value; }; // Position objective: (X - g_x)^2 + (Y - g_y)^2, dropping the @@ -156,14 +209,12 @@ impl ReduceTo> for MinimumDiscretePlanarInverseKinematics { } Ok(ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { - crate::rules::ReductionError::construction::< - MinimumDiscretePlanarInverseKinematics, - QUBO, - >(message) - })?, + target: QUBO::from_rows(matrix) + .map_err(>>::target_construction)?, block_offsets, block_sizes, + omitted_constant, + feasible_energy_upper, }) } } diff --git a/src/rules/minimumdominatingset_ilp.rs b/src/rules/minimumdominatingset_ilp.rs index 5b1d3f945..6c5c59f4e 100644 --- a/src/rules/minimumdominatingset_ilp.rs +++ b/src/rules/minimumdominatingset_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumDominatingSet; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MinimumDominatingSet to ILP. @@ -36,13 +37,14 @@ impl ReductionResult for ReductionDSToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/minimumedgecostflow_ilp.rs b/src/rules/minimumedgecostflow_ilp.rs index dd30eb04a..b6a9b9e4a 100644 --- a/src/rules/minimumedgecostflow_ilp.rs +++ b/src/rules/minimumedgecostflow_ilp.rs @@ -21,7 +21,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumEdgeCostFlow; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MinimumEdgeCostFlow to `ILP`. /// @@ -43,12 +44,22 @@ impl ReductionResult for ReductionMECFToILP { } /// Extract flow solution: first m variables are the flow values. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMECFToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(&target_solution[..self.num_edges]) } } diff --git a/src/rules/minimumexternalmacrodatacompression_ilp.rs b/src/rules/minimumexternalmacrodatacompression_ilp.rs index ba34bba53..3141fd882 100644 --- a/src/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/rules/minimumexternalmacrodatacompression_ilp.rs @@ -16,7 +16,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::MinimumExternalMacroDataCompression; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Index layout for ILP variables. #[derive(Debug, Clone)] @@ -107,7 +108,7 @@ pub struct ReductionEMDCToILP { target: ILP, /// Variable layout for solution extraction. layout: VarLayout, - /// The source string (needed for extract_solution). + /// The source string (needed for solution recovery). source_string: Vec, /// Alphabet size. alphabet_size: usize, @@ -121,12 +122,22 @@ impl ReductionResult for ReductionEMDCToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionEMDCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.layout.n; let k = self.alphabet_size; @@ -135,27 +146,10 @@ impl ReductionResult for ReductionEMDCToILP { // Build D-slots let mut d_slots = vec![empty; n]; for j in 0..n { - let symbols: Vec<_> = (0..k) - .filter(|&c| target_solution[self.layout.d_var(j, c)] == 1) - .collect(); if target_solution[self.layout.d_used_var(j)] == 1 { - match symbols.as_slice() { - [symbol] => d_slots[j] = *symbol, - [] => { - return Err(crate::rules::ExtractionError::invalid(format!( - "dictionary slot {j} is active without a symbol" - ))) - } - _ => { - return Err(crate::rules::ExtractionError::invalid(format!( - "dictionary slot {j} selects multiple symbols" - ))) - } - } - } else if !symbols.is_empty() { - return Err(crate::rules::ExtractionError::invalid(format!( - "inactive dictionary slot {j} selects a symbol" - ))); + d_slots[j] = (0..k) + .filter(|&c| target_solution[self.layout.d_var(j, c)] == 1) + .sum(); } } @@ -173,23 +167,14 @@ impl ReductionResult for ReductionEMDCToILP { }) .collect(); if target_solution[self.layout.lit_var(pos)] == 1 { - if !pointers.is_empty() { - return Err(crate::rules::ExtractionError::invalid(format!( - "position {pos} selects both a literal and a pointer" - ))); - } // Literal at position pos c_slots[c_pos] = self.source_string[pos]; c_pos += 1; pos += 1; continue; } - let [(d_start, length)] = pointers.as_slice() else { - return Err(crate::rules::ExtractionError::invalid(format!( - "position {pos} must select exactly one pointer" - ))); - }; - let ptr_idx = encode_pointer(n, *d_start, *length); + let (d_start, length) = pointers[0]; + let ptr_idx = encode_pointer(n, d_start, length); c_slots[c_pos] = k + 1 + ptr_idx; c_pos += 1; pos += length; @@ -355,7 +340,7 @@ impl ReduceTo> for MinimumExternalMacroDataCompression { // 6. Literal matching: lit[i] can only be active if position i exists // (this is always true for i < n, so no constraint needed). // But we do need: if lit[i] = 1, the literal is s[i], which is automatic - // in the extract_solution. No additional constraint needed because the + // in solution recovery. No additional constraint needed because the // objective already penalizes literals. // Objective: minimize sum d_used[j] + sum lit[i] + h * sum ptr[i][l][d_start] @@ -385,6 +370,7 @@ impl ReduceTo> for MinimumExternalMacroDataCompression { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; // s = "ab" (len 2), alphabet {a,b} (size 2), h=2 // Optimal: uncompressed, D="" C="ab", cost = 0+2+0 = 2 @@ -406,7 +392,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec`. @@ -27,12 +28,22 @@ impl ReductionResult for ReductionMFDTSToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMFDTSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok((0..self.num_inputs) .map(|input| { (0..self.num_outputs) diff --git a/src/rules/minimumfeedbackarcset_ilp.rs b/src/rules/minimumfeedbackarcset_ilp.rs index 2de455d85..9f1cd2f0e 100644 --- a/src/rules/minimumfeedbackarcset_ilp.rs +++ b/src/rules/minimumfeedbackarcset_ilp.rs @@ -12,7 +12,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumFeedbackArcSet; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MinimumFeedbackArcSet to ILP. /// @@ -41,12 +42,22 @@ impl ReductionResult for ReductionFASToILP { /// /// The first m variables of the ILP solution are the binary y_a values, /// which directly correspond to the FAS configuration (1 = removed). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionFASToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_arcs] .iter() .map(|&value| value == 1) diff --git a/src/rules/minimumfeedbackvertexset_ilp.rs b/src/rules/minimumfeedbackvertexset_ilp.rs index 8b712b1a1..8afd8f9ea 100644 --- a/src/rules/minimumfeedbackvertexset_ilp.rs +++ b/src/rules/minimumfeedbackvertexset_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumFeedbackVertexSet; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MinimumFeedbackVertexSet to ILP. /// @@ -38,12 +39,22 @@ impl ReductionResult for ReductionMFVSToILP { /// /// The first n variables of the ILP solution are the binary x_i values, /// which directly correspond to the FVS configuration (1 = removed). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMFVSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs index 1e336e56b..ee8756d6a 100644 --- a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs +++ b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs @@ -9,7 +9,8 @@ use crate::models::graph::MinimumFeedbackVertexSet; use crate::models::misc::MinimumCodeGenerationUnlimitedRegisters; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::types::One; /// Result of the unit-weight FVS to code-generation reduction. @@ -30,17 +31,22 @@ impl ReductionResult for ReductionFVSToCodeGen { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionFVSToCodeGen { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(self .chain_start .iter() @@ -127,6 +133,7 @@ fn issue_example_source() -> MinimumFeedbackVertexSet { pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; use crate::solvers::BruteForce; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "minimumfeedbackvertexset_to_minimumcodegenerationunlimitedregisters", @@ -138,7 +145,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionMGBToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_vertices, self.num_vertices, 0, - ) + )) } } diff --git a/src/rules/minimumhittingset_ilp.rs b/src/rules/minimumhittingset_ilp.rs index fe8f498d9..127caef46 100644 --- a/src/rules/minimumhittingset_ilp.rs +++ b/src/rules/minimumhittingset_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::MinimumHittingSet; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionHSToILP { @@ -21,13 +22,14 @@ impl ReductionResult for ReductionHSToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/minimuminternalmacrodatacompression_ilp.rs b/src/rules/minimuminternalmacrodatacompression_ilp.rs index 0ff986409..18f13aa76 100644 --- a/src/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/rules/minimuminternalmacrodatacompression_ilp.rs @@ -18,7 +18,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::MinimumInternalMacroDataCompression; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Index layout for ILP variables. #[derive(Debug, Clone)] @@ -95,12 +96,22 @@ impl ReductionResult for ReductionIMDCToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionIMDCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.layout.n; let k = self.alphabet_size; @@ -284,6 +295,7 @@ impl ReduceTo> for MinimumInternalMacroDataCompression { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; // s = "ab" (len 2), alphabet {a,b} (size 2), h=2 // Optimal: uncompressed C="ab", cost = 2 @@ -300,7 +312,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, diff --git a/src/rules/minimummatrixcover_ilp.rs b/src/rules/minimummatrixcover_ilp.rs index d279e1b67..5bfe9c398 100644 --- a/src/rules/minimummatrixcover_ilp.rs +++ b/src/rules/minimummatrixcover_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::MinimumMatrixCover; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MinimumMatrixCover to ILP. #[derive(Debug, Clone)] @@ -28,12 +29,22 @@ impl ReductionResult for ReductionMinimumMatrixCoverToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMinimumMatrixCoverToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // First n variables are the sign variables x_0,...,x_{n-1} target_solution[..self.n] @@ -160,7 +171,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)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs index 65615aa27..946671c14 100644 --- a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs +++ b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs @@ -9,14 +9,15 @@ use crate::models::graph::{MaximumAchromaticNumber, MinimumMaximalMatching}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{BipartiteGraph, Graph, SimpleGraph}; /// Result of reducing `MinimumMaximalMatching` to /// `MaximumAchromaticNumber`. /// /// Stores the target problem along with the source edge list (in unified vertex -/// coordinates) so that `extract_solution` can map a target coloring back to a +/// coordinates) so that `recover_result` can map a target coloring back to a /// maximal matching of the source graph. #[derive(Debug, Clone)] pub struct ReductionMMMToAchromatic { @@ -42,12 +43,22 @@ impl ReductionResult for ReductionMMMToAchromatic { /// size 2, i.e., a source edge. A source edge `(u, v)` belongs to the /// extracted matching iff `u` and `v` share a color, which we detect in a /// single pass over `source_edges`. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMMMToAchromatic { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ self.source_edges .iter() diff --git a/src/rules/minimummaximalmatching_minimummatrixdomination.rs b/src/rules/minimummaximalmatching_minimummatrixdomination.rs index 03b0c98d7..59fab764d 100644 --- a/src/rules/minimummaximalmatching_minimummatrixdomination.rs +++ b/src/rules/minimummaximalmatching_minimummatrixdomination.rs @@ -36,7 +36,8 @@ use crate::models::algebraic::MinimumMatrixDomination; use crate::models::graph::MinimumMaximalMatching; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{BipartiteGraph, Graph}; /// Result of reducing `MinimumMaximalMatching` to @@ -44,7 +45,7 @@ use crate::topology::{BipartiteGraph, Graph}; /// /// Holds the constructed target matrix-domination instance together with a copy /// of the source bipartite-matching problem. The source copy is used by -/// `extract_solution` to perform the Yannakakis-Gavril conversion from an edge +/// `recover_result` to perform the Yannakakis-Gavril conversion from an edge /// dominating set to an equally-sized maximal matching. #[derive(Debug, Clone)] pub struct ReductionMMMToMatrixDomination { @@ -80,7 +81,6 @@ impl ReductionResult for ReductionMMMToMatrixDomination { /// /// - **Drop:** if every edge of `B` incident to `u` is already dominated /// by `D \ {e1}`, set `D := D \ {e1}` (size strictly decreases). - /// Symmetric for `w` and `e2`. /// - **Swap:** otherwise, some edge `(u, x)` of `B` is currently dominated /// only by `e1`. This `x` must lie outside `V(D \ {e1})` and is /// therefore distinct from `w`, so `(u, x)` is not adjacent to `e2`. @@ -89,16 +89,26 @@ impl ReductionResult for ReductionMMMToMatrixDomination { /// /// Each iteration strictly decreases either `|D|` or the number of /// adjacent pairs, so the loop terminates in `O(|F|^2)` iterations. Each - /// iteration scans `O(|F|)` edges to find an adjacent pair, an EDS check, - /// and a swap candidate, for a total of `O(|F|^3)` time. The result is a + /// iteration scans `O(|F|)` edges to find an adjacent pair and an + /// undominated edge, for a total of `O(|F|^3)` time. The result is a /// matching that is an EDS, i.e. an independent EDS, which is precisely a /// maximal matching. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMMMToMatrixDomination { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let graph = self.source.graph(); let edges = graph.edges(); @@ -124,87 +134,28 @@ impl ReductionResult for ReductionMMMToMatrixDomination { .collect(); let mut d: Vec = target_solution .iter() - .zip(target_ones.iter()) - .filter_map(|(&sel, &cell)| { - if sel { - Some(cell_to_source_edge.get(&cell).copied().ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "selected matrix cell {cell:?} has no source edge" - )) - })) - } else { - None - } - }) - .collect::>()?; - - // Step 2: Yannakakis-Gavril EDS -> independent EDS (maximal matching). - // Loop invariants: `d` is an EDS of the source graph; each iteration - // strictly decreases either |d| or the number of (unordered) pairs of - // adjacent edges inside `d`. - loop { - // Find an adjacent pair (e1_idx, e2_idx) inside `d`, sharing vertex v. - let pair = find_adjacent_pair(&d, &edges); - let Some((e1_idx, e2_idx, _shared)) = pair else { - break; // `d` is a matching; we are done. - }; - - // Try dropping e1_idx or e2_idx if the remainder is still an EDS. - let mut without_e1 = d.clone(); - let e1_position = d.iter().position(|&x| x == e1_idx).ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "edge-domination transformation lost its selected edge", - ) - })?; - without_e1.swap_remove(e1_position); - if is_edge_dominating_set(&without_e1, &edges) { - d = without_e1; - continue; - } - let mut without_e2 = d.clone(); - let e2_position = d.iter().position(|&x| x == e2_idx).ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "edge-domination transformation lost its selected edge", - ) - })?; - without_e2.swap_remove(e2_position); - if is_edge_dominating_set(&without_e2, &edges) { - d = without_e2; - continue; - } - - // Neither drop works -> perform a swap on one of e1 or e2. - // Choose endpoint not shared with the other edge: for e1=(u, v), - // e2=(v, w), the "non-shared" endpoint of e1 is u. - let (e1_a, e1_b) = edges[e1_idx]; - let (e2_a, e2_b) = edges[e2_idx]; - let shared = if e1_a == e2_a || e1_a == e2_b { - e1_a - } else { - e1_b - }; - let u = if e1_a == shared { e1_b } else { e1_a }; - let w = if e2_a == shared { e2_b } else { e2_a }; + .zip(target_ones) + .filter(|(selected, _)| **selected) + .map(|(_, cell)| cell_to_source_edge[cell]) + .collect(); - // Try to swap e1 := (u, x) where x ∉ V(d \ {e1}). The YG proof - // guarantees such x exists when neither drop succeeded. - if let Some(new_idx) = find_swap_edge(u, e1_idx, &d, &edges) { - d[e1_position] = new_idx; - continue; + // Remove one of two adjacent selected edges. Any edge no longer + // dominated is private to its non-shared endpoint; replacing the + // removed edge with it preserves domination and reduces adjacency. + while let Some(position) = find_adjacent_pair(&d, &edges) { + let mut remaining = d.clone(); + remaining.swap_remove(position); + let covered: std::collections::HashSet<_> = remaining + .iter() + .flat_map(|&edge| [edges[edge].0, edges[edge].1]) + .collect(); + match edges + .iter() + .position(|&(u, v)| !covered.contains(&u) && !covered.contains(&v)) + { + Some(edge) => d[position] = edge, + None => d = remaining, } - // Symmetric swap on e2. - if let Some(new_idx) = find_swap_edge(w, e2_idx, &d, &edges) { - d[e2_position] = new_idx; - continue; - } - - // YG guarantees that for an EDS at least one of the four moves - // above succeeds. Reaching this point implies the input was not - // a valid EDS (i.e., not a feasible MMD witness on the constructed - // instance), which violates the reduction's precondition. - return Err(crate::rules::ExtractionError::invalid( - "target matrix entries do not encode an edge-dominating set", - )); } // Step 3: encode the matching as a binary configuration over source edges. @@ -217,81 +168,19 @@ impl ReductionResult for ReductionMMMToMatrixDomination { } } -/// Return `Some((i, j, v))` where `i`, `j` are indices in `d` of two edges that -/// share vertex `v`, or `None` if all edges in `d` are pairwise independent. -fn find_adjacent_pair(d: &[usize], edges: &[(usize, usize)]) -> Option<(usize, usize, usize)> { - for (a_pos, &i) in d.iter().enumerate() { - let (iu, iv) = edges[i]; - for &j in &d[a_pos + 1..] { - let (ju, jv) = edges[j]; - if iu == ju || iu == jv { - return Some((i, j, iu)); - } - if iv == ju || iv == jv { - return Some((i, j, iv)); +/// Find the position of a selected edge adjacent to another selected edge. +fn find_adjacent_pair(d: &[usize], edges: &[(usize, usize)]) -> Option { + let mut incident = std::collections::HashMap::new(); + for (position, &edge) in d.iter().enumerate() { + for vertex in [edges[edge].0, edges[edge].1] { + if let Some(previous) = incident.insert(vertex, position) { + return Some(previous); } } } None } -/// Check whether the edge set `d` (indices into `edges`) dominates every edge -/// of `edges`. An edge `f` is dominated iff `f ∈ d` or `f` shares an endpoint -/// with some edge in `d`. -fn is_edge_dominating_set(d: &[usize], edges: &[(usize, usize)]) -> bool { - // Vertex cover of the candidate EDS. - let mut covered_vertices: std::collections::HashSet = std::collections::HashSet::new(); - for &i in d { - let (u, v) = edges[i]; - covered_vertices.insert(u); - covered_vertices.insert(v); - } - edges.iter().enumerate().all(|(f_idx, (u, v))| { - d.contains(&f_idx) || covered_vertices.contains(u) || covered_vertices.contains(v) - }) -} - -/// Find an edge index in `edges` that is (i) incident to vertex `endpoint`, -/// (ii) different from `excluded_idx`, and (iii) whose other endpoint lies -/// outside `V(d \ {excluded_idx})`. -/// -/// This is the swap candidate `(u, x)` from the Yannakakis-Gavril argument -/// when the drop move is not available for `excluded_idx`. -fn find_swap_edge( - endpoint: usize, - excluded_idx: usize, - d: &[usize], - edges: &[(usize, usize)], -) -> Option { - // Vertex cover of d \ {excluded_idx}. - let mut other_cover: std::collections::HashSet = std::collections::HashSet::new(); - for &i in d { - if i == excluded_idx { - continue; - } - let (u, v) = edges[i]; - other_cover.insert(u); - other_cover.insert(v); - } - for (k, &(u, v)) in edges.iter().enumerate() { - if k == excluded_idx { - continue; - } - let (e_endpoint, other) = if u == endpoint { - (u, v) - } else if v == endpoint { - (v, u) - } else { - continue; - }; - debug_assert_eq!(e_endpoint, endpoint); - if !other_cover.contains(&other) { - return Some(k); - } - } - None -} - #[reduction( transform = exact { num_rows = "num_vertices", diff --git a/src/rules/minimummetricdimension_ilp.rs b/src/rules/minimummetricdimension_ilp.rs index a10a1e9a5..645e86ad8 100644 --- a/src/rules/minimummetricdimension_ilp.rs +++ b/src/rules/minimummetricdimension_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::minimum_metric_dimension::bfs_distances; use crate::models::graph::MinimumMetricDimension; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MinimumMetricDimension to ILP. @@ -38,13 +39,14 @@ impl ReductionResult for ReductionMDToILP { /// /// Since the mapping is 1:1 (each vertex maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/minimummultiwaycut_ilp.rs b/src/rules/minimummultiwaycut_ilp.rs index 51c157007..e6108e162 100644 --- a/src/rules/minimummultiwaycut_ilp.rs +++ b/src/rules/minimummultiwaycut_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumMultiwayCut; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MinimumMultiwayCut to ILP. @@ -42,12 +43,22 @@ impl ReductionResult for ReductionMMCToILP { /// Extract solution from ILP back to MinimumMultiwayCut. /// /// For each edge e, source config[e] = target_solution[k*n + e] (the x_e variable). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMMCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let offset = self.k * self.n; (0..self.m) diff --git a/src/rules/minimummultiwaycut_qubo.rs b/src/rules/minimummultiwaycut_qubo.rs index 8679c2285..96d6a39e9 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 during +//! extraction: deleting them improves the objective and preserves separation. //! //! Reference: Heidari, Dinneen & Delmas (2022). @@ -15,6 +16,8 @@ use crate::models::algebraic::QUBO; use crate::models::graph::MinimumMultiwayCut; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MinimumMultiwayCut to QUBO. @@ -24,6 +27,7 @@ pub struct ReductionMinimumMultiwayCutToQUBO { num_vertices: usize, num_terminals: usize, edges: Vec<(usize, usize)>, + negative_edges: Vec, } impl ReductionResult for ReductionMinimumMultiwayCutToQUBO { @@ -34,38 +38,48 @@ impl ReductionResult for ReductionMinimumMultiwayCutToQUBO { &self.target } - /// Decode one-hot assignment: for each vertex find its terminal, then - /// for each edge check if endpoints are in different terminals. - fn extract_solution( + /// Map an optimal target assignment to an optimal edge deletion set. + /// The penalty guarantees one-hot, terminal-pinned assignments at every + /// optimum. All source instances are feasible by deleting every edge. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - let k = self.num_terminals; - let n = self.num_vertices; - - // For each vertex, find which terminal position it is assigned to - let assignments: Vec = (0..n) - .map(|vertex| { - let mut selected = - (0..k).filter(|&terminal| target_solution[vertex * k + terminal]); - match (selected.next(), selected.next()) { - (Some(terminal), None) => Ok(terminal), - _ => Err(crate::rules::ExtractionError::invalid(format!( - "vertex {vertex} does not have exactly one terminal assignment" - ))), - } - }) - .collect::>()?; - - // For each edge, output 1 (cut) if endpoints differ, 0 (keep) otherwise - self.edges - .iter() - .map(|&(u, v)| assignments[u] != assignments[v]) - .collect() - }) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::optimal(source, solution)?) + } + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } + } +} + +impl ReductionMinimumMultiwayCutToQUBO { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + let k = self.num_terminals; + let assignments: Vec = (0..self.num_vertices) + .map(|vertex| { + (0..k) + .find(|&terminal| target_solution[vertex * k + terminal]) + .unwrap() + }) + .collect(); + Ok(self + .edges + .iter() + .zip(&self.negative_edges) + .map(|(&(u, v), &negative)| negative || assignments[u] != assignments[v]) + .collect()) } } @@ -91,26 +105,24 @@ impl ReduceTo> for MinimumMultiwayCut { .checked_mul(k) .ok_or_else(|| overflow("computing the number of QUBO variables"))?; - // Penalty: sum of all edge weights + 1 + // Nonnegative cut costs cannot reward invalid assignments. A feasible + // pinned partition costs at most their sum, below one penalty unit. 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) .ok_or_else(|| overflow("computing the partition penalty"))?; - let mut matrix = vec![vec![0i64; nq]; nq]; + let mut matrix = vec![std::collections::BTreeMap::new(); nq]; // Helper: add value to upper-triangular position let mut add_upper = |i: usize, j: usize, val: i64| { let (lo, hi) = if i <= j { (i, j) } else { (j, i) }; - matrix[lo][hi] = matrix[lo][hi] + let coefficient = matrix[lo].entry(hi).or_insert(0i64); + *coefficient = coefficient .checked_add(val) .ok_or_else(|| overflow("adding a multiway-cut QUBO coefficient"))?; Ok::<(), crate::rules::ReductionError>(()) @@ -158,7 +170,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 { @@ -169,15 +181,12 @@ impl ReduceTo> for MinimumMultiwayCut { } Ok(ReductionMinimumMultiwayCutToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { - crate::rules::ReductionError::construction::< - MinimumMultiwayCut, - QUBO, - >(message) - })?, + target: QUBO::from_rows(matrix) + .map_err(>>::target_construction)?, num_vertices: n, num_terminals: k, edges, + negative_edges: edge_weights.iter().map(|&weight| weight < 0).collect(), }) } } diff --git a/src/rules/minimumsetcovering_ilp.rs b/src/rules/minimumsetcovering_ilp.rs index 6f208be75..ca95faa05 100644 --- a/src/rules/minimumsetcovering_ilp.rs +++ b/src/rules/minimumsetcovering_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::MinimumSetCovering; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MinimumSetCovering to ILP. /// @@ -33,13 +34,14 @@ impl ReductionResult for ReductionSCToILP { /// /// Since the mapping is 1:1 (each set maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/minimumsummulticenter_ilp.rs b/src/rules/minimumsummulticenter_ilp.rs index bb6f82fd6..c30ffaf86 100644 --- a/src/rules/minimumsummulticenter_ilp.rs +++ b/src/rules/minimumsummulticenter_ilp.rs @@ -23,7 +23,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinimumSumMulticenter; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MinimumSumMulticenter to ILP. @@ -41,12 +42,22 @@ impl ReductionResult for ReductionMSMCToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMSMCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/minimumtardinesssequencing_ilp.rs b/src/rules/minimumtardinesssequencing_ilp.rs index e8533153d..63649c3e1 100644 --- a/src/rules/minimumtardinesssequencing_ilp.rs +++ b/src/rules/minimumtardinesssequencing_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::MinimumTardinessSequencing; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::types::One; /// Result of reducing MinimumTardinessSequencing to `ILP`. @@ -26,16 +27,26 @@ impl ReductionResult for ReductionMTSToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMTSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_tasks; - one_hot_decode(target_solution, n, n, 0)? + one_hot_decode(target_solution, n, n, 0) }) } } @@ -55,16 +66,26 @@ impl ReductionResult for ReductionMTSWeightedToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMTSWeightedToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_tasks; - one_hot_decode(target_solution, n, n, 0)? + one_hot_decode(target_solution, n, n, 0) }) } } diff --git a/src/rules/minimumvertexcover_comparativecontainment.rs b/src/rules/minimumvertexcover_comparativecontainment.rs index 4e08fc68a..38f3f767f 100644 --- a/src/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/rules/minimumvertexcover_comparativecontainment.rs @@ -13,7 +13,8 @@ use crate::models::decision::Decision; use crate::models::graph::MinimumVertexCover; use crate::models::set::ComparativeContainment; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Identity witness map for the signed-weight containment construction. @@ -30,18 +31,12 @@ impl ReductionResult for ReductionDecisionMVCToComparativeContainment { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } - Ok(target_solution.clone()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/minimumvertexcover_ensemblecomputation.rs b/src/rules/minimumvertexcover_ensemblecomputation.rs index 710badf1f..d3a3540e4 100644 --- a/src/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/rules/minimumvertexcover_ensemblecomputation.rs @@ -15,7 +15,8 @@ use crate::models::graph::MinimumVertexCover; use crate::models::misc::EnsembleComputation; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::One; @@ -40,22 +41,26 @@ impl ReductionResult for ReductionVCToEC { /// its edge. An L-step program yields at most L minus the number of /// distinct required triples; loops instead require endpoint pairs. /// This applies to arbitrary programs, without a normal-form assumption. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - let value = - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let crate::types::Min(Some(length)) = value else { - return Err(crate::rules::ExtractionError::invalid( - "target configuration does not encode a valid ensemble computation", - )); - }; - let meaningful_steps = usize::try_from(length).map_err(|_| { - crate::rules::ExtractionError::invalid( - "ensemble operation count cannot be represented as usize", - ) - })?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionVCToEC { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + let value = crate::traits::Problem::evaluate(self.target_problem(), target_solution)?; + // Evaluation supplies the meaningful prefix, which the mapping needs. + // The target witness premise already guarantees a feasible program. + let meaningful_steps = value.0.unwrap() as usize; let mut cover = vec![false; self.num_vertices]; let universe_size = self.target.universe_size(); for &[left, right] in target_solution diff --git a/src/rules/minimumvertexcover_longestcommonsubsequence.rs b/src/rules/minimumvertexcover_longestcommonsubsequence.rs index e2f0a23a8..775f99efd 100644 --- a/src/rules/minimumvertexcover_longestcommonsubsequence.rs +++ b/src/rules/minimumvertexcover_longestcommonsubsequence.rs @@ -3,7 +3,8 @@ use crate::models::graph::MinimumVertexCover; use crate::models::misc::LongestCommonSubsequence; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::One; @@ -21,12 +22,22 @@ impl ReductionResult for ReductionVCToLCS { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionVCToLCS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let mut cover = vec![true; self.num_vertices]; for &symbol in target_solution { diff --git a/src/rules/minimumvertexcover_maximumindependentset.rs b/src/rules/minimumvertexcover_maximumindependentset.rs index 5c43127d7..6fdebd7b6 100644 --- a/src/rules/minimumvertexcover_maximumindependentset.rs +++ b/src/rules/minimumvertexcover_maximumindependentset.rs @@ -4,7 +4,8 @@ use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::WeightElement; @@ -27,13 +28,14 @@ where /// Solution extraction: complement the configuration. /// If v is in the independent set (1), it's NOT in the vertex cover (0). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&x| !x).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&x| !x).collect()) + }) } } @@ -73,13 +75,14 @@ where } /// Solution extraction: complement the configuration. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&x| !x).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&x| !x).collect()) + }) } } diff --git a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs index ef8f39d10..0542efef5 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -11,6 +11,8 @@ use crate::models::graph::{MinimumFeedbackArcSet, MinimumVertexCover}; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; use crate::topology::{DirectedGraph, Graph, SimpleGraph}; /// Result of reducing MinimumVertexCover to MinimumFeedbackArcSet. @@ -30,14 +32,24 @@ impl ReductionResult for ReductionVCToFAS { } /// Extract solution: internal arcs are at positions 0..n in the FAS config. - /// If internal arc i is in the FAS (config[i] = 1), vertex i is in the cover. - fn extract_solution( + /// If internal arc i is in the FAS, vertex i is in the cover. + /// Only optimal target results qualify: a feasible incumbent may remove + /// crossing arcs, whose removal does not select any source vertex. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution[..self.num_source_vertices].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => { + let solution = solution[..self.num_source_vertices].to_vec(); + Ok(SolveOutcome::optimal(source, solution)?) + } + SolveOutcome::Feasible { .. } => { + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + } + } } } @@ -122,7 +134,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, diff --git a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs index d5ed67e8a..88a2b373d 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs @@ -5,7 +5,8 @@ use crate::models::graph::{MinimumFeedbackVertexSet, MinimumVertexCover}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{DirectedGraph, Graph, SimpleGraph}; use crate::types::WeightElement; @@ -26,13 +27,12 @@ where &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/minimumvertexcover_minimumhittingset.rs b/src/rules/minimumvertexcover_minimumhittingset.rs index 656cbcfd6..61e19ece6 100644 --- a/src/rules/minimumvertexcover_minimumhittingset.rs +++ b/src/rules/minimumvertexcover_minimumhittingset.rs @@ -6,7 +6,8 @@ use crate::models::graph::MinimumVertexCover; use crate::models::set::MinimumHittingSet; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::One; @@ -26,13 +27,12 @@ impl ReductionResult for ReductionVCToHS { /// Solution extraction: variables correspond 1:1. /// Element i in the hitting set corresponds to vertex i in the vertex cover. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/minimumvertexcover_minimummaximalmatching.rs b/src/rules/minimumvertexcover_minimummaximalmatching.rs index 06819a13b..3d2f015cd 100644 --- a/src/rules/minimumvertexcover_minimummaximalmatching.rs +++ b/src/rules/minimumvertexcover_minimummaximalmatching.rs @@ -30,7 +30,6 @@ inventory::submit! { }, module_path: module_path!(), reduce_fn: None, - reduce_aggregate_fn: None, turing: false, } } diff --git a/src/rules/minimumvertexcover_minimumsetcovering.rs b/src/rules/minimumvertexcover_minimumsetcovering.rs index 4a7baceca..6322f7bbe 100644 --- a/src/rules/minimumvertexcover_minimumsetcovering.rs +++ b/src/rules/minimumvertexcover_minimumsetcovering.rs @@ -6,7 +6,8 @@ use crate::models::graph::MinimumVertexCover; use crate::models::set::MinimumSetCovering; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::WeightElement; @@ -29,13 +30,12 @@ where /// Solution extraction: variables correspond 1:1. /// Vertex i in VC corresponds to set i in SC. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/rules/minimumvertexcover_minimumweightandorgraph.rs index 247ce8733..4dd28ebe3 100644 --- a/src/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -3,7 +3,8 @@ use crate::models::graph::MinimumVertexCover; use crate::models::misc::MinimumWeightAndOrGraph; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::Graph; use crate::topology::SimpleGraph; @@ -12,7 +13,7 @@ use crate::topology::SimpleGraph; pub struct ReductionVCToAndOrGraph { target: MinimumWeightAndOrGraph, sink_arc_start: usize, - num_source_vertices: usize, + forced_cover: Vec, } impl ReductionResult for ReductionVCToAndOrGraph { @@ -23,22 +24,24 @@ impl ReductionResult for ReductionVCToAndOrGraph { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - (0..self.num_source_vertices) - .map(|j| target_solution[self.sink_arc_start + j]) - .collect() + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(self + .forced_cover + .iter() + .enumerate() + .map(|(j, &forced)| forced || solution[self.sink_arc_start + j]) + .collect()) }) } } #[reduction( - transform = exact { + transform = upper_bound { num_vertices = "1 + num_edges + 2 * num_vertices", num_arcs = "3 * num_edges + num_vertices", } @@ -48,7 +51,15 @@ impl ReduceTo for MinimumVertexCover fn reduce_to(&self) -> Result { let n = self.graph().num_vertices(); - let edges = self.graph().edges(); + // Adding a negative-weight vertex preserves coverage and strictly + // lowers cost, so every optimum contains all such vertices. + let forced_cover: Vec<_> = self.weights().iter().map(|&w| w < 0).collect(); + let edges: Vec<_> = self + .graph() + .edges() + .into_iter() + .filter(|&(u, v)| !forced_cover[u] && !forced_cover[v]) + .collect(); let m = edges.len(); let num_target_vertices = 1 + m + (2 * n); @@ -80,7 +91,7 @@ impl ReduceTo for MinimumVertexCover let sink_arc_start = arcs.len(); for (j, &weight) in self.weights().iter().enumerate() { arcs.push((cover_vertex(j), sink_vertex(j))); - arc_weights.push(weight); + arc_weights.push(weight.max(0)); } let target = @@ -89,7 +100,7 @@ impl ReduceTo for MinimumVertexCover Ok(ReductionVCToAndOrGraph { target, sink_arc_start, - num_source_vertices: n, + forced_cover, }) } } diff --git a/src/rules/minimumweightdecoding_ilp.rs b/src/rules/minimumweightdecoding_ilp.rs index 970470c71..94403b0ee 100644 --- a/src/rules/minimumweightdecoding_ilp.rs +++ b/src/rules/minimumweightdecoding_ilp.rs @@ -18,7 +18,8 @@ use crate::models::algebraic::MinimumWeightDecoding; use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MinimumWeightDecoding to `ILP`. /// @@ -40,12 +41,22 @@ impl ReductionResult for ReductionMinimumWeightDecodingToILP { } /// Extract the source solution: first m variables are the binary x_j values. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMinimumWeightDecodingToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_cols] .iter() .map(|&value| value == 1) diff --git a/src/rules/minmaxmulticenter_ilp.rs b/src/rules/minmaxmulticenter_ilp.rs index f4360ac63..d9569c159 100644 --- a/src/rules/minmaxmulticenter_ilp.rs +++ b/src/rules/minmaxmulticenter_ilp.rs @@ -27,7 +27,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MinMaxMulticenter; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing MinMaxMulticenter to ILP. @@ -45,12 +46,22 @@ impl ReductionResult for ReductionMMCToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMMCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/mixedchinesepostman_ilp.rs b/src/rules/mixedchinesepostman_ilp.rs index 88da8843a..c47301065 100644 --- a/src/rules/mixedchinesepostman_ilp.rs +++ b/src/rules/mixedchinesepostman_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MixedChinesePostman; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::types::WeightElement; /// Result of reducing MixedChinesePostman to ILP. @@ -26,12 +27,22 @@ impl ReductionResult for ReductionMCPToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMCPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Return the orientation bits d_k in source edge order target_solution[..self.num_undirected_edges] diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 2b7d66b7c..fcac87bdb 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -11,12 +11,12 @@ pub(crate) mod bicliquecover_bmf; pub(crate) mod bmf_bicliquecover; pub(crate) mod circuit_sat; pub(crate) mod circuit_spinglass; -mod closestvectorproblem_casts; mod closestvectorproblem_qubo; pub(crate) mod coloring_qubo; pub(crate) mod decisionmaximumindependentset_integralflowbundles; pub(crate) mod decisionminimumdominatingset_minimumsummulticenter; pub(crate) mod decisionminimumdominatingset_minmaxmulticenter; +mod decisionminimumvertexcover_casts; pub(crate) mod decisionminimumvertexcover_hamiltoniancircuit; pub(crate) mod ensemblecomputation_ilp; pub(crate) mod exactcoverby3sets_algebraicequationsovergf2; @@ -43,8 +43,8 @@ pub(crate) mod hamiltoniancircuit_travelingsalesman; pub(crate) mod hamiltonianpath_degreeconstrainedspanningtree; pub(crate) mod hamiltonianpath_isomorphicspanningtree; pub(crate) mod hamiltonianpathbetweentwovertices_longestpath; -pub(crate) mod ilp_casts; pub(crate) mod ilp_i64_ilp_bool; +pub(crate) mod ilp_i64_ilp_f64; pub(crate) mod integerknapsack_ilp; pub(crate) mod kclique_balancedcompletebipartitesubgraph; pub(crate) mod kclique_conjunctivebooleanquery; @@ -64,7 +64,6 @@ pub(crate) mod ksatisfiability_directedtwocommodityintegralflow; pub(crate) mod ksatisfiability_feasibleregisterassignment; pub(crate) mod ksatisfiability_kclique; pub(crate) mod ksatisfiability_kernel; -pub(crate) mod ksatisfiability_minimumvertexcover; pub(crate) mod ksatisfiability_monochromatictriangle; pub(crate) mod ksatisfiability_oneinthreesatisfiability; pub(crate) mod ksatisfiability_preemptivescheduling; @@ -271,7 +270,6 @@ pub(crate) mod shortestweightconstrainedpath_ilp; pub(crate) mod sparsematrixcompression_ilp; pub(crate) mod stackercrane_ilp; pub(crate) mod steinertree_ilp; -pub(crate) mod steinertreeingraphs_ilp; pub(crate) mod stringtostringcorrection_ilp; pub(crate) mod strongconnectivityaugmentation_ilp; pub(crate) mod subgraphisomorphism_ilp; @@ -285,14 +283,14 @@ pub(crate) mod undirectedtwocommodityintegralflow_ilp; #[cfg(test)] pub(crate) use graph::ReductionEdgeData; pub use graph::{ - AggregateReductionChain, ExecutePathsError, ExecutedPath, NeighborInfo, NeighborTree, - PathParameterError, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionMode, - ReductionPath, ReductionStep, TraversalFlow, + ExecutePathsError, ExecutedPath, NeighborInfo, NeighborTree, PathParameterError, + ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, + TraversalFlow, }; -pub(crate) use traits::{validate_target_solution, DynReductionResult}; +pub(crate) use traits::DynReductionResult; pub use traits::{ - AggregateReductionResult, ExtractionError, ExtractionResult, ReduceTo, ReduceToAggregate, - ReductionError, ReductionResult, VariantReductionResult, + ExtractionError, ExtractionResult, ReduceTo, ReductionError, ReductionResult, + VariantReductionResult, }; #[cfg(feature = "example-db")] @@ -347,7 +345,6 @@ pub(crate) fn canonical_rule_example_specs() -> Vec 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..2f010ff70 100644 --- a/src/rules/monochromatictriangle_ilp.rs +++ b/src/rules/monochromatictriangle_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MonochromaticTriangle; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use std::collections::HashMap; @@ -25,13 +26,14 @@ impl ReductionResult for ReductionMonochromaticTriangleToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/multiplechoicebranching_ilp.rs b/src/rules/multiplechoicebranching_ilp.rs index cba6f76cd..bf198c17a 100644 --- a/src/rules/multiplechoicebranching_ilp.rs +++ b/src/rules/multiplechoicebranching_ilp.rs @@ -3,7 +3,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MultipleChoiceBranching; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionMultipleChoiceBranchingToILP { @@ -19,11 +20,22 @@ impl ReductionResult for ReductionMultipleChoiceBranchingToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionMultipleChoiceBranchingToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_arcs] .iter() .map(|&selected| selected == 1) diff --git a/src/rules/multiplecopyfileallocation_ilp.rs b/src/rules/multiplecopyfileallocation_ilp.rs index 335aa7a91..b5b982a86 100644 --- a/src/rules/multiplecopyfileallocation_ilp.rs +++ b/src/rules/multiplecopyfileallocation_ilp.rs @@ -17,7 +17,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::MultipleCopyFileAllocation; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use std::collections::VecDeque; @@ -36,12 +37,22 @@ impl ReductionResult for ReductionMCFAToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionMCFAToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_vertices] .iter() .map(|&value| value == 1) diff --git a/src/rules/multiprocessorscheduling_ilp.rs b/src/rules/multiprocessorscheduling_ilp.rs index c3d8ed3cd..23a59a005 100644 --- a/src/rules/multiprocessorscheduling_ilp.rs +++ b/src/rules/multiprocessorscheduling_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::MultiprocessorScheduling; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing MultiprocessorScheduling to ILP. /// @@ -33,18 +34,28 @@ impl ReductionResult for ReductionMSToILP { } /// Extract solution: for each task j, find the unique processor p where x_{j,p} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionMSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_tasks, self.num_processors, 0, - ) + )) } } diff --git a/src/rules/naesatisfiability_ilp.rs b/src/rules/naesatisfiability_ilp.rs index 1c57fc3a5..9472c3910 100644 --- a/src/rules/naesatisfiability_ilp.rs +++ b/src/rules/naesatisfiability_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::formula::NAESatisfiability; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionNAESATToILP { @@ -26,13 +27,14 @@ impl ReductionResult for ReductionNAESATToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/naesatisfiability_maxcut.rs b/src/rules/naesatisfiability_maxcut.rs index 55e8e81c7..9455f32af 100644 --- a/src/rules/naesatisfiability_maxcut.rs +++ b/src/rules/naesatisfiability_maxcut.rs @@ -11,23 +11,24 @@ //! 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; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 @@ -38,18 +39,22 @@ impl ReductionResult for ReductionNAESATToMaxCut { /// Variable x_i is assigned based on vertex 2*i: if it is in set 0 /// (config[2*i] == 0), set x_i = false (config value 0); if in set 1, /// set x_i = true (config value 1). - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionNAESATToMaxCut { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ (0..self.source_num_vars) .map(|i| target_solution[2 * i]) @@ -58,28 +63,16 @@ 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)) - } -} - /// Dimensions, variable-edge weight, and certificate for legal clause lengths. fn nae_maxcut_parameters( n: usize, 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 +137,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 +165,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 +192,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 +220,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..968f47b14 100644 --- a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -2,12 +2,14 @@ //! //! 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 +//! 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; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; #[derive(Debug, Clone, Copy)] @@ -39,6 +41,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, @@ -65,18 +70,19 @@ impl ReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - self.layout + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(self + .layout .variables .iter() - .map(|variable| target_solution[variable.t] == 0) - .collect() + .take(self.layout.source_num_vars) + .map(|variable| solution[variable.t] == 0) + .collect()) }) } } @@ -86,12 +92,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,29 +183,36 @@ 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 num_vars = 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] { + num_vars += 1; + let auxiliary = i64::try_from(num_vars).map_err(|_| { + crate::registry::ConstructionError::from("auxiliary literal index exceeds i64") + })?; + #[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 mut next_vertex = 0usize; @@ -299,6 +325,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, @@ -312,9 +341,9 @@ fn build_layout( } #[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 +351,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..fc52aec65 100644 --- a/src/rules/naesatisfiability_setsplitting.rs +++ b/src/rules/naesatisfiability_setsplitting.rs @@ -9,7 +9,8 @@ use crate::models::formula::NAESatisfiability; use crate::models::set::SetSplitting; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionNAESATToSetSplitting { @@ -25,13 +26,14 @@ impl ReductionResult for ReductionNAESATToSetSplitting { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution[..self.num_source_variables].to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.num_source_variables].to_vec()) + }) } } diff --git a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 30729b498..aa17d447b 100644 --- a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -7,15 +7,13 @@ use crate::models::misc::{Numerical3DimensionalMatching, NumericalMatchingWithTargetSums}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; -use std::collections::BTreeMap; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Numerical3DimensionalMatching to NumericalMatchingWithTargetSums. #[derive(Debug, Clone)] pub struct ReductionN3DMToNMTS { target: NumericalMatchingWithTargetSums, - source_sizes_w: Vec, - source_bound: i64, } impl ReductionResult for ReductionN3DMToNMTS { @@ -26,43 +24,39 @@ impl ReductionResult for ReductionN3DMToNMTS { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionN3DMToNMTS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ - let mut x_indices_by_pair_sum: BTreeMap> = BTreeMap::new(); - for (x_index, &y_index) in target_solution.iter().enumerate() { - let pair_sum = self.target.sizes_x()[x_index] - .checked_add(self.target.sizes_y()[y_index]) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "target pair sum overflows the target numeric domain", - ) - })?; - x_indices_by_pair_sum - .entry(pair_sum) - .or_default() - .push(x_index); - } + let mut pairs: Vec<_> = target_solution + .iter() + .enumerate() + .map(|(x, &y)| (self.target.sizes_x()[x] + self.target.sizes_y()[y], x, y)) + .collect(); + let mut targets: Vec<_> = self.target.targets().iter().copied().enumerate().collect(); + pairs.sort_unstable(); + targets.sort_unstable_by_key(|&(w, sum)| (sum, w)); - let mut x_perm = Vec::with_capacity(self.source_sizes_w.len()); - let mut y_perm = Vec::with_capacity(self.source_sizes_w.len()); - for &w_size in &self.source_sizes_w { - let target_sum = checked_target_sum(self.source_bound, w_size) - .map_err(crate::rules::ExtractionError::invalid)?; - let x_index = x_indices_by_pair_sum - .get_mut(&target_sum) - .and_then(Vec::pop) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "target matching does not realize required pair sum {target_sum}" - )) - })?; - x_perm.push(x_index); - y_perm.push(target_solution[x_index]); + // Feasibility equates the pair-sum and target multisets. Target + // index w retains the source W order from construction. + let mut x_perm = vec![0; targets.len()]; + let mut y_perm = vec![0; targets.len()]; + for ((w, _), (_, x, y)) in targets.into_iter().zip(pairs) { + x_perm[w] = x; + y_perm[w] = y; } x_perm.extend(y_perm); @@ -102,11 +96,7 @@ impl ReduceTo for Numerical3DimensionalMatching .map_err(map_error)?, ); - Ok(ReductionN3DMToNMTS { - target, - source_sizes_w: self.sizes_w().to_vec(), - source_bound: self.bound(), - }) + Ok(ReductionN3DMToNMTS { target }) } } diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index 3f658a8bf..20f98715f 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -14,7 +14,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::NumericalMatchingWithTargetSums; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// A compatible triple (i, j, k) where s(x_i) + s(y_j) = B_k. #[derive(Debug, Clone)] @@ -44,12 +45,22 @@ impl ReductionResult for ReductionNMTSToILP { } /// Extract solution: for each x_i find the y_j it is paired with. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionNMTSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let mut assignment = vec![0usize; self.m]; for (var_idx, triple) in self.triples.iter().enumerate() { diff --git a/src/rules/openshopscheduling_ilp.rs b/src/rules/openshopscheduling_ilp.rs index 41f385661..31acc1a07 100644 --- a/src/rules/openshopscheduling_ilp.rs +++ b/src/rules/openshopscheduling_ilp.rs @@ -29,7 +29,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::OpenShopScheduling; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing OpenShopScheduling to `ILP`. /// @@ -87,11 +88,22 @@ impl ReductionResult for ReductionOSSToILP { } /// Extract the job-major operation start times from the ILP solution. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionOSSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::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]) diff --git a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 1efcd0b0b..f290e4382 100644 --- a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::ConsecutiveOnesMatrixAugmentation; use crate::models::decision::Decision; use crate::models::graph::OptimalLinearArrangement; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// The target incidence matrix, or a fixed infeasible matrix when the source @@ -26,17 +27,22 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionOptimalLinearArrangementToConsecutiveOnesMatrixAugmentation { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { // 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. diff --git a/src/rules/optimallineararrangement_ilp.rs b/src/rules/optimallineararrangement_ilp.rs index 934dd35b1..b04fbe53d 100644 --- a/src/rules/optimallineararrangement_ilp.rs +++ b/src/rules/optimallineararrangement_ilp.rs @@ -10,7 +10,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::OptimalLinearArrangement; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing OptimalLinearArrangement to ILP. @@ -34,18 +35,28 @@ impl ReductionResult for ReductionOLAToILP { } /// Extract: for each vertex v, output its position p (the unique p with x_{v,p} = 1). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionOLAToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_vertices, self.num_vertices, 0, - ) + )) } } diff --git a/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs b/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs index e7a14a680..16cff5e91 100644 --- a/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs +++ b/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs @@ -14,7 +14,8 @@ use crate::models::graph::OptimalLinearArrangement; use crate::models::misc::SequencingToMinimizeWeightedCompletionTime; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing OptimalLinearArrangement to SequencingToMinimizeWeightedCompletionTime. @@ -32,12 +33,22 @@ impl ReductionResult for ReductionOLAToSequencingToMinimizeWeightedCompletionTim &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionOLAToSequencingToMinimizeWeightedCompletionTime { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let mut arrangement = vec![0usize; self.num_vertices]; let mut next_position = 0usize; @@ -117,6 +128,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionOptimumCommunicationSpanningTreeToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_edges] .iter() .map(|&value| value == 1) diff --git a/src/rules/paintshop_ilp.rs b/src/rules/paintshop_ilp.rs index b5f455827..fd7993092 100644 --- a/src/rules/paintshop_ilp.rs +++ b/src/rules/paintshop_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::PaintShop; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionPaintShopToILP { @@ -24,12 +25,22 @@ impl ReductionResult for ReductionPaintShopToILP { } /// Extract first-occurrence color bits (x_i) from ILP solution. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPaintShopToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_cars] .iter() .map(|&value| value == 1) @@ -113,6 +124,7 @@ impl ReduceTo> for PaintShop { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "paintshop_to_ilp", build: || { @@ -126,7 +138,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/paintshop_qubo.rs b/src/rules/paintshop_qubo.rs index b8a05456c..3e6ea0eff 100644 --- a/src/rules/paintshop_qubo.rs +++ b/src/rules/paintshop_qubo.rs @@ -11,6 +11,8 @@ use crate::models::algebraic::QUBO; use crate::models::misc::PaintShop; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; /// Result of reducing PaintShop to QUBO. #[derive(Debug, Clone)] @@ -28,13 +30,18 @@ impl ReductionResult for ReductionPaintShopToQUBO { /// The QUBO solution maps directly back: car i's first occurrence gets /// color x_i, second gets 1 - x_i. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => Ok(SolveOutcome::optimal(source, solution)?), + SolveOutcome::Feasible { solution, .. } => { + Ok(SolveOutcome::feasible(source, solution)?) + } + } } } @@ -50,7 +57,7 @@ impl ReduceTo> for PaintShop { let is_first = self.is_first(); let seq_len = seq.len(); - let mut matrix = vec![vec![0i64; n]; n]; + let mut matrix = vec![std::collections::BTreeMap::new(); n]; let overflow = |operation| { crate::rules::ReductionError::integer_overflow::>(operation) }; @@ -74,32 +81,38 @@ impl ReduceTo> for PaintShop { if parity_a == parity_b { // Same parity: color change when x_a != x_b // Contribution: +1 to Q[a][a], +1 to Q[b][b], -2 to Q[lo][hi] - matrix[a][a] = matrix[a][a] + let coefficient = matrix[a].entry(a).or_insert(0i64); + *coefficient = coefficient .checked_add(1) .ok_or_else(|| overflow("adding a PaintShop diagonal coefficient"))?; - matrix[b][b] = matrix[b][b] + let coefficient = matrix[b].entry(b).or_insert(0i64); + *coefficient = coefficient .checked_add(1) .ok_or_else(|| overflow("adding a PaintShop diagonal coefficient"))?; - matrix[lo][hi] = matrix[lo][hi] + let coefficient = matrix[lo].entry(hi).or_insert(0i64); + *coefficient = coefficient .checked_sub(2) .ok_or_else(|| overflow("adding a PaintShop interaction coefficient"))?; } else { // Different parity: color change when x_a == x_b // Contribution: -1 to Q[a][a], -1 to Q[b][b], +2 to Q[lo][hi] - matrix[a][a] = matrix[a][a] + let coefficient = matrix[a].entry(a).or_insert(0i64); + *coefficient = coefficient .checked_sub(1) .ok_or_else(|| overflow("adding a PaintShop diagonal coefficient"))?; - matrix[b][b] = matrix[b][b] + let coefficient = matrix[b].entry(b).or_insert(0i64); + *coefficient = coefficient .checked_sub(1) .ok_or_else(|| overflow("adding a PaintShop diagonal coefficient"))?; - matrix[lo][hi] = matrix[lo][hi] + let coefficient = matrix[lo].entry(hi).or_insert(0i64); + *coefficient = coefficient .checked_add(2) .ok_or_else(|| overflow("adding a PaintShop interaction coefficient"))?; } } Ok(ReductionPaintShopToQUBO { - target: QUBO::from_matrix(matrix).map_err(|message| { + target: QUBO::from_rows(matrix).map_err(|message| { crate::rules::ReductionError::construction::>(message) })?, }) diff --git a/src/rules/partiallyorderedknapsack_ilp.rs b/src/rules/partiallyorderedknapsack_ilp.rs index ecfef47b6..01b546c3a 100644 --- a/src/rules/partiallyorderedknapsack_ilp.rs +++ b/src/rules/partiallyorderedknapsack_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::PartiallyOrderedKnapsack; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionPOKToILP { @@ -21,13 +22,14 @@ impl ReductionResult for ReductionPOKToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/partition_binpacking.rs b/src/rules/partition_binpacking.rs index 20301e8a5..709705203 100644 --- a/src/rules/partition_binpacking.rs +++ b/src/rules/partition_binpacking.rs @@ -14,7 +14,8 @@ use crate::models::misc::{BinPacking, Partition}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Partition to BinPacking. #[derive(Debug, Clone)] @@ -30,12 +31,25 @@ impl ReductionResult for ReductionPartitionToBinPacking { &self.target } - fn extract_solution( + /// A balanced partition packs into two bins, and no packing uses fewer, so every + /// optimum then fills two bins of capacity `S/2` to exactly `S/2` each; any other + /// optimum proves NO. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPartitionToBinPacking { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // BinPacking may use any bin indices (0..n-1). Remap the two distinct // bins used in a 2-bin packing to Partition's {0, 1} assignment. diff --git a/src/rules/partition_cosineproductintegration.rs b/src/rules/partition_cosineproductintegration.rs index 1698a5334..a22c8e5e6 100644 --- a/src/rules/partition_cosineproductintegration.rs +++ b/src/rules/partition_cosineproductintegration.rs @@ -12,7 +12,8 @@ use crate::models::misc::{CosineProductIntegration, Partition}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Partition to CosineProductIntegration. #[derive(Debug, Clone)] @@ -28,13 +29,12 @@ impl ReductionResult for ReductionPartitionToCPI { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/partition_integralflowwithmultipliers.rs b/src/rules/partition_integralflowwithmultipliers.rs index 8c1a96391..de9313f9e 100644 --- a/src/rules/partition_integralflowwithmultipliers.rs +++ b/src/rules/partition_integralflowwithmultipliers.rs @@ -8,14 +8,15 @@ use crate::models::graph::IntegralFlowWithMultipliers; use crate::models::misc::Partition; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::DirectedGraph; /// Result of reducing Partition to IntegralFlowWithMultipliers. #[derive(Debug, Clone)] pub struct ReductionPartitionToIntegralFlowWithMultipliers { target: IntegralFlowWithMultipliers, - item_arc_count: Option, + item_arc_count: usize, } impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { @@ -26,19 +27,24 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionPartitionToIntegralFlowWithMultipliers { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ - let item_arc_count = self.item_arc_count.ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "the fixed infeasible target instance has no extractable witness", - ) - })?; - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - target_solution[..item_arc_count] + target_solution[..self.item_arc_count] .iter() .map(|&flow| flow > 0) .collect() @@ -67,7 +73,7 @@ impl ReduceTo for Partition { let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); return Ok(ReductionPartitionToIntegralFlowWithMultipliers { target: IntegralFlowWithMultipliers::new(graph, 0, 2, vec![1, 2, 1], vec![1, 1], 1), - item_arc_count: None, + item_arc_count: source_n, }); } @@ -106,7 +112,7 @@ impl ReduceTo for Partition { capacities, half_sum, ), - item_arc_count: Some(source_n), + item_arc_count: source_n, }) } } diff --git a/src/rules/partition_knapsack.rs b/src/rules/partition_knapsack.rs index 6ea901cca..6f547fa93 100644 --- a/src/rules/partition_knapsack.rs +++ b/src/rules/partition_knapsack.rs @@ -2,7 +2,8 @@ use crate::models::misc::{Knapsack, Partition}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Partition to Knapsack. #[derive(Debug, Clone)] @@ -18,12 +19,24 @@ impl ReductionResult for ReductionPartitionToKnapsack { &self.target } - fn extract_solution( + /// A balanced partition fills the capacity `S/2`, so every Knapsack optimum then has + /// value `S/2` and is itself a balanced half; an optimum below `S/2` proves NO. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPartitionToKnapsack { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_multiprocessorscheduling.rs b/src/rules/partition_multiprocessorscheduling.rs index 9e35ce1d2..f77c5c35b 100644 --- a/src/rules/partition_multiprocessorscheduling.rs +++ b/src/rules/partition_multiprocessorscheduling.rs @@ -14,7 +14,8 @@ use crate::models::misc::{MultiprocessorScheduling, Partition}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Partition to MultiprocessorScheduling. #[derive(Debug, Clone)] @@ -32,12 +33,22 @@ impl ReductionResult for ReductionPartitionToMPS { /// Solution extraction: identity mapping. /// Partition config (0/1 for subset) maps directly to processor assignment (0/1). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPartitionToMPS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution .iter() .map(|&processor| processor == 1) diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index f3126f0c5..b028221e2 100644 --- a/src/rules/partition_openshopscheduling.rs +++ b/src/rules/partition_openshopscheduling.rs @@ -1,98 +1,64 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[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 } - fn extract_solution( + fn recover_result( &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", - )); - } - - Ok({ - let num_elements = self.target.num_jobs() - 1; - let mut source_config = vec![false; num_elements]; - let m = self.target.num_machines(); - let start_times = target_solution - .chunks_exact(m) - .map(|times| { - times - .iter() - .map(|&time| { - i64::try_from(time).map_err(|_| { - crate::rules::ExtractionError::invalid( - "target schedule time does not fit i64", - ) - }) - }) - .collect::, _>>() - }) - .collect::, _>>()?; - let special_job = num_elements; - let half_sum = self.target.processing_times()[special_job][0]; - - // Find the middle machine where the special job starts at half_sum - let middle_machine = (0..m) - .find(|&machine| start_times[special_job][machine] == half_sum) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "target schedule has no machine at the partition boundary", - ) - })?; - let pivot = start_times[special_job][middle_machine]; - - 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]) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid("target schedule time overflows i64") - })?; - if completion <= pivot { - *slot = true; - } - } - - source_config - }) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) } } -impl crate::rules::AggregateReductionResult for ReductionPartitionToOpenShopScheduling { - type Source = Partition; - type Target = OpenShopScheduling; - - fn target_problem(&self) -> &Self::Target { - &self.target - } +impl ReductionPartitionToOpenShopScheduling { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + let target = self.target.inner(); + let num_elements = target.num_jobs() - 1; + let mut source_config = vec![false; num_elements]; + let m = target.num_machines(); + let special_job = num_elements; + let half_sum = target.processing_times()[special_job][0]; + + // Find the middle machine where the special job starts at half_sum. + let middle_machine: usize = (0..m) + .filter(|&machine| target_solution[special_job * m + machine] as i64 == half_sum) + .sum(); + let pivot = target_solution[special_job * m + middle_machine] as i64; + + for (job, slot) in source_config.iter_mut().enumerate() { + let completion = target_solution[job * m + middle_machine] as i64 + + target.processing_times()[job][middle_machine]; + *slot = completion <= pivot; + } - fn extract_value(&self, value: crate::types::Min) -> crate::types::Or { - crate::types::Or(value.0 == Some(self.feasible_makespan)) + Ok(source_config) } } #[reduction( - aggregate = custom, transform = exact { num_jobs = "num_elements + 1", num_machines = "3", @@ -101,7 +67,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 +77,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 +93,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..4c0201093 100644 --- a/src/rules/partition_productionplanning.rs +++ b/src/rules/partition_productionplanning.rs @@ -2,7 +2,8 @@ use crate::models::misc::{Partition, ProductionPlanning}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionPartitionToProductionPlanning { @@ -17,12 +18,22 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPartitionToProductionPlanning { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.target.num_periods() - 1] .iter() .map(|&production| production > 0) diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index 72827a5d0..7a37075a0 100644 --- a/src/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/rules/partition_sequencingtominimizetardytaskweight.rs @@ -1,48 +1,48 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// 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 } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPartitionToSequencingToMinimizeTardyTaskWeight { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { 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]) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "target schedule completion time overflows i64", - ) - })?; - if completion_time <= self.target.deadlines()[task] { + completion_time += self.target.inner().lengths()[task]; + if completion_time <= self.target.inner().deadlines()[task] { source_config[task] = false; } } @@ -52,28 +52,11 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight } } -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 +66,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 +83,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..58c502e99 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -6,16 +6,14 @@ use crate::models::misc::{Partition, SubsetSum}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use num_bigint::{BigUint, ToBigUint}; /// Result of reducing Partition to SubsetSum. #[derive(Debug, Clone)] pub struct ReductionPartitionToSubsetSum { target: SubsetSum, - /// Number of elements in the original Partition instance. - /// When the total sum is odd, the target has 0 elements but the source has n. - source_n: usize, } impl ReductionResult for ReductionPartitionToSubsetSum { @@ -26,20 +24,12 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - if target_solution.len() != self.source_n { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} subset-selection values, got {}", - self.source_n, - target_solution.len() - ))); - } - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } @@ -52,14 +42,12 @@ impl ReduceTo for Partition { fn reduce_to(&self) -> Result { let total = self.total_sum(); - let source_n = self.num_elements(); Ok(if total % 2 != 0 { // Odd total sum: no balanced partition exists. // Return a trivially infeasible SubsetSum: no elements, target = 1. ReductionPartitionToSubsetSum { target: SubsetSum::new_unchecked(vec![], BigUint::from(1u32)), - source_n, } } else { let sizes: Vec = self @@ -75,7 +63,6 @@ impl ReduceTo for Partition { .expect("validated nonnegative Partition total"); ReductionPartitionToSubsetSum { target: SubsetSum::new_unchecked(sizes, target_val), - source_n, } }) } diff --git a/src/rules/partition_sumofsquarespartition.rs b/src/rules/partition_sumofsquarespartition.rs index 8eb491c9b..8e5ba8b0f 100644 --- a/src/rules/partition_sumofsquarespartition.rs +++ b/src/rules/partition_sumofsquarespartition.rs @@ -7,11 +7,9 @@ //! instance is YES iff the optimal target witness is a balanced split, in //! 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))`. +//! Recovery uses this optimum correspondence: a balanced optimum yields a source +//! solution; an unbalanced optimum proves source infeasibility. An unbalanced +//! candidate without optimality returns `InsufficientSolutionQuality`. //! //! Solution extraction is the identity (group assignment in the target is the //! subset assignment in the source). Small inputs with `|A| < 2` use a @@ -22,7 +20,8 @@ use crate::models::misc::{Partition, SumOfSquaresPartition}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Partition to SumOfSquaresPartition. #[derive(Debug, Clone)] @@ -45,19 +44,22 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { /// Solution extraction preserves the source elements. The sentinel target /// appends elements, so only the prefix corresponding to actual source /// elements is mapped back. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if target_solution.len() != self.target.num_elements() { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} target group assignments, got {}", - self.target.num_elements(), - target_solution.len() - ))); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPartitionToSumOfSquaresPartition { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.source_n] .iter() .map(|&group| group == 1) diff --git a/src/rules/partitionintocliques_ilp.rs b/src/rules/partitionintocliques_ilp.rs index 843c6a905..698427740 100644 --- a/src/rules/partitionintocliques_ilp.rs +++ b/src/rules/partitionintocliques_ilp.rs @@ -3,7 +3,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::PartitionIntoCliques; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] @@ -21,23 +22,28 @@ impl ReductionResult for ReductionPartitionIntoCliquesToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - (0..self.num_vertices) - .map(|vertex| { - (0..self.num_cliques) - .find(|&clique| target_solution[vertex * self.num_cliques + clique] == 1) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "target solution does not assign vertex {vertex} to a clique" - )) - }) - }) - .collect() +impl ReductionPartitionIntoCliquesToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_vertices, + self.num_cliques, + 0, + )) } } diff --git a/src/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/rules/partitionintocliques_minimumcoveringbycliques.rs index b3078e508..6057b2dd0 100644 --- a/src/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -8,11 +8,12 @@ //! 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::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; -use crate::types::{Min, OptimizationValue, Or}; use std::collections::BTreeMap; #[derive(Debug, Clone)] @@ -87,7 +88,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 +107,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 +132,7 @@ fn target_clique_bound( .ok_or_else(|| { crate::rules::ReductionError::integer_overflow::< PartitionIntoCliques, - MinimumCoveringByCliques, + Decision>, >("computing target clique bound") }) } @@ -140,102 +140,73 @@ 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 } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - Ok({ - let n = self.num_source_vertices; - let target_edges = self.target.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 { - Some(*u) - } else if *v < n && *u == n + *v { - Some(*v) +impl ReductionPartitionIntoCliquesToMinimumCoveringByCliques { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + let n = self.num_source_vertices; + let mut matching_labels: Vec<_> = self + .target + .inner() + .graph() + .edges() + .into_iter() + .zip(target_solution) + .filter_map(|((u, v), &label)| { + if u < n && v == n + u { + Some((u, label)) + } else if v < n && u == n + v { + Some((v, label)) } else { None - }; - - if let Some(i) = matching_index { - matching_labels[i] = Some(label); } - } - - let mut label_map = BTreeMap::new(); - let extracted = matching_labels - .into_iter() - .map(|label| { - let label = label.ok_or_else(|| { - crate::rules::ExtractionError::invalid( - "target cover does not label every matching gadget edge", - ) - })?; - let next = label_map.len(); - Ok(*label_map.entry(label).or_insert(next)) - }) - .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 - }) - } -} - -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)) + }) + .collect(); + matching_labels.sort_unstable_by_key(|&(vertex, _)| vertex); + let mut label_map = BTreeMap::new(); + Ok(matching_labels + .into_iter() + .map(|(_, label)| { + let next = label_map.len(); + *label_map.entry(label).or_insert(next) + }) + .collect()) } } #[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 +214,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 +258,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 +323,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..f37db7c67 100644 --- a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -12,7 +12,8 @@ use crate::models::graph::{BoundedComponentSpanningForest, PartitionIntoPathsOfLength2}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing PartitionIntoPathsOfLength2 to BoundedComponentSpanningForest. @@ -33,13 +34,12 @@ impl ReductionResult for ReductionPPL2ToBCSF { /// /// Both problems use the same vertex-to-group assignment encoding, /// so the solution mapping is identity. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/partitionintopathsoflength2_ilp.rs b/src/rules/partitionintopathsoflength2_ilp.rs index f652ba758..f9ec02c93 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -20,7 +20,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::PartitionIntoPathsOfLength2; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing PartitionIntoPathsOfLength2 to ILP. @@ -44,18 +45,28 @@ impl ReductionResult for ReductionPIPL2ToILP { } /// Extract solution: for each vertex v, find the unique group g where x_{v,g} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionPIPL2ToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_vertices, self.num_groups, 0, - ) + )) } } diff --git a/src/rules/partitionintotriangles_ilp.rs b/src/rules/partitionintotriangles_ilp.rs index b80d3fe8c..2704c7c52 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -12,7 +12,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::PartitionIntoTriangles; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing PartitionIntoTriangles to ILP. @@ -37,18 +38,28 @@ impl ReductionResult for ReductionPITToILP { } /// Extract solution: for each vertex v, find the unique group g where x_{v,g} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionPITToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_vertices, self.num_groups, 0, - ) + )) } } diff --git a/src/rules/pathconstrainednetworkflow_ilp.rs b/src/rules/pathconstrainednetworkflow_ilp.rs index 799cebfc8..c6843ff15 100644 --- a/src/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/rules/pathconstrainednetworkflow_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::PathConstrainedNetworkFlow; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing PathConstrainedNetworkFlow to ILP. #[derive(Debug, Clone)] @@ -22,12 +23,22 @@ impl ReductionResult for ReductionPCNFToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPCNFToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(target_solution) } } diff --git a/src/rules/precedenceconstrainedscheduling_ilp.rs b/src/rules/precedenceconstrainedscheduling_ilp.rs index c79cbc5fc..6d533413a 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -13,7 +13,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::PrecedenceConstrainedScheduling; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing PrecedenceConstrainedScheduling to `ILP`. /// @@ -38,18 +39,28 @@ impl ReductionResult for ReductionPCSToILP { /// /// For each task j, find the time slot t where x_{j,t} = 1. /// Returns the time slot for each task (matching the `dims()` encoding of PCS). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionPCSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_tasks, self.deadline, 0, - ) + )) } } diff --git a/src/rules/preemptivescheduling_ilp.rs b/src/rules/preemptivescheduling_ilp.rs index f6d65c971..e906f13cf 100644 --- a/src/rules/preemptivescheduling_ilp.rs +++ b/src/rules/preemptivescheduling_ilp.rs @@ -24,7 +24,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::PreemptiveScheduling; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing PreemptiveScheduling to `ILP`. /// @@ -51,12 +52,22 @@ impl ReductionResult for ReductionPSToILP { /// Extract schedule from ILP solution. /// /// Returns a binary config of length n * D_max: `config[t * D_max + u] = x_{t,u}`. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok((0..self.num_tasks) .map(|task| { (0..self.d_max) diff --git a/src/rules/prizecollectingsteinerforest_steinertree.rs b/src/rules/prizecollectingsteinerforest_steinertree.rs index 26ab8ddb6..51e6008b8 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`, for every `v in V_p` add `(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|`. +//! In an optimum each gadget terminal is a leaf: replacing both gadget edges +//! by the include edge and a root attachment strictly reduces cost. //! //! References: //! - Bienstock, Goemans, Simchi-Levi, Williamson, "A note on the prize @@ -33,12 +35,13 @@ use crate::models::graph::{PrizeCollectingSteinerForest, SteinerTree}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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) @@ -69,17 +72,28 @@ impl ReductionResult for ReductionPCSFToSteinerTree { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionPCSFToSteinerTree { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_source_vertices; let m = self.num_source_edges; let mut selected_vertices = vec![false; n]; let mut selected_edges = vec![false; m]; + let edges = self.target.graph().edges(); // Mark vertices included via their gadget include-edge `(v, t_v)`, // and edges via the matching original edge. @@ -91,20 +105,9 @@ impl ReductionResult for ReductionPCSFToSteinerTree { selected_vertices[v] = true; } else if let Some(src_edge) = self.target_to_source_edge[target_idx] { selected_edges[src_edge] = true; - } - } - - // Any original edge selected in `T*` forces both endpoints into - // `V_F`. The PCSF model rejects configurations where a selected - // edge has an unselected endpoint, so we mark endpoints explicitly - // (this also covers prize-zero endpoints, which have no gadget). - let edges = self.target.graph().edges(); - for (target_idx, &(_, _)) in edges.iter().enumerate() { - if !target_solution[target_idx] { - continue; - } - if let Some(src_edge) = self.target_to_source_edge[target_idx] { - let (u, v) = self.source_edge_pair(src_edge); + // Include both endpoints, including prize-zero vertices + // that have no inclusion gadget. + let (u, v) = edges[target_idx]; selected_vertices[u] = true; selected_vertices[v] = true; } @@ -115,14 +118,6 @@ impl ReductionResult for ReductionPCSFToSteinerTree { } } -impl ReductionPCSFToSteinerTree { - /// Look up the endpoint pair of the `idx`-th source edge in the target - /// graph's edge list (source edges are placed first by construction). - fn source_edge_pair(&self, src_edge_idx: usize) -> (usize, usize) { - self.target.graph().edges()[src_edge_idx] - } -} - #[reduction( transform = exact { num_vertices = "num_vertices + num_vertices_with_prize + 1", @@ -174,18 +169,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 records 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 extra beta * p(v). 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); } @@ -215,16 +226,10 @@ impl ReduceTo> for PrizeCollectingSteinerForest Vec { use crate::example_db::specs::RuleExampleSpec; use crate::export::SolutionPair; - use crate::solvers::BruteForce; vec![RuleExampleSpec { id: "prize_collecting_steiner_forest_to_steiner_tree", build: || { - // Issue #1027 canonical instance with the omit-edge actually - // selected at the optimum: path 0 - 1 - 2 with c(0,1)=10, - // c(1,2)=10, prizes p = (5, 1, 5), beta = 1, omega = 1. The - // optimum drops vertex 1 (paying p(1) = 1) rather than paying a - // size-10 edge to reach it. let source = PrizeCollectingSteinerForest::::new( SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![5, 1, 5], @@ -233,25 +238,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec as ReduceTo< - SteinerTree, - >>::reduce_to(&source) - .expect("reduction should succeed"); - let target = reduction.target_problem(); - let target_config = BruteForce::new() - .solve(target) - .expect("canonical target evaluation must succeed") - .expect("canonical PCSF -> SteinerTree example must have an optimal target tree"); - let source_config = reduction.extract_solution(&target_config).unwrap(); - crate::example_db::specs::assemble_rule_example( - &source, - target, - vec![SolutionPair { - source_config: serde_json::to_value(source_config) - .expect("solution serialization must succeed"), - target_config: serde_json::to_value(target_config) - .expect("solution serialization must succeed"), - }], + crate::example_db::specs::rule_example_with_witness::<_, SteinerTree>( + source, + SolutionPair { + source_config: serde_json::json!([[true, false, true], [false, false]]), + target_config: serde_json::json!([ + false, false, true, false, true, true, false, false, true, true, false + ]), + }, ) }, }] diff --git a/src/rules/quadraticassignment_ilp.rs b/src/rules/quadraticassignment_ilp.rs index aacef5d4e..1e1229010 100644 --- a/src/rules/quadraticassignment_ilp.rs +++ b/src/rules/quadraticassignment_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::QuadraticAssignment; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::reduction; use crate::rules::ilp_helpers::{mccormick_product, one_hot_assignment_constraints}; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing QuadraticAssignment to ILP. /// @@ -34,18 +35,28 @@ impl ReductionResult for ReductionQAPToILP { } /// Extract: for each facility i, output the unique location p with x_{i,p} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionQAPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_facilities, self.num_locations, 0, - ) + )) } } diff --git a/src/rules/qubo_casts.rs b/src/rules/qubo_casts.rs index 841c432cc..08639d9be 100644 --- a/src/rules/qubo_casts.rs +++ b/src/rules/qubo_casts.rs @@ -10,20 +10,16 @@ impl_variant_reduction!( => , fields: [num_vars], |src| { - let matrix = src - .matrix() - .iter() - .map(|row| { - row.iter() - .copied() - .map(i64_to_exact_f64) - .collect::, _>>() - }) - .collect::, _>>() - .map_err(|error| { - ReductionError::inexact_float_conversion::, QUBO>(error) - })?; - QUBO::from_matrix(matrix) + let coefficients = src.matrix().data().iter().copied() + .map(i64_to_exact_f64).collect::, _>>() + .map_err(ReductionError::inexact_float_conversion::, QUBO>)?; + let matrix = sprs::CsMat::new( + src.matrix().shape(), + src.matrix().indptr().raw_storage().to_vec(), + src.matrix().indices().to_vec(), + coefficients, + ); + QUBO::from_sparse(matrix) .map_err(ReductionError::construction::, QUBO>)? } ); diff --git a/src/rules/qubo_ilp.rs b/src/rules/qubo_ilp.rs index 2dd22909c..4f39a59cb 100644 --- a/src/rules/qubo_ilp.rs +++ b/src/rules/qubo_ilp.rs @@ -17,7 +17,8 @@ use crate::models::algebraic::{ILPCoefficient, ObjectiveSense, ILP, QUBO}; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing QUBO to ILP. #[derive(Debug, Clone)] @@ -37,12 +38,25 @@ where &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionQUBOToILP +where + C: ILPCoefficient + crate::variant::VariantParam, +{ + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_original] .iter() .map(|&value| value == 1) @@ -59,9 +73,9 @@ where // 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() { + for (i, row) in matrix.outer_iterator().enumerate() { + for (j, &q_ij) in row.iter() { + if j > i && q_ij != C::zero() { off_diag.push((i, j, q_ij)); } } @@ -72,8 +86,8 @@ where // 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]; + for (i, row) in matrix.outer_iterator().enumerate() { + let q_ii = row.get(i).copied().unwrap_or_else(C::zero); if q_ii != C::zero() { objective.push((i, q_ii)); } diff --git a/src/rules/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index cf75fb3a1..6ebf2c56a 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -6,7 +6,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::RectilinearPictureCompression; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionRPCToILP { @@ -21,13 +22,14 @@ impl ReductionResult for ReductionRPCToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/registersufficiency_ilp.rs b/src/rules/registersufficiency_ilp.rs index 021b02edc..2d5233759 100644 --- a/src/rules/registersufficiency_ilp.rs +++ b/src/rules/registersufficiency_ilp.rs @@ -10,7 +10,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::RegisterSufficiency; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionRegisterSufficiencyToILP { @@ -26,12 +27,22 @@ impl ReductionResult for ReductionRegisterSufficiencyToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionRegisterSufficiencyToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(&target_solution[..self.num_vertices]) } } diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 80a10beb6..af9008057 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -2,7 +2,7 @@ use crate::expr::Expr; use crate::parameters::{ParameterRelation, ParameterTransform, ParameterTransformError}; -use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; +use crate::rules::traits::DynReductionResult; use std::any::Any; use std::collections::HashSet; @@ -132,34 +132,30 @@ impl From for ParameterContractError { } } -/// Witness/config reduction executor stored in the inventory. -pub type ReduceFn = - fn(&dyn Any) -> Result, crate::rules::ReductionError>; +/// One executed reduction, shared by all paths using this prefix. +#[derive(Clone)] +pub struct ExecutedStep { + pub witness: std::rc::Rc, +} -/// Aggregate/value reduction executor stored in the inventory. -pub type AggregateReduceFn = - fn(&dyn Any) -> Result, crate::rules::ReductionError>; +/// Witness/config reduction executor stored in the inventory. +pub type ReduceFn = fn(&dyn Any) -> Result; -/// Execution capabilities carried by a reduction edge. +/// Executability and theoretical multi-query relations carried by a reduction edge. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct EdgeCapabilities { pub witness: bool, - pub aggregate: bool, /// Turing (multi-query) reduction: solving the source requires multiple /// adaptive queries to the target (e.g., binary search over a decision bound). + /// This is graph metadata only; the library does not execute Turing reductions. #[serde(default)] pub turing: bool, } impl EdgeCapabilities { - pub(crate) const fn from_executors( - reduce_fn: Option, - reduce_aggregate_fn: Option, - turing: bool, - ) -> Self { + pub(crate) const fn from_executors(reduce_fn: Option, turing: bool) -> Self { Self { witness: reduce_fn.is_some(), - aggregate: reduce_aggregate_fn.is_some(), turing, } } @@ -182,14 +178,8 @@ pub struct ReductionEntry { pub module_path: &'static str, /// Type-erased reduction executor. /// Takes a `&dyn Any` (must be `&SourceType`), calls `ReduceTo::reduce_to()`, - /// and returns either a boxed `DynReductionResult` or the edge's `ReductionError`. + /// and returns one `ExecutedStep` sharing the result, or the edge's `ReductionError`. 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 - /// `DynAggregateReductionResult` or the edge's `ReductionError`. - pub reduce_aggregate_fn: Option, - /// Whether this is a Turing (multi-query) reduction. pub turing: bool, } @@ -211,7 +201,7 @@ impl ReductionEntry { /// Return the modes backed by this entry's executors. pub fn capabilities(&self) -> EdgeCapabilities { - EdgeCapabilities::from_executors(self.reduce_fn, self.reduce_aggregate_fn, self.turing) + EdgeCapabilities::from_executors(self.reduce_fn, self.turing) } /// Check if this reduction involves only the base (unweighted) variants. diff --git a/src/rules/resourceconstrainedscheduling_ilp.rs b/src/rules/resourceconstrainedscheduling_ilp.rs index bcc1726d9..c05f65408 100644 --- a/src/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/rules/resourceconstrainedscheduling_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ResourceConstrainedScheduling; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ResourceConstrainedScheduling to `ILP`. /// @@ -29,18 +30,28 @@ impl ReductionResult for ReductionRCSToILP { } /// Extract: for each task j, find the unique slot t with x_{j,t} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionRCSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_tasks, self.deadline, 0, - ) + )) } } diff --git a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs index 8c40f906a..9de5919e4 100644 --- a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -12,7 +12,8 @@ use crate::models::graph::RootedTreeArrangement; use crate::models::set::RootedTreeStorageAssignment; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing RootedTreeArrangement to RootedTreeStorageAssignment. @@ -36,12 +37,22 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign /// The target config is a parent array defining a rooted tree on X = V. /// The source config is [parent_array | identity_mapping] since X = V /// means the mapping f is the identity. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionRootedTreeArrangementToRootedTreeStorageAssignment { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_vertices; // target_solution is the parent array of the rooted tree on X = V diff --git a/src/rules/rootedtreestorageassignment_ilp.rs b/src/rules/rootedtreestorageassignment_ilp.rs index 70b4d9c56..c91c4ee04 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::RootedTreeStorageAssignment; use crate::reduction; use crate::rules::ilp_helpers::{mccormick_product, one_hot_decode_rows}; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; // Index helpers @@ -72,13 +73,14 @@ impl ReductionResult for ReductionRTSAToILP { } /// Decode parent array from one-hot parent indicators p_{v,u}. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - one_hot_decode_rows(target_solution, self.n, self.n, 0) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(one_hot_decode_rows(solution, self.n, self.n, 0)) + }) } } @@ -394,6 +396,7 @@ impl ReduceTo> for RootedTreeStorageAssignment { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "rootedtreestorageassignment_to_ilp", build: || { @@ -406,7 +409,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/ruralpostman_ilp.rs b/src/rules/ruralpostman_ilp.rs index ad8e63a1d..0430c0d35 100644 --- a/src/rules/ruralpostman_ilp.rs +++ b/src/rules/ruralpostman_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::RuralPostman; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::WeightElement; @@ -26,12 +27,22 @@ impl ReductionResult for ReductionRPToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionRPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { if self.target.num_vars() == 0 { Ok(vec![0; self.num_edges]) } else { diff --git a/src/rules/sat_circuitsat.rs b/src/rules/sat_circuitsat.rs index c6fbfab3e..3e15c5894 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -6,8 +6,8 @@ use crate::models::formula::Satisfiability; use crate::models::formula::{Assignment, BooleanExpr, Circuit, CircuitSAT}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::solvers::BruteForceProblem as _; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use std::collections::HashSet; /// Result of reducing SAT to CircuitSAT. @@ -26,12 +26,22 @@ impl ReductionResult for ReductionSATToCircuit { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSATToCircuit { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ self.source_var_indices .iter() @@ -55,7 +65,7 @@ impl ReduceTo for Satisfiability { type Result = ReductionSATToCircuit; fn reduce_to(&self) -> Result { - let num_vars = self.num_variables(); + let num_vars = self.num_vars(); let clauses = self.clauses(); let mut assignments = Vec::new(); diff --git a/src/rules/sat_coloring.rs b/src/rules/sat_coloring.rs index 09fadc3b2..47647f67f 100644 --- a/src/rules/sat_coloring.rs +++ b/src/rules/sat_coloring.rs @@ -12,7 +12,8 @@ use crate::models::formula::Satisfiability; use crate::models::graph::KColoring; use crate::reduction; use crate::rules::sat_maximumindependentset::BoolVar; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; use crate::variant::K3; use std::collections::HashMap; @@ -240,37 +241,31 @@ impl ReductionResult for ReductionSATToColoring { /// /// 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). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSATToColoring { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::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; diff --git a/src/rules/sat_ksat.rs b/src/rules/sat_ksat.rs index 897c8bb5b..988c54d07 100644 --- a/src/rules/sat_ksat.rs +++ b/src/rules/sat_ksat.rs @@ -9,7 +9,8 @@ use crate::models::formula::{CNFClause, KSatisfiability, Satisfiability}; use crate::reduction; use crate::rules::sat_helpers::SatVariableAllocator; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::variant::{KValue, K2, K3, KN}; /// Result of reducing general SAT to K-SAT. @@ -32,12 +33,22 @@ impl ReductionResult for ReductionSATToKSAT { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSATToKSAT { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Only return the original variables, discarding ancillas target_solution[..self.source_num_vars].to_vec() @@ -182,12 +193,22 @@ impl ReductionResult for ReductionKSATToSAT { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionKSATToSAT { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Direct mapping - no transformation needed target_solution.to_vec() diff --git a/src/rules/sat_maximumindependentset.rs b/src/rules/sat_maximumindependentset.rs index d8271f74e..60e3aac7a 100644 --- a/src/rules/sat_maximumindependentset.rs +++ b/src/rules/sat_maximumindependentset.rs @@ -8,12 +8,14 @@ //! 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::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; 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 +56,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 @@ -78,19 +78,22 @@ impl ReductionResult for ReductionSATToIS { /// For each selected vertex (representing a literal), we set the corresponding /// variable to make that literal true. Variables not covered by any selected /// literal default to false. - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSATToIS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut assignment = vec![false; self.num_source_variables]; for (literal, &selected) in self.literals.iter().zip(target_solution) { if selected { @@ -101,19 +104,6 @@ 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))) - } -} - impl ReductionSATToIS { /// Get the number of clauses in the source SAT problem. pub fn num_clauses(&self) -> usize { @@ -127,20 +117,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 +170,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 +203,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..87c8122e5 100644 --- a/src/rules/sat_minimumdominatingset.rs +++ b/src/rules/sat_minimumdominatingset.rs @@ -14,13 +14,14 @@ //! - 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::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; -use crate::types::{Min, Or}; use std::collections::BTreeMap; /// Result of reducing Satisfiability to MinimumDominatingSet. @@ -32,20 +33,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 @@ -58,19 +57,22 @@ impl ReductionResult for ReductionSATToDS { /// that original variable true. Negative, dummy and absent variables decode /// to false. The finite size certificate proves every clause is dominated /// by a selected literal and no clause vertex is selected. - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSATToDS { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let mut assignment = vec![false; self.num_literals]; for (&variable, &gadget) in &self.variables { assignment[variable] = target_solution[3 * gadget]; @@ -80,19 +82,6 @@ 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))) - } -} - impl ReductionSATToDS { /// Compute the graph dimensions and exact certificate before allocation. fn target_dimensions( @@ -105,20 +94,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 +124,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 +185,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 +215,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..7c5a5fd00 100644 --- a/src/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/rules/satisfiability_integralflowhomologousarcs.rs @@ -9,7 +9,8 @@ use crate::models::formula::Satisfiability; use crate::models::graph::IntegralFlowHomologousArcs; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::DirectedGraph; #[derive(Debug, Clone)] @@ -102,12 +103,22 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSATToIntegralFlowHomologousArcs { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ self.variable_paths .iter() diff --git a/src/rules/satisfiability_maximum2satisfiability.rs b/src/rules/satisfiability_maximum2satisfiability.rs index 040837da5..769946ec7 100644 --- a/src/rules/satisfiability_maximum2satisfiability.rs +++ b/src/rules/satisfiability_maximum2satisfiability.rs @@ -1,54 +1,35 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// 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 } - fn extract_solution( + fn recover_result( &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", - )); - } - - 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))) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution[..self.source_num_vars].to_vec()) + }) } } @@ -121,13 +102,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 { @@ -135,29 +115,22 @@ impl ReduceTo for Satisfiability { let mut variables = SatVariableAllocator::new("Satisfiability -> Maximum2Satisfiability", self.num_vars()) .map_err( - crate::rules::ReductionError::construction::< - Satisfiability, - Maximum2Satisfiability, - >, + >>::target_construction, )?; for clause in self.clauses() { add_normalized_clause(clause, &mut variables, &mut normalized).map_err( - crate::rules::ReductionError::construction::< - Satisfiability, - Maximum2Satisfiability, - >, + >>::target_construction, )?; } - 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 +140,18 @@ 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( + >>::target_construction, + )?; 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 +167,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..902cd27c9 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -10,7 +10,8 @@ use crate::models::formula::{CNFClause, NAESatisfiability, Satisfiability}; use crate::reduction; use crate::rules::sat_helpers::SatVariableAllocator; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing Satisfiability to NAE-Satisfiability. #[derive(Debug, Clone)] @@ -29,20 +30,23 @@ impl ReductionResult for ReductionSATToNAESAT { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSATToNAESAT { + pub(crate) fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { 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() diff --git a/src/rules/satisfiability_nontautology.rs b/src/rules/satisfiability_nontautology.rs index 3ba7ee1c8..3f88fa79d 100644 --- a/src/rules/satisfiability_nontautology.rs +++ b/src/rules/satisfiability_nontautology.rs @@ -5,7 +5,8 @@ use crate::models::formula::{NonTautology, Satisfiability}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SAT to NonTautology. #[derive(Debug, Clone)] @@ -21,13 +22,12 @@ impl ReductionResult for ReductionSATToNonTautology { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs index caefabe83..d1f0eb2ef 100644 --- a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SchedulingToMinimizeWeightedCompletionTime; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode_rows; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SchedulingToMinimizeWeightedCompletionTime to ILP. /// @@ -52,13 +53,28 @@ impl ReductionResult for ReductionSMWCTToILP { } /// Extract solution: for each task, find the processor with x_{t,p} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode_rows(target_solution, self.num_tasks, self.num_processors, 0) +impl ReductionSMWCTToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode_rows( + target_solution, + self.num_tasks, + self.num_processors, + 0, + )) } } diff --git a/src/rules/schedulingwithindividualdeadlines_ilp.rs b/src/rules/schedulingwithindividualdeadlines_ilp.rs index a36ee872d..87d4e3a0a 100644 --- a/src/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/rules/schedulingwithindividualdeadlines_ilp.rs @@ -15,7 +15,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SchedulingWithIndividualDeadlines; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode_rows; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SchedulingWithIndividualDeadlines to `ILP`. /// @@ -39,13 +40,28 @@ impl ReductionResult for ReductionSWIDToILP { /// Extract schedule from ILP solution. /// /// For each task j, find the time slot t where x_{j,t} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode_rows(target_solution, self.num_tasks, self.max_deadline, 0) +impl ReductionSWIDToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode_rows( + target_solution, + self.num_tasks, + self.max_deadline, + 0, + )) } } diff --git a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs index 1c87717e3..2d18f590d 100644 --- a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SequencingToMinimizeMaximumCumulativeCost; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SequencingToMinimizeMaximumCumulativeCost to `ILP`. /// @@ -31,16 +32,26 @@ impl ReductionResult for ReductionSTMMCCToILP { } /// Extract: decode position assignment → permutation → Lehmer code. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSTMMCCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_tasks; - one_hot_decode(target_solution, n, n, 0)? + one_hot_decode(target_solution, n, n, 0) }) } } diff --git a/src/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/rules/sequencingtominimizetardytaskweight_ilp.rs index 5f41493d2..b7e8a89ad 100644 --- a/src/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SequencingToMinimizeTardyTaskWeight; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SequencingToMinimizeTardyTaskWeight to `ILP`. #[derive(Debug, Clone)] @@ -25,24 +26,28 @@ impl ReductionResult for ReductionSTMTTWToILP { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSTMTTWToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_tasks; // Decode the n*n block of x_{j,p} variables into a schedule permutation. // The source uses direct permutation encoding (config = schedule directly), // so return the schedule as-is (it is already a permutation of 0..n). - one_hot_decode(target_solution, n, n, 0)? + one_hot_decode(target_solution, n, n, 0) }) } } diff --git a/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index 1171c160a..5c3945526 100644 --- a/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SequencingToMinimizeWeightedCompletionTime; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionSTMWCTToILP { @@ -37,12 +38,22 @@ impl ReductionResult for ReductionSTMWCTToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSTMWCTToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let mut schedule: Vec = (0..self.num_tasks).collect(); schedule.sort_by_key(|&task| (target_solution[task], task)); diff --git a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs index f046bd232..7c45b8090 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SequencingToMinimizeWeightedTardiness; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SequencingToMinimizeWeightedTardiness to `ILP`. /// @@ -33,12 +34,22 @@ impl ReductionResult for ReductionSTMWTToILP { } /// Extract by sorting jobs by completion time C_j. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSTMWTToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_tasks; let c_offset = self.num_order_vars; diff --git a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 21cd366b0..cfe6ef8ba 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -19,7 +19,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SequencingWithDeadlinesAndSetUpTimes; use crate::reduction; use crate::rules::ilp_helpers::one_hot_decode; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SequencingWithDeadlinesAndSetUpTimes to `ILP`. #[derive(Debug, Clone)] @@ -36,16 +37,26 @@ impl ReductionResult for ReductionSWDSTToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSWDSTToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_tasks; // x_{j,p} occupies the first n*n variables: decode the permutation. - one_hot_decode(target_solution, n, n, 0)? + one_hot_decode(target_solution, n, n, 0) }) } } diff --git a/src/rules/sequencingwithinintervals_ilp.rs b/src/rules/sequencingwithinintervals_ilp.rs index daccfd111..446d9eb1a 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -18,7 +18,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SequencingWithinIntervals; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SequencingWithinIntervals to `ILP`. /// @@ -43,28 +44,31 @@ impl ReductionResult for ReductionSWIToILP { /// /// For each task j, find the offset k where x_{j,k} = 1. /// Returns config[j] = k (start time offset from release time). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - self.task_layout +impl ReductionSWIToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(self + .task_layout .iter() - .enumerate() - .map(|(task, &(base, count))| { - let mut selected = (0..count).filter(|&offset| target_solution[base + offset] == 1); - match (selected.next(), selected.next()) { - (Some(offset), None) => Ok(offset), - (None, _) => Err(crate::rules::ExtractionError::invalid(format!( - "task {task} has no selected start time" - ))), - (Some(_), Some(_)) => Err(crate::rules::ExtractionError::invalid(format!( - "task {task} has multiple selected start times" - ))), - } + .map(|&(base, count)| { + (0..count) + .filter(|&offset| target_solution[base + offset] == 1) + .sum() }) - .collect() + .collect()) } } @@ -159,6 +163,7 @@ impl ReduceTo> for SequencingWithinIntervals { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "sequencingwithinintervals_to_ilp", @@ -174,7 +179,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index d14ad874c..103e72167 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SequencingWithReleaseTimesAndDeadlines; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SequencingWithReleaseTimesAndDeadlines to `ILP`. /// @@ -29,18 +30,28 @@ impl ReductionResult for ReductionSWRTDToILP { } /// Extract by reading each task's start time and sorting tasks by start time. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSWRTDToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_tasks; let horizon = self.time_horizon; // For each task, find the start time let starts = - crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, n, horizon, 0)?; + crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, n, horizon, 0); let mut start_times: Vec<_> = starts.into_iter().enumerate().collect(); // Sort by start time (break ties by task index) start_times.sort_by_key(|&(j, t)| (t, j)); diff --git a/src/rules/setsplitting_betweenness.rs b/src/rules/setsplitting_betweenness.rs index a62f799d4..953377261 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -10,7 +10,8 @@ use crate::models::misc::Betweenness; use crate::models::set::SetSplitting; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SetSplitting to Betweenness. #[derive(Debug, Clone)] @@ -28,12 +29,22 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSetSplittingToBetweenness { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { let pole_position = target_solution[self.pole]; Ok(target_solution[..self.source_universe_size] .iter() @@ -63,6 +74,14 @@ impl ReduceTo for SetSplitting { for subset in normalized_subsets { match subset.as_slice() { + [u] => { + // A singleton cannot contain both colors. These two + // incompatible orders encode that same impossibility. + let auxiliary = num_elements; + num_elements += 1; + triples.push((*u, pole, auxiliary)); + triples.push((pole, *u, auxiliary)); + } [u, v] => triples.push((*u, pole, *v)), [u, v, w] => { let auxiliary = num_elements; @@ -75,7 +94,7 @@ impl ReduceTo for SetSplitting { SetSplitting, Betweenness, >( - "normalized subset must contain two or three elements" + "normalized subset must contain one, two or three elements", )); } } diff --git a/src/rules/setsplitting_ilp.rs b/src/rules/setsplitting_ilp.rs index db0b8a61c..0ee1066a7 100644 --- a/src/rules/setsplitting_ilp.rs +++ b/src/rules/setsplitting_ilp.rs @@ -12,7 +12,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::SetSplitting; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SetSplitting to ILP. #[derive(Debug, Clone)] @@ -28,13 +29,14 @@ impl ReductionResult for ReductionSetSplittingToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/shortestcommonsupersequence_ilp.rs b/src/rules/shortestcommonsupersequence_ilp.rs index d1c713717..a72ff2286 100644 --- a/src/rules/shortestcommonsupersequence_ilp.rs +++ b/src/rules/shortestcommonsupersequence_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ShortestCommonSupersequence; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionSCSToILP { @@ -27,18 +28,28 @@ impl ReductionResult for ReductionSCSToILP { /// At each position p, output the unique symbol a with x_{p,a} = 1. /// Uses alphabet_size + 1 symbols (last = padding). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSCSToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.max_length, self.alphabet_size + 1, 0, - )? + ) .into_iter() .map(|symbol| (symbol < self.alphabet_size).then_some(symbol)) .collect()) @@ -152,6 +163,7 @@ impl ReduceTo> for ShortestCommonSupersequence { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "shortestcommonsupersequence_to_ilp", build: || { @@ -165,7 +177,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/shortestweightconstrainedpath_ilp.rs b/src/rules/shortestweightconstrainedpath_ilp.rs index fe8ff92be..a996f6128 100644 --- a/src/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/rules/shortestweightconstrainedpath_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::ShortestWeightConstrainedPath; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::WeightElement; @@ -40,12 +41,22 @@ impl ReductionResult for ReductionSWCPToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSWCPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ (0..self.num_edges) .map(|edge_idx| { diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index b19b79368..6b2a8793d 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -5,7 +5,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, SparseMatrixCompression, ILP}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionSMCToILP { @@ -22,18 +23,28 @@ impl ReductionResult for ReductionSMCToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionSMCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_rows, self.bound_k, 0, - ) + )) } } @@ -113,6 +124,7 @@ impl ReduceTo> for SparseMatrixCompression { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "sparsematrixcompression_to_ilp", build: || { @@ -131,7 +143,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/spinglass_maxcut.rs b/src/rules/spinglass_maxcut.rs index 31e315acc..df7de8703 100644 --- a/src/rules/spinglass_maxcut.rs +++ b/src/rules/spinglass_maxcut.rs @@ -6,7 +6,8 @@ use crate::models::graph::MaxCut; use crate::models::graph::SpinGlass; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::WeightElement; use num_traits::Zero; @@ -35,13 +36,14 @@ where &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&spin| spin == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&spin| spin == 1).collect()) + }) } } @@ -121,12 +123,32 @@ where &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSGToMaxCut +where + W: WeightElement + + crate::variant::VariantParam + + PartialOrd + + num_traits::Num + + num_traits::Zero + + num_traits::Bounded + + std::ops::AddAssign + + std::ops::Mul, +{ + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ match self.ancilla { None => target_solution diff --git a/src/rules/spinglass_qubo.rs b/src/rules/spinglass_qubo.rs index 1a6a900ad..26dbc1742 100644 --- a/src/rules/spinglass_qubo.rs +++ b/src/rules/spinglass_qubo.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::QUBO; use crate::models::graph::SpinGlass; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::SimpleGraph; /// Result of reducing QUBO to SpinGlass. @@ -26,13 +27,14 @@ impl ReductionResult for ReductionQUBOToSG { } /// Solution maps directly (same binary encoding). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&spin| spin == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&spin| spin == 1).collect()) + }) } } @@ -63,10 +65,9 @@ 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 { + for (i, row) in matrix.outer_iterator().enumerate() { + for (j, &q) in row.iter() { + if j < i || q == 0.0 { continue; } @@ -77,7 +78,7 @@ impl ReduceTo> for QUBO { // Off-diagonal: Q_ij * x_i * x_j // J_ij contribution let j_ij = q / 4.0; - if j_ij.abs() > 1e-10 { + if j_ij != 0.0 { interactions.push(((i, j), j_ij)); } // h_i and h_j contributions @@ -117,12 +118,25 @@ where &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSGToQUBO +where + W: crate::types::WeightElement + crate::types::NumericSize + crate::variant::VariantParam, +{ + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution .iter() .map(|&bit| if bit { 1 } else { -1 }) @@ -140,7 +154,7 @@ impl ReduceTo> for SpinGlass { fn reduce_to(&self) -> Result { let n = self.num_spins(); - let mut matrix = vec![vec![0.0; n]; n]; + let mut matrix = vec![std::collections::BTreeMap::new(); n]; // Convert using s = 2x - 1: // s_i * s_j = (2x_i - 1)(2x_j - 1) = 4x_i*x_j - 2x_i - 2x_j + 1 @@ -152,19 +166,19 @@ impl ReduceTo> for SpinGlass { // h_i * s_i = h_i * (2x_i - 1) = 2*h_i*x_i - h_i for ((i, j), j_val) in self.interactions() { // Off-diagonal: 4 * J_ij - matrix[i][j] += 4.0 * j_val; + *matrix[i].entry(j).or_insert(0.0) += 4.0 * j_val; // Diagonal contributions: -2 * J_ij - matrix[i][i] -= 2.0 * j_val; - matrix[j][j] -= 2.0 * j_val; + *matrix[i].entry(i).or_insert(0.0) -= 2.0 * j_val; + *matrix[j].entry(j).or_insert(0.0) -= 2.0 * j_val; } // Convert h fields to diagonal for (i, &h) in self.fields().iter().enumerate() { // h_i * s_i -> 2*h_i*x_i - matrix[i][i] += 2.0 * h; + *matrix[i].entry(i).or_insert(0.0) += 2.0 * h; } - let target = QUBO::from_matrix(matrix).map_err(|message| { + let target = QUBO::from_rows(matrix).map_err(|message| { crate::rules::ReductionError::construction::, QUBO>( message, ) @@ -184,7 +198,7 @@ impl ReduceTo> for SpinGlass { fn reduce_to(&self) -> Result { let n = self.num_spins(); - let mut matrix = vec![vec![0_i64; n]; n]; + let mut matrix = vec![std::collections::BTreeMap::new(); n]; let overflow = |operation| { crate::rules::ReductionError::integer_overflow::, QUBO>( operation, @@ -195,16 +209,19 @@ impl ReduceTo> for SpinGlass { let interaction = coupling .checked_mul(4) .ok_or_else(|| overflow("scaling a spin-glass interaction"))?; - matrix[i][j] = matrix[i][j] + let coefficient = matrix[i].entry(j).or_insert(0i64); + *coefficient = coefficient .checked_add(interaction) .ok_or_else(|| overflow("summing QUBO interaction coefficients"))?; let diagonal = coupling .checked_mul(2) .ok_or_else(|| overflow("scaling a spin-glass diagonal contribution"))?; - matrix[i][i] = matrix[i][i] + let coefficient = matrix[i].entry(i).or_insert(0i64); + *coefficient = coefficient .checked_sub(diagonal) .ok_or_else(|| overflow("summing QUBO diagonal coefficients"))?; - matrix[j][j] = matrix[j][j] + let coefficient = matrix[j].entry(j).or_insert(0i64); + *coefficient = coefficient .checked_sub(diagonal) .ok_or_else(|| overflow("summing QUBO diagonal coefficients"))?; } @@ -213,14 +230,15 @@ impl ReduceTo> for SpinGlass { let diagonal = field .checked_mul(2) .ok_or_else(|| overflow("scaling a spin-glass field"))?; - matrix[i][i] = matrix[i][i] + let coefficient = matrix[i].entry(i).or_insert(0i64); + *coefficient = coefficient .checked_add(diagonal) .ok_or_else(|| overflow("summing QUBO diagonal coefficients"))?; } Ok(ReductionSGToQUBO { target: - QUBO::from_matrix(matrix).map_err( + QUBO::from_rows(matrix).map_err( crate::rules::ReductionError::construction::< SpinGlass, QUBO, diff --git a/src/rules/stackercrane_ilp.rs b/src/rules/stackercrane_ilp.rs index 5e9a0d7d9..93996eec7 100644 --- a/src/rules/stackercrane_ilp.rs +++ b/src/rules/stackercrane_ilp.rs @@ -8,7 +8,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::StackerCrane; use crate::reduction; use crate::rules::ilp_helpers::{mccormick_product, one_hot_decode}; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing StackerCrane to ILP. /// @@ -31,15 +32,25 @@ impl ReductionResult for ReductionSCToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Decode the permutation: for each position p, find the arc a with x_{a,p} = 1 - one_hot_decode(target_solution, self.num_arcs, self.num_arcs, 0)? + one_hot_decode(target_solution, self.num_arcs, self.num_arcs, 0) }) } } diff --git a/src/rules/steinertree_ilp.rs b/src/rules/steinertree_ilp.rs index e19b19e68..7f7eeaf0c 100644 --- a/src/rules/steinertree_ilp.rs +++ b/src/rules/steinertree_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::SteinerTree; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Binary layout: m edge selectors, n vertex selectors, then 2m flow arcs @@ -26,18 +27,22 @@ impl ReductionResult for ReductionSteinerTreeToILP { &self.target } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionSteinerTreeToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_edges] .iter() .map(|&value| value == 1) @@ -61,7 +66,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 +137,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/steinertreeingraphs_ilp.rs b/src/rules/steinertreeingraphs_ilp.rs deleted file mode 100644 index af256448f..000000000 --- a/src/rules/steinertreeingraphs_ilp.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Reduction from SteinerTreeInGraphs to ILP (Integer Linear Programming). -//! -//! Uses the rooted multi-commodity flow formulation: -//! - Variables: binary edge selectors `y_e` plus binary directed flow variables -//! `f^t_(u,v)` for each non-root terminal `t` -//! - Constraints: flow conservation and capacity linking `f^t_(u,v) <= y_e` -//! - Objective: minimize total weight of selected edges - -use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; -use crate::models::graph::SteinerTreeInGraphs; -use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::topology::{Graph, SimpleGraph}; -use crate::types::WeightElement; - -/// Result of reducing SteinerTreeInGraphs to ILP. -/// -/// Variable layout (all binary): -/// - `y_e` for each undirected source edge `e` (indices `0..m`) -/// - `f^t_(u,v)` and `f^t_(v,u)` for each non-root terminal `t` and each edge -/// (indices `m..m + 2m(k-1)`) -#[derive(Debug, Clone)] -pub struct ReductionSTIGToILP { - target: ILP, - num_edges: usize, -} - -impl ReductionResult for ReductionSTIGToILP { - type Source = SteinerTreeInGraphs; - type Target = ILP; - - fn target_problem(&self) -> &ILP { - &self.target - } - - fn extract_solution( - &self, - target_solution: &::Solution, - ) -> 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()) - } -} - -#[reduction( - transform = exact { - num_vars = "num_edges + 2 * num_edges * (num_terminals - 1)", - num_constraints = "num_vertices * (num_terminals - 1) + 2 * num_edges * (num_terminals - 1)", - }, - unavailable = { - num_nonzeros = "the exact target parameter is not represented by this reduction's symbolic transform", - } -)] -impl ReduceTo> for SteinerTreeInGraphs { - type Result = ReductionSTIGToILP; - - fn reduce_to(&self) -> Result { - if self.weights().iter().any(|&weight| weight <= 0) { - return Err(crate::rules::ReductionError::invalid_target::< - SteinerTreeInGraphs, - ILP, - >( - "ILP construction requires strictly positive edge weights" - )); - } - - let n = self.num_vertices(); - let m = self.num_edges(); - let root = *self.terminals().first().ok_or_else(|| { - crate::rules::ReductionError::invalid_target::< - SteinerTreeInGraphs, - ILP, - >("source must contain at least one terminal") - })?; - let non_root_terminals = &self.terminals()[1..]; - let edges = self.graph().edges(); - let num_vars = m + 2 * m * non_root_terminals.len(); - let mut constraints = Vec::new(); - - let edge_var = |edge_idx: usize| edge_idx; - let flow_var = |terminal_pos: usize, edge_idx: usize, dir: usize| -> usize { - m + terminal_pos * 2 * m + 2 * edge_idx + dir - }; - - // Flow conservation for each non-root terminal commodity - for (terminal_pos, &terminal) in non_root_terminals.iter().enumerate() { - for vertex in 0..n { - let mut terms = Vec::new(); - for (edge_idx, &(u, v)) in edges.iter().enumerate() { - if v == vertex { - terms.push((flow_var(terminal_pos, edge_idx, 0), 1)); - terms.push((flow_var(terminal_pos, edge_idx, 1), -1)); - } - if u == vertex { - terms.push((flow_var(terminal_pos, edge_idx, 0), -1)); - terms.push((flow_var(terminal_pos, edge_idx, 1), 1)); - } - } - - let rhs = if vertex == root { - -1 - } else if vertex == terminal { - 1 - } else { - 0 - }; - constraints.push(LinearConstraint::eq(terms, rhs)); - } - } - - // Capacity linking: f^t_{e,dir} <= y_e - for terminal_pos in 0..non_root_terminals.len() { - for edge_idx in 0..m { - let selector = edge_var(edge_idx); - constraints.push(LinearConstraint::le( - vec![(flow_var(terminal_pos, edge_idx, 0), 1), (selector, -1)], - 0, - )); - constraints.push(LinearConstraint::le( - vec![(flow_var(terminal_pos, edge_idx, 1), 1), (selector, -1)], - 0, - )); - } - } - - // Objective: minimize total weight - let edge_weights = self.weights(); - let objective: Vec<(usize, i64)> = edge_weights - .iter() - .enumerate() - .map(|(edge_idx, weight)| (edge_var(edge_idx), weight.to_sum())) - .collect(); - - let target = ILP::new(num_vars, constraints, objective, ObjectiveSense::Minimize) - .map_err(Self::target_construction)?; - - Ok(ReductionSTIGToILP { - target, - num_edges: m, - }) - } -} - -#[cfg(feature = "example-db")] -pub(crate) fn canonical_rule_example_specs() -> Vec { - vec![crate::example_db::specs::RuleExampleSpec { - id: "steinertreeingraphs_to_ilp", - build: || { - // 4 vertices, 4 edges, 2 terminals - // ILP: 4 + 2*4*1 = 12 binary variables = 4096 configs - let source = SteinerTreeInGraphs::new( - SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 3)]), - vec![0, 2], - vec![1, 1, 1, 3], - ); - crate::example_db::specs::rule_example_via_ilp::<_, bool>(source) - }, - }] -} - -#[cfg(test)] -#[path = "../unit_tests/rules/steinertreeingraphs_ilp.rs"] -mod tests; diff --git a/src/rules/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index 1f6cf33d2..0d5cdd125 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::StringToStringCorrection; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing StringToStringCorrection to ILP. #[derive(Debug, Clone)] @@ -54,12 +55,22 @@ impl ReductionResult for ReductionSTSCToILP { } /// Extract operation sequence from ILP solution. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSTSCToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.n; let k = self.bound; @@ -88,19 +99,7 @@ impl ReductionResult for ReductionSTSCToILP { .filter(|&j| target_solution[idx_s(n, k, t, j)] == 1) .map(|j| current_len + j), ); - match selected.as_slice() { - [operation] => ops.push(*operation), - [] => { - return Err(crate::rules::ExtractionError::invalid(format!( - "edit step {t} has no selected operation" - ))) - } - _ => { - return Err(crate::rules::ExtractionError::invalid(format!( - "edit step {t} has multiple selected operations" - ))) - } - } + ops.push(selected.into_iter().sum()); } ops }) @@ -391,6 +390,7 @@ impl ReduceTo> for StringToStringCorrection { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; vec![crate::example_db::specs::RuleExampleSpec { id: "stringtostringcorrection_to_ilp", build: || { @@ -404,7 +404,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/strongconnectivityaugmentation_ilp.rs b/src/rules/strongconnectivityaugmentation_ilp.rs index 02bb8a4e3..c8d528657 100644 --- a/src/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/rules/strongconnectivityaugmentation_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::StrongConnectivityAugmentation; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionSCAToILP { @@ -23,12 +24,22 @@ impl ReductionResult for ReductionSCAToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSCAToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_candidates] .iter() .map(|&value| value == 1) @@ -185,6 +196,7 @@ impl ReduceTo> for StrongConnectivityAugmentation { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; use crate::topology::DirectedGraph; vec![crate::example_db::specs::RuleExampleSpec { id: "strongconnectivityaugmentation_to_ilp", @@ -201,7 +213,13 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/subgraphisomorphism_ilp.rs b/src/rules/subgraphisomorphism_ilp.rs index d8c6c3f4b..fd67e5128 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -11,7 +11,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::SubgraphIsomorphism; use crate::reduction; use crate::rules::ilp_helpers::{one_hot_assignment_constraints, one_hot_decode_rows}; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::Graph; /// Result of reducing SubgraphIsomorphism to ILP. @@ -34,18 +35,28 @@ impl ReductionResult for ReductionSubIsoToILP { } /// Extract: for each pattern vertex v, output the unique host vertex u with x_{v,u} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - one_hot_decode_rows( +impl ReductionSubIsoToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(one_hot_decode_rows( target_solution, self.num_pattern_vertices, self.num_host_vertices, 0, - ) + )) } } diff --git a/src/rules/subsetsum_closestvectorproblem.rs b/src/rules/subsetsum_closestvectorproblem.rs index dd4762df5..7d0726e92 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -1,40 +1,43 @@ //! 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}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// 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 } - fn extract_solution( + fn recover_result( &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", - )); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} + +impl ReductionSubsetSumToClosestVectorProblem { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.num_elements] .iter() .map(|&value| value == 1) @@ -42,19 +45,6 @@ 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))) - } -} - impl ReductionSubsetSumToClosestVectorProblem { /// Check the dense representation before allocating its columns. fn dimensions( @@ -62,9 +52,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 +72,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 +102,7 @@ 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. for bit in (1..bits).rev() { let mut column = vec![0_i64; rows]; column[rows - 1 - bit] = 1; @@ -126,22 +114,17 @@ 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 = ClosestVectorProblem::new(basis, target) - .map_err(>>::target_construction)?; + .map_err(>>::target_construction)?; Ok(ReductionSubsetSumToClosestVectorProblem { - target, + target: Decision::new( + target, + >>::exact_i64( + n, + "representing the subset count", + )?, + ), num_elements: n, - target_distance, }) } } @@ -153,7 +136,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..cd7c65c69 100644 --- a/src/rules/subsetsum_integerexpressionmembership.rs +++ b/src/rules/subsetsum_integerexpressionmembership.rs @@ -1,7 +1,8 @@ use crate::models::misc::SubsetSum; use crate::models::misc::{IntExpr, IntegerExpressionMembership}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use num_traits::ToPrimitive; #[derive(Debug, Clone)] @@ -17,12 +18,22 @@ impl ReductionResult for ReductionSubsetSumToIntegerExpressionMembership { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSubsetSumToIntegerExpressionMembership { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Union choice 0 = left = Atom(1) = exclude, choice 1 = right = Atom(s_i+1) = include. // This maps directly to SubsetSum's 0/1 include/exclude encoding. diff --git a/src/rules/subsetsum_integerknapsack.rs b/src/rules/subsetsum_integerknapsack.rs index 1a244f968..72f89dd63 100644 --- a/src/rules/subsetsum_integerknapsack.rs +++ b/src/rules/subsetsum_integerknapsack.rs @@ -41,7 +41,6 @@ inventory::submit! { }, module_path: module_path!(), reduce_fn: None, - reduce_aggregate_fn: None, turing: false, } } diff --git a/src/rules/subsetsum_partition.rs b/src/rules/subsetsum_partition.rs index 8b1e2726f..6d085be04 100644 --- a/src/rules/subsetsum_partition.rs +++ b/src/rules/subsetsum_partition.rs @@ -2,7 +2,8 @@ use crate::models::misc::{Partition, SubsetSum}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use num_bigint::BigUint; use num_traits::ToPrimitive; use std::cmp::Ordering; @@ -30,12 +31,22 @@ impl ReductionResult for ReductionSubsetSumToPartition { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionSubsetSumToPartition { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let source_bits = &target_solution[..self.source_len]; diff --git a/src/rules/sumofsquarespartition_ilp.rs b/src/rules/sumofsquarespartition_ilp.rs index 1d82db321..36da2363e 100644 --- a/src/rules/sumofsquarespartition_ilp.rs +++ b/src/rules/sumofsquarespartition_ilp.rs @@ -20,7 +20,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::SumOfSquaresPartition; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing SumOfSquaresPartition to ILP. /// @@ -57,18 +58,28 @@ impl ReductionResult for ReductionSSPToILP { } /// Extract solution: for each element i, find the unique group g where x_{i,g} = 1. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - crate::rules::ilp_helpers::one_hot_decode_rows( +impl ReductionSSPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + Ok(crate::rules::ilp_helpers::one_hot_decode_rows( target_solution, self.num_elements, self.num_groups, 0, - ) + )) } } diff --git a/src/rules/test_helpers.rs b/src/rules/test_helpers.rs index 620f17233..11f085180 100644 --- a/src/rules/test_helpers.rs +++ b/src/rules/test_helpers.rs @@ -1,7 +1,8 @@ use crate::rules::{ReductionChain, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolutionAggregate; +use crate::solvers::SolveOutcome; use crate::traits::Problem; -use crate::types::SolutionAggregate; use std::collections::HashSet; fn verify_optimization_round_trip( @@ -14,7 +15,7 @@ fn verify_optimization_round_trip( Source: Problem + 'static, Source::Solution: Eq + std::hash::Hash + std::fmt::Debug + 'static, ::Value: SolutionAggregate + std::fmt::Debug + PartialEq, - Extract: Fn(&TargetSolution) -> Source::Solution, + Extract: Fn(TargetSolution) -> Source::Solution, { assert!( !target_solutions.is_empty(), @@ -39,7 +40,7 @@ fn verify_optimization_round_trip( .expect("reference set is non-empty"), ); let extracted: HashSet = - target_solutions.iter().map(extract_solution).collect(); + target_solutions.into_iter().map(extract_solution).collect(); assert!( !extracted.is_empty(), "{context}: no extracted source solutions" @@ -67,14 +68,14 @@ fn verify_satisfaction_round_trip( Source: Problem + 'static, Source::Solution: Eq + std::hash::Hash + std::fmt::Debug + 'static, ::Value: SolutionAggregate + std::fmt::Debug, - Extract: Fn(&TargetSolution) -> Source::Solution, + Extract: Fn(TargetSolution) -> Source::Solution, { assert!( !target_solutions.is_empty(), "{context}: target solver found no {target_solution_kind} solutions" ); let extracted: HashSet = - target_solutions.iter().map(extract_solution).collect(); + target_solutions.into_iter().map(extract_solution).collect(); assert!( !extracted.is_empty(), "{context}: no extracted source solutions" @@ -113,7 +114,16 @@ pub(crate) fn assert_optimization_round_trip_from_optimization_target( verify_optimization_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution).unwrap(), + |target_solution| { + reduction + .recover_result( + source, + SolveOutcome::optimal(reduction.target_problem(), target_solution).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }, "optimal", context, ); @@ -138,7 +148,16 @@ pub(crate) fn assert_optimization_round_trip_from_satisfaction_target( verify_optimization_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution).unwrap(), + |target_solution| { + reduction + .recover_result( + source, + SolveOutcome::optimal(reduction.target_problem(), target_solution).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }, "satisfying", context, ); @@ -164,7 +183,12 @@ pub(crate) fn assert_optimization_round_trip_chain( target_solutions, |target_solution| { chain - .extract_solution::(target_solution) + .recover_result::( + source, + SolveOutcome::optimal(chain.target_problem::(), target_solution) + .unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) .unwrap() }, "optimal", @@ -191,7 +215,16 @@ pub(crate) fn assert_satisfaction_round_trip_from_optimization_target( verify_satisfaction_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution).unwrap(), + |target_solution| { + reduction + .recover_result( + source, + SolveOutcome::optimal(reduction.target_problem(), target_solution).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }, "optimal", context, ); @@ -216,7 +249,16 @@ pub(crate) fn assert_satisfaction_round_trip_from_satisfaction_target( verify_satisfaction_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution).unwrap(), + |target_solution| { + reduction + .recover_result( + source, + SolveOutcome::optimal(reduction.target_problem(), target_solution).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }, "satisfying", context, ); @@ -227,6 +269,7 @@ where R: ReductionResult, R::Source: Problem + 'static, R::Target: Problem> + 'static, + ::Value: SolutionAggregate, ::Value: SolutionAggregate + std::fmt::Debug + PartialEq, { use crate::solvers::ILPSolver; @@ -238,10 +281,42 @@ where let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), bf_value); } +/// Assert that a rule without an incumbent guarantee refuses a valid target +/// candidate that the supplied target optimum strictly improves on. +pub(crate) fn assert_suboptimal_feasible_target_is_insufficient( + source: &R::Source, + reduction: &R, + candidate: ::Solution, + optimum: &::Solution, +) where + R: ReductionResult, + ::Value: std::fmt::Debug + PartialEq, +{ + let target = reduction.target_problem(); + assert_ne!( + target.evaluate(&candidate).unwrap(), + target.evaluate(optimum).unwrap(), + "candidate must be strictly suboptimal" + ); + let candidate = SolveOutcome::feasible(target, candidate) + .expect("candidate must be a valid target solution"); + assert!(matches!( + reduction.recover_result(source, candidate), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); +} + #[cfg(test)] mod tests { use super::{ @@ -250,7 +325,9 @@ mod tests { assert_satisfaction_round_trip_from_optimization_target, assert_satisfaction_round_trip_from_satisfaction_target, }; + use crate::rules::traits::recover_preserving_status; use crate::rules::ReductionResult; + use crate::solvers::ProblemOutcome; use crate::traits::Problem; use crate::types::{Max, Or}; @@ -288,8 +365,12 @@ mod tests { } impl crate::solvers::BruteForceProblem for ToyExtremumProblem { - fn dimensions(&self) -> Vec { - vec![2, 2] + fn num_variables(&self) -> Result { + Ok(2usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([2, 2][variable]) } } @@ -322,8 +403,12 @@ mod tests { } impl crate::solvers::BruteForceProblem for ToyOrProblem { - fn dimensions(&self) -> Vec { - vec![2, 2] + fn num_variables(&self) -> Result { + Ok(2usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([2, 2][variable]) } } @@ -375,13 +460,22 @@ mod tests { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> - { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } + } + impl OptToOptReduction { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } @@ -398,13 +492,22 @@ mod tests { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> - { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } + } + impl OptToSatReduction { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } @@ -421,13 +524,22 @@ mod tests { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> - { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } + } + impl SatToOptReduction { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } @@ -444,13 +556,22 @@ mod tests { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> - { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } + } + impl SatToSatReduction { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_ilp.rs b/src/rules/threedimensionalmatching_ilp.rs index 57db1ddc7..3f62365a2 100644 --- a/src/rules/threedimensionalmatching_ilp.rs +++ b/src/rules/threedimensionalmatching_ilp.rs @@ -3,7 +3,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::ThreeDimensionalMatching; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone)] pub struct ReductionThreeDimensionalMatchingToILP { @@ -18,13 +19,14 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToILP { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.iter().map(|&value| value == 1).collect()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| { + Ok(solution.iter().map(|&value| value == 1).collect()) + }) } } diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index c8239dae0..d661b2695 100644 --- a/src/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -11,9 +11,9 @@ //! //! `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 }`. Thus recovery of an optimum yields +//! either an exact matching or `Infeasible`. A candidate without optimality +//! that does not decode to a matching returns `InsufficientSolutionQuality`. //! //! **Sentinel branch.** `MinimumWeightDecoding::new` panics on zero-row or //! zero-column matrices, so degenerate inputs (`q = 0` or `T = []`) emit a @@ -24,7 +24,8 @@ use crate::models::algebraic::MinimumWeightDecoding; use crate::models::set::ThreeDimensionalMatching; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_by_source_evaluation, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ThreeDimensionalMatching to MinimumWeightDecoding. #[derive(Debug, Clone)] @@ -47,19 +48,22 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin /// The target codeword prefix is the source subset indicator over the same /// triple index set. The sentinel target appends one synthetic column, so /// an empty source maps back to the empty prefix. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - if target_solution.len() != self.target.num_cols() { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} target codeword bits, got {}", - self.target.num_cols(), - target_solution.len() - ))); - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_by_source_evaluation(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionThreeDimensionalMatchingToMinimumWeightDecoding { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution[..self.source_num_triples].to_vec()) } } diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index 03a6ce030..937291d57 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -8,8 +8,8 @@ use crate::models::misc::ThreePartition; use crate::models::set::ThreeDimensionalMatching; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; -use std::collections::HashMap; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; #[derive(Debug, Clone, Copy)] enum Step2Item { @@ -33,18 +33,6 @@ enum Step2Item { }, } -#[derive(Debug, Clone, Copy)] -enum PairingKind { - U, - UPrime, -} - -#[derive(Debug, Default, Clone, Copy)] -struct PairUsage { - saw_u: bool, - uprime_regulars: Option<[usize; 2]>, -} - /// Result of reducing ThreeDimensionalMatching to ThreePartition. #[derive(Debug, Clone)] pub struct ReductionThreeDimensionalMatchingToThreePartition { @@ -67,27 +55,6 @@ impl ReductionThreeDimensionalMatchingToThreePartition { self.pairing_start() + 2 * self.pair_keys.len() } - fn classify_target_element(&self, element_index: usize) -> TargetElement { - if element_index < self.num_regulars() { - return TargetElement::Regular { - step2_index: element_index, - }; - } - - if element_index < self.filler_start() { - let pairing_offset = element_index - self.pairing_start(); - let pair_index = pairing_offset / 2; - let kind = if pairing_offset.is_multiple_of(2) { - PairingKind::U - } else { - PairingKind::UPrime - }; - return TargetElement::Pairing { pair_index, kind }; - } - - TargetElement::Filler - } - fn decode_real_group(&self, step2_group: [usize; 4]) -> Option { let mut a_item = None; let mut b_item = None; @@ -143,6 +110,8 @@ impl ReductionThreeDimensionalMatchingToThreePartition { #[cfg(test)] fn build_target_witness(&self, source_solution: &[usize]) -> Vec { + use std::collections::HashMap; + let mut a_indices = vec![0usize; self.num_source_triples]; let mut first_b_by_w = HashMap::new(); let mut first_c_by_x = HashMap::new(); @@ -294,102 +263,73 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreePartition { /// Reverse the 4-Partition -> 3-Partition pairing gadget, then decode the /// surviving real ABCD groups back into selected source triples. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - let mut groups = vec![Vec::new(); self.target.num_groups()]; - for (element_index, &group_index) in target_solution.iter().enumerate() { - groups[group_index].push(element_index); - } - - let mut pair_usage: HashMap<(usize, usize), PairUsage> = HashMap::new(); - - for members in groups.into_iter().filter(|members| !members.is_empty()) { - let mut regulars = Vec::new(); - let mut pairing = None; - let mut has_filler = false; - - for element_index in members { - match self.classify_target_element(element_index) { - TargetElement::Regular { step2_index } => regulars.push(step2_index), - TargetElement::Pairing { pair_index, kind } => { - pairing = Some((pair_index, kind)) - } - TargetElement::Filler => has_filler = true, - } - } - - if has_filler || regulars.len() != 2 { - continue; - } + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} - let Some((pair_index, kind)) = pairing else { - continue; - }; +impl ReductionThreeDimensionalMatchingToThreePartition { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + if self.num_source_triples == 0 { + return Ok(Vec::new()); + } + let mut groups = vec![Vec::with_capacity(3); self.target.num_groups()]; + let mut positions = Vec::with_capacity(target_solution.len()); + for (element, &group) in target_solution.iter().enumerate() { + positions.push((group, groups[group].len())); + groups[group].push(element); + } - let pair_key = self.pair_keys[pair_index]; - let regular_pair = sorted_pair(regulars[0], regulars[1]); - let usage = pair_usage.entry(pair_key).or_default(); + // Garey--Johnson's reverse construction first normalizes filler triples. + // Each has two pairing elements whose sum equals that of an original + // U/UPrime pair. Exchange the second element with the first's mate: + // they have equal sizes, so both affected triples remain valid. A mate + // cannot belong to an already normalized filler triple unless it is + // already here, so each iteration permanently normalizes one triple. + // Initial index order puts regulars first and fillers last; exchanges + // only move pairing elements, preserving those positions. + let pairing_start = self.pairing_start(); + for filler in self.filler_start()..target_solution.len() { + let (group, _) = positions[filler]; + let first = groups[group][0]; + let second = groups[group][1]; + let mate = pairing_start + ((first - pairing_start) ^ 1); + let (mate_group, mate_slot) = positions[mate]; + groups[group][1] = mate; + groups[mate_group][mate_slot] = second; + positions[mate] = (group, 1); + positions[second] = (mate_group, mate_slot); + } - match kind { - PairingKind::U => { - if regular_pair == [pair_key.0, pair_key.1] { - usage.saw_u = true; - } - } - PairingKind::UPrime => { - usage.uprime_regulars = Some(regular_pair); - } - } + let mut source_solution = vec![false; self.num_source_triples]; + for first in (pairing_start..self.filler_start()).step_by(2) { + let (left, _) = positions[first]; + if groups[left][0] >= pairing_start { + continue; // This complete pair is used by a filler triple. } - - let mut source_solution = vec![false; self.num_source_triples]; - - for ((left, right), usage) in pair_usage { - let Some(other_two) = usage.uprime_regulars else { - continue; - }; - if !usage.saw_u { - continue; - } - - let mut group = [left, right, other_two[0], other_two[1]]; - group.sort_unstable(); - if group.windows(2).any(|window| window[0] == window[1]) { - continue; - } - - if let Some(source_triple) = self.decode_real_group(group) { - source_solution[source_triple] = true; - } + let (right, _) = positions[first + 1]; + // Use the actual regular elements, not the pair's construction + // indices: equal-valued pairing elements are interchangeable. + let regulars = [ + groups[left][0], + groups[left][1], + groups[right][0], + groups[right][1], + ]; + if let Some(source_triple) = self.decode_real_group(regulars) { + source_solution[source_triple] = true; } - - source_solution - }) - } -} - -#[derive(Debug, Clone, Copy)] -enum TargetElement { - Regular { - step2_index: usize, - }, - Pairing { - pair_index: usize, - kind: PairingKind, - }, - Filler, -} - -fn sorted_pair(a: usize, b: usize) -> [usize; 2] { - if a <= b { - [a, b] - } else { - [b, a] + } + Ok(source_solution) } } @@ -407,9 +347,9 @@ fn enumerate_pair_keys(num_regulars: usize) -> Option> { } #[reduction( - transform = exact { - num_elements = "24 * num_triples * num_triples - 3 * num_triples", - num_groups = "8 * num_triples * num_triples - num_triples", + transform = upper_bound { + num_elements = "24 * num_triples * num_triples - 3 * num_triples + 6", + num_groups = "8 * num_triples * num_triples - num_triples + 2", })] impl ReduceTo for ThreeDimensionalMatching { type Result = ReductionThreeDimensionalMatchingToThreePartition; @@ -419,16 +359,13 @@ impl ReduceTo for ThreeDimensionalMatching { let t = self.num_triples(); if q == 0 { - return Err(crate::rules::ReductionError::invalid_target::< - ThreeDimensionalMatching, - ThreePartition, - >("source universe must be nonempty")); - } - if t == 0 { - return Err(crate::rules::ReductionError::invalid_target::< - ThreeDimensionalMatching, - ThreePartition, - >("source must contain at least one triple")); + // The empty matching covers the empty universe. + return Ok(ReductionThreeDimensionalMatchingToThreePartition { + target: ThreePartition::new(vec![1, 1, 1], 3), + step2_items: Vec::new(), + pair_keys: Vec::new(), + num_source_triples: 0, + }); } let mut covered_w = vec![false; q]; @@ -639,7 +576,8 @@ impl ReduceTo for ThreeDimensionalMatching { .ok_or_else(|| arithmetic_overflow("computing the 3-Partition bound"))?; Ok(ReductionThreeDimensionalMatchingToThreePartition { - target: ThreePartition::new(sizes, bound), + target: ThreePartition::try_new(sizes, bound) + .map_err(>::target_construction)?, step2_items, pair_keys, num_source_triples: t, diff --git a/src/rules/threepartition_resourceconstrainedscheduling.rs b/src/rules/threepartition_resourceconstrainedscheduling.rs index 817ace389..555ced049 100644 --- a/src/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/rules/threepartition_resourceconstrainedscheduling.rs @@ -20,7 +20,8 @@ use crate::models::misc::{ResourceConstrainedScheduling, ThreePartition}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing ThreePartition to ResourceConstrainedScheduling. #[derive(Debug, Clone)] @@ -38,13 +39,12 @@ impl ReductionResult for ReductionThreePartitionToRCS { /// Solution extraction: identity mapping. /// ThreePartition config (group index 0..m-1) maps directly to time slot assignment. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok(target_solution.to_vec()) + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| Ok(solution.clone())) } } diff --git a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index d1b480304..605fa3809 100644 --- a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -13,7 +13,8 @@ use crate::models::misc::{SequencingWithReleaseTimesAndDeadlines, ThreePartition}; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Number of element tasks (= source.num_elements() = 3m). fn num_element_tasks(source: &ThreePartition) -> usize { @@ -47,36 +48,35 @@ impl ReductionResult for ReductionThreePartitionToSRTD { /// /// Simulate the task permutation to find each task's start time, then assign each element task to its slot /// based on start_time / (B + 1). - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionThreePartitionToSRTD { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ // Simulate the schedule to find start times let mut current_time: i64 = 0; let mut slot_assignment = vec![0usize; self.num_element_tasks]; - let slot_width = self.bound.checked_add(1).ok_or_else(|| { - crate::rules::ExtractionError::invalid("slot width overflows i64") - })?; // B + 1 (slot width including the filler gap) + let slot_width = self.bound + 1; for &task in target_solution { let start = current_time.max(self.target.release_times()[task]); - let finish = start - .checked_add(self.target.lengths()[task]) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid("task finish time overflows i64") - })?; - current_time = finish; + current_time = start + self.target.lengths()[task]; // Only element tasks (indices 0..3m) contribute to the partition if task < self.num_element_tasks { - let slot = usize::try_from(start / slot_width).map_err(|_| { - crate::rules::ExtractionError::invalid( - "decoded task slot cannot be represented as usize", - ) - })?; + let slot = (start / slot_width) as usize; slot_assignment[task] = slot; } } diff --git a/src/rules/timetabledesign_ilp.rs b/src/rules/timetabledesign_ilp.rs index 9e771d48e..2df404575 100644 --- a/src/rules/timetabledesign_ilp.rs +++ b/src/rules/timetabledesign_ilp.rs @@ -7,7 +7,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::TimetableDesign; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; /// Result of reducing TimetableDesign to `ILP`. /// @@ -31,12 +32,22 @@ impl ReductionResult for ReductionTDToILP { /// Extract: direct identity mapping — the ILP variable layout matches the /// source configuration layout exactly. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionTDToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok((0..self.num_craftsmen) .map(|craftsman| { (0..self.num_tasks) diff --git a/src/rules/traits.rs b/src/rules/traits.rs index c33c39754..36908b129 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -1,8 +1,7 @@ //! Core traits for problem reductions. -use crate::traits::Problem; -use serde::de::DeserializeOwned; -use serde::Serialize; +use crate::solvers::{ProblemOutcome, SolveOutcome}; +use crate::traits::{EvaluationValue, Problem}; use std::any::Any; use std::marker::PhantomData; @@ -129,6 +128,17 @@ impl ReductionError { /// Failure to map a target witness back into the source configuration space. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ExtractionError { + #[error("the target result does not establish the conditions required for source recovery")] + InsufficientSolutionQuality, + /// [`Self::InsufficientSolutionQuality`] located at the reduction that reported it. + #[error( + "{source_problem} -> {target_problem}: {}", + Self::InsufficientSolutionQuality + )] + InsufficientSolutionQualityAt { + source_problem: &'static str, + target_problem: &'static str, + }, #[error("{0}")] InvalidTargetSolution(String), #[error("{source_problem} -> {target_problem}: {message}")] @@ -137,7 +147,7 @@ pub enum ExtractionError { target_problem: &'static str, message: String, }, - #[error("target evaluation failed during extraction: {0}")] + #[error("problem evaluation failed during recovery: {0}")] Evaluation(#[from] crate::traits::EvaluationError), } @@ -146,6 +156,14 @@ impl ExtractionError { Self::InvalidTargetSolution(message.into()) } + /// Whether the target result was too weak for recovery, with or without edge context. + pub fn is_insufficient_quality(&self) -> bool { + matches!( + self, + Self::InsufficientSolutionQuality | Self::InsufficientSolutionQualityAt { .. } + ) + } + fn for_reduction(self) -> Self { match self { Self::InvalidTargetSolution(message) => Self::Reduction { @@ -153,6 +171,10 @@ impl ExtractionError { target_problem: T::NAME, message, }, + Self::InsufficientSolutionQuality => Self::InsufficientSolutionQualityAt { + source_problem: S::NAME, + target_problem: T::NAME, + }, error => error, } } @@ -160,38 +182,85 @@ impl ExtractionError { pub type ExtractionResult = std::result::Result; -/// Validate a typed target solution and return its evaluated value for reuse. -pub(crate) fn validate_target_solution( - target: &P, - solution: &P::Solution, -) -> ExtractionResult { - Ok(target.evaluate(solution)?) -} - /// 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. +/// Stores the target and recovers complete source results using the executed mapping. pub trait ReductionResult { /// The source problem type. - type Source: Problem; + type Source: Problem; /// The target problem type. - type Target: Problem; + type Target: Problem; /// Get a reference to the target problem. fn target_problem(&self) -> &Self::Target; - /// Extract a solution from target problem space to source problem space. - /// - /// # Arguments - /// * `target_solution` - A solution to the target problem - /// - /// # Returns - /// The corresponding solution in the source problem space - fn extract_solution( + /// Recover the complete source result using this execution's mathematical relation. + /// `source` must be the instance used to construct this reduction result. + /// Optimal results must preserve optimality or prove source infeasibility. + /// Feasible incumbents may establish only what the rule proves; insufficient + /// witness quality is an error, never evidence of source infeasibility. + fn recover_result( &self, - target_solution: &::Solution, - ) -> ExtractionResult<::Solution>; + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> ExtractionResult>; +} + +/// Recover using a rule that preserves optimality, feasibility, and infeasibility. +/// +/// The caller must establish all three implications for its mathematical mapping. +/// Rules requiring optimum thresholds or rejecting feasible incumbents must instead +/// interpret those outcomes in their own `recover_result` implementation. +/// Source evaluation errors propagate; they never establish source infeasibility. +pub(super) fn recover_preserving_status, S, V>( + source: &P, + target: SolveOutcome, + map_solution: impl FnOnce(&S) -> ExtractionResult, +) -> ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => { + Ok(SolveOutcome::optimal(source, map_solution(&solution)?)?) + } + SolveOutcome::Feasible { solution, .. } => { + Ok(SolveOutcome::feasible(source, map_solution(&solution)?)?) + } + } +} + +/// Recover by evaluating the decoded target solution on the source. +/// +/// Use only when a feasible source forces EVERY tied target optimum to decode to a +/// valid source solution, so an optimal target whose decoding is source-invalid +/// proves source infeasibility. The caller must also establish that target +/// infeasibility implies source infeasibility. +/// A feasible incumbent carries no such proof: a valid decoding is a feasible source +/// solution, and an invalid one is `InsufficientSolutionQuality`. +/// Mapping and evaluation errors propagate; they never establish source infeasibility. +pub(super) fn recover_by_source_evaluation, S, V>( + source: &P, + target: SolveOutcome, + map_solution: impl FnOnce(&S) -> ExtractionResult, +) -> ExtractionResult> { + let (solution, optimal) = match &target { + SolveOutcome::Infeasible => return Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { solution, .. } => (solution, true), + SolveOutcome::Feasible { solution, .. } => (solution, false), + }; + let solution = map_solution(solution)?; + let evaluation = source.evaluate(&solution)?; + match (evaluation.is_valid(), optimal) { + (true, true) => Ok(SolveOutcome::Optimal { + solution, + evaluation, + }), + (true, false) => Ok(SolveOutcome::Feasible { + solution, + evaluation, + }), + (false, true) => Ok(SolveOutcome::Infeasible), + (false, false) => Err(ExtractionError::InsufficientSolutionQuality), + } } /// Trait for problems that can be reduced to target type T. @@ -214,12 +283,10 @@ pub trait ReductionResult { /// let reduction = sat_problem.reduce_to().expect("reduction should succeed"); /// let is_problem = reduction.target_problem(); /// -/// // Solve and extract solutions -/// let solver = BruteForce::new(); -/// let solutions = solver.find_all_witnesses(is_problem).unwrap(); -/// let sat_solutions: Vec<_> = solutions.iter() -/// .map(|s| reduction.extract_solution(s)) -/// .collect(); +/// // Solve the target and recover its complete source result. +/// let solution = BruteForce::new().solve(is_problem)?.unwrap(); +/// let target_result = SolveOutcome::optimal(is_problem, solution)?; +/// let source_result = reduction.recover_result(&sat_problem, target_result)?; /// ``` pub trait ReduceTo: Problem { /// The reduction result type. @@ -245,36 +312,6 @@ pub trait ReduceTo: Problem { fn reduce_to(&self) -> Result; } -/// Result of reducing a source problem to a target problem for aggregate values. -/// -/// Unlike [`ReductionResult`], this trait maps aggregate values back from target -/// space to source space instead of mapping witness configurations. -pub trait AggregateReductionResult { - /// The source problem type. - type Source: Problem; - /// The target problem type. - type Target: Problem; - - /// Get a reference to the target problem. - fn target_problem(&self) -> &Self::Target; - - /// Extract an aggregate value from target problem space back to source space. - fn extract_value( - &self, - target_value: ::Value, - ) -> ::Value; -} - -/// Trait for problems that can be reduced to target type T for aggregate-value -/// workflows. -pub trait ReduceToAggregate: Problem { - /// The reduction result type. - type Result: AggregateReductionResult; - - /// Reduce this problem to the target problem type. - fn reduce_to_aggregate(&self) -> Result; -} - /// Reduction result for an explicit conversion between variants of one model. /// /// The target witness is also the source witness. @@ -296,25 +333,8 @@ impl VariantReductionResult { impl ReductionResult for VariantReductionResult where - S: Problem, - T: Problem, - S::Solution: Clone, -{ - type Source = S; - type Target = T; - - fn target_problem(&self) -> &Self::Target { - &self.target - } - - fn extract_solution(&self, target_solution: &T::Solution) -> ExtractionResult { - validate_target_solution(self.target_problem(), target_solution)?; - Ok(target_solution.clone()) - } -} - -impl> AggregateReductionResult - for VariantReductionResult + S: Problem, + T: Problem, { type Source = S; type Target = T; @@ -323,129 +343,115 @@ impl> AggregateReductionResult &self.target } - fn extract_value(&self, target_value: T::Value) -> S::Value { - target_value + fn recover_result( + &self, + source: &S, + target: crate::solvers::ProblemOutcome, + ) -> ExtractionResult> { + use crate::solvers::SolveOutcome; + Ok(match target { + SolveOutcome::Optimal { solution, .. } => SolveOutcome::optimal(source, solution)?, + SolveOutcome::Feasible { solution, .. } => SolveOutcome::feasible(source, solution)?, + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + }) } } -/// Type-erased reduction result for runtime-discovered paths. -/// -/// Implemented automatically for all `ReductionResult` types via blanket impl. -/// Used internally by `ReductionChain`. +/// Type erasure for executed reduction results. Mathematical recovery remains typed. pub trait DynReductionResult { - /// 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. - fn extract_solution_dyn(&self, target_solution: &dyn Any) -> ExtractionResult>; - /// Serialize a source-space solution after the complete extraction chain. - fn source_solution_json( + fn recover_result_dyn( + &self, + source: &dyn Any, + target: crate::solvers::ErasedOutcome, + ) -> ExtractionResult; + /// Decode and evaluate once, returning the typed result and its canonical JSON. + fn target_result_from_json( &self, - source_solution: &dyn Any, - ) -> ExtractionResult; - /// Deserialize the concrete target witness at the dynamic boundary. - fn target_solution_from_json( + target: serde_json::Value, + ) -> ExtractionResult<(crate::solvers::ErasedOutcome, crate::solvers::SolveOutcome)>; + fn source_result_json( &self, - target_solution: serde_json::Value, - ) -> ExtractionResult>; + source: crate::solvers::ErasedOutcome, + ) -> ExtractionResult; } impl DynReductionResult for R where + R::Source: 'static, R::Target: 'static, - ::Solution: 'static, - ::Solution: serde::de::DeserializeOwned, - ::Solution: 'static, - ::Solution: serde::Serialize, + ::Solution: serde::de::DeserializeOwned + serde::Serialize + 'static, + ::Value: std::fmt::Display + 'static, + ::Solution: serde::Serialize + 'static, + ::Value: std::fmt::Display + 'static, { fn target_problem_any(&self) -> &dyn Any { - self.target_problem() as &dyn Any - } - fn extract_solution_dyn(&self, target_solution: &dyn Any) -> ExtractionResult> { - let target_solution = target_solution - .downcast_ref::<::Solution>() - .ok_or_else(|| { - ExtractionError::invalid(format!( - "target solution type mismatch: expected {}", - std::any::type_name::<::Solution>() - )) - })?; - self.extract_solution(target_solution) - .map(|solution| Box::new(solution) as Box) - .map_err(|error| error.for_reduction::()) - } - - fn source_solution_json( - &self, - source_solution: &dyn Any, - ) -> ExtractionResult { - let source_solution = source_solution - .downcast_ref::<::Solution>() - .ok_or_else(|| ExtractionError::invalid("source solution type mismatch"))?; - serde_json::to_value(source_solution).map_err(|error| { - ExtractionError::invalid(format!("source solution serialization failed: {error}")) - }) + self.target_problem() } - fn target_solution_from_json( + fn recover_result_dyn( &self, - target_solution: serde_json::Value, - ) -> ExtractionResult> { - serde_json::from_value::<::Solution>(target_solution) - .map(|solution| Box::new(solution) as Box) - .map_err(|error| { - ExtractionError::invalid(format!("target solution deserialization failed: {error}")) - }) + source: &dyn Any, + target: crate::solvers::ErasedOutcome, + ) -> ExtractionResult { + let source = source + .downcast_ref::() + .ok_or_else(|| ExtractionError::invalid("source problem type mismatch"))?; + let target = crate::solvers::downcast_outcome(target)?; + self.recover_result(source, target) + .map(crate::solvers::erase_outcome) + .map_err(ExtractionError::for_reduction::) } -} -/// 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. - /// 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( + fn target_result_from_json( &self, - target_solution: &dyn Any, - ) -> ExtractionResult>; -} - -impl DynAggregateReductionResult for R -where - R::Target: 'static, - ::Solution: 'static, - ::Value: Serialize + DeserializeOwned, - ::Value: Serialize + 'static, -{ - fn target_problem_any(&self) -> &dyn Any { - 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"); - let source_value = self.extract_value(target_value); - serde_json::to_value(source_value) - .expect("DynAggregateReductionResult source value serialize failed") + target: serde_json::Value, + ) -> ExtractionResult<(crate::solvers::ErasedOutcome, crate::solvers::SolveOutcome)> { + use crate::solvers::SolveOutcome; + #[derive(serde::Deserialize)] + #[serde(tag = "status", rename_all = "snake_case")] + enum Candidate { + Optimal { solution: S }, + Feasible { solution: S }, + Infeasible, + } + let reported = target.get("evaluation").cloned(); + let candidate = serde_json::from_value(target).map_err(|error| { + ExtractionError::invalid(format!("invalid target result JSON: {error}")) + })?; + let target = match candidate { + Candidate::Optimal { solution } => { + SolveOutcome::optimal(self.target_problem(), solution)? + } + Candidate::Feasible { solution } => { + SolveOutcome::feasible(self.target_problem(), solution)? + } + Candidate::Infeasible => SolveOutcome::Infeasible, + }; + if let Some(reported) = reported { + match &target { + SolveOutcome::Optimal { evaluation, .. } + | SolveOutcome::Feasible { evaluation, .. } => { + crate::solvers::check_reported_evaluation(&reported, evaluation)?; + } + SolveOutcome::Infeasible => { + return Err(ExtractionError::invalid( + "infeasible results must not contain evaluation", + )); + } + } + } + let target_json = crate::solvers::outcome_to_json(&target)?; + Ok((crate::solvers::erase_outcome(target), target_json)) } - fn extract_value_from_solution_dyn( + fn source_result_json( &self, - target_solution: &dyn Any, - ) -> ExtractionResult> { - let target_solution = target_solution - .downcast_ref::<::Solution>() - .ok_or_else(|| { - ExtractionError::invalid(format!( - "target solution type mismatch: expected {}", - std::any::type_name::<::Solution>() - )) - })?; - let target_value = self.target_problem().evaluate(target_solution)?; - Ok(Box::new(self.extract_value(target_value))) + source: crate::solvers::ErasedOutcome, + ) -> ExtractionResult { + let source: crate::solvers::ProblemOutcome = + crate::solvers::downcast_outcome(source)?; + crate::solvers::outcome_to_json(&source) } } diff --git a/src/rules/travelingsalesman_ilp.rs b/src/rules/travelingsalesman_ilp.rs index ee89c2ec7..b8b6aa257 100644 --- a/src/rules/travelingsalesman_ilp.rs +++ b/src/rules/travelingsalesman_ilp.rs @@ -9,7 +9,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::TravelingSalesman; use crate::reduction; use crate::rules::ilp_helpers::{mccormick_product, one_hot_decode}; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::{Graph, SimpleGraph}; /// Result of reducing TravelingSalesman to ILP. @@ -32,32 +33,41 @@ impl ReductionResult for ReductionTSPToILP { /// Extract solution: read tour permutation from x variables, /// then map to edge selection for the source problem. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionTSPToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let n = self.num_vertices; - let tour = one_hot_decode(target_solution, n, n, 0)?; + let tour = one_hot_decode(target_solution, n, n, 0); // Map tour to edge selection let mut edge_selection = vec![false; self.source_edges.len()]; for k in 0..n { let u = tour[k]; let v = tour[(k + 1) % n]; - let edge = self + for (edge, _) in self .source_edges .iter() - .position(|&(a, b)| (a == u && b == v) || (a == v && b == u)) - .ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "target tour uses absent source edge ({u}, {v})" - )) - })?; - edge_selection[edge] = true; + .enumerate() + .filter(|&(_, &(a, b))| (a == u && b == v) || (a == v && b == u)) + .take(1) + { + edge_selection[edge] = true; + } } edge_selection diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index 42cbda1a9..dbc23fa6c 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -10,7 +10,9 @@ 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::solvers::ProblemOutcome; +use crate::solvers::SolveOutcome; +use crate::topology::SimpleGraph; use std::collections::HashMap; /// Result of reducing TravelingSalesman to QUBO. @@ -20,6 +22,9 @@ pub struct ReductionTravelingSalesmanToQUBO { num_vertices: usize, num_edges: usize, edge_index: HashMap<(usize, usize), usize>, + objective_offset: i128, + feasible_energy_upper: i128, + small_optimum: Option<(Vec, i64)>, } impl ReductionResult for ReductionTravelingSalesmanToQUBO { @@ -30,48 +35,81 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { &self.target } - /// Decode position encoding back to edge-based configuration. - /// - /// The QUBO solution uses n^2 binary variables x_{v,p} (vertex v at position p). - /// We extract the tour order, then map consecutive pairs to edge indices. - fn extract_solution( + /// Decode an optimum whose value relation establishes source feasibility. + /// The energy gap guarantees a permutation using existing source edges. + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - - Ok({ - let n = self.num_vertices; - - let tour: Vec = (0..n) - .map(|position| { - let mut selected = - (0..n).filter(|&vertex| target_solution[vertex * n + position]); - match (selected.next(), selected.next()) { - (Some(vertex), None) => Ok(vertex), - _ => Err(crate::rules::ExtractionError::invalid(format!( - "tour position {position} does not select exactly one vertex" - ))), - } - }) - .collect::>()?; - - // Build edge-based config: for each consecutive pair in the tour, mark the edge - let mut config = vec![false; self.num_edges]; - for p in 0..n { - let u = tour[p]; - let v = tour[(p + 1) % n]; - let key = (u.min(v), u.max(v)); - let &edge = self.edge_index.get(&key).ok_or_else(|| { - crate::rules::ExtractionError::invalid(format!( - "target tour uses absent source edge ({u}, {v})" - )) - })?; - config[edge] = true; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + SolveOutcome::Infeasible => Ok(SolveOutcome::Infeasible), + SolveOutcome::Optimal { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Ok(SolveOutcome::Infeasible); + } + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::optimal(source, solution)?) } + SolveOutcome::Feasible { + solution, + evaluation, + } => { + if !self.map_value(evaluation).is_valid() { + return Err(crate::rules::ExtractionError::InsufficientSolutionQuality); + } + let solution = self.map_solution(&solution)?; + Ok(SolveOutcome::feasible(source, solution)?) + } + } + } +} - config - }) +impl ReductionTravelingSalesmanToQUBO { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { + if self.num_vertices < 3 { + return Ok(self.small_optimum.as_ref().unwrap().0.clone()); + } + let n = self.num_vertices; + let tour: Vec = (0..n) + .map(|position| { + (0..n) + .find(|&vertex| target_solution[vertex * n + position]) + .unwrap() + }) + .collect(); + let mut config = vec![false; self.num_edges]; + for p in 0..n { + let (u, v) = (tour[p], tour[(p + 1) % n]); + config[self.edge_index[&(u.min(v), u.max(v))]] = true; + } + Ok(config) + } +} + +impl ReductionTravelingSalesmanToQUBO { + fn map_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)), + ); + } + crate::types::Min( + value.0.filter(|&energy| i128::from(energy) < self.feasible_energy_upper) + // Construction bounds source tour costs by a representable sum of + // absolute edge weights; the offset is calculated in i128. + .map(|energy| i64::try_from(i128::from(energy) + self.objective_offset).unwrap()), + ) } } @@ -87,49 +125,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_sparse(sprs::CsMat::zero((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 .checked_add(1) .ok_or_else(|| overflow("computing the tour penalty"))?; + let omitted_constant = 2 * n as i128 * i128::from(a); + let objective_offset = omitted_constant + n as i128 * i128::from(shift); + 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]; + let mut matrix = vec![std::collections::BTreeMap::new(); dim]; // Helper: add value to upper-triangular position let mut add_upper = |i: usize, j: usize, val: i64| { let (lo, hi) = if i <= j { (i, j) } else { (j, i) }; - matrix[lo][hi] = matrix[lo][hi] + let coefficient = matrix[lo].entry(hi).or_insert(0i64); + *coefficient = coefficient .checked_add(val) .ok_or_else(|| overflow("adding a tour QUBO coefficient"))?; Ok::<(), crate::rules::ReductionError>(()) @@ -189,7 +284,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 +298,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_rows(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..76bd7f77f 100644 --- a/src/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/rules/undirectedflowlowerbounds_ilp.rs @@ -26,7 +26,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::UndirectedFlowLowerBounds; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::Graph; /// Result of reducing UndirectedFlowLowerBounds to `ILP`. @@ -54,12 +55,22 @@ impl ReductionResult for ReductionUFLBToILP { /// The model encodes orientation as config[e] = 0 for u→v, 1 for v→u. /// The ILP uses z_e = 1 for u→v, z_e = 0 for v→u. /// So we return 1 - z_e to match the model's convention. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionUFLBToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok({ let e = self.num_edges; target_solution[2 * e..3 * e] diff --git a/src/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/rules/undirectedtwocommodityintegralflow_ilp.rs index 821af6079..b53c7608f 100644 --- a/src/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -27,7 +27,8 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::UndirectedTwoCommodityIntegralFlow; use crate::reduction; -use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::rules::traits::{recover_preserving_status, ReduceTo, ReductionResult}; +use crate::solvers::ProblemOutcome; use crate::topology::Graph; /// Result of reducing UndirectedTwoCommodityIntegralFlow to `ILP`. @@ -51,12 +52,22 @@ impl ReductionResult for ReductionU2CIFToILP { } /// Extract flow solution: first 4*|E| variables are the flow values. - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { - crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + source: &Self::Source, + target: ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + recover_preserving_status(source, target, |solution| self.map_solution(solution)) + } +} +impl ReductionU2CIFToILP { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { crate::rules::ilp_helpers::decode_usize_values(&target_solution[..4 * self.num_edges]) } } @@ -200,6 +211,7 @@ impl ReduceTo> for UndirectedTwoCommodityIntegralFlow { #[cfg(feature = "example-db")] pub(crate) fn canonical_rule_example_specs() -> Vec { use crate::export::SolutionPair; + use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; vec![crate::example_db::specs::RuleExampleSpec { @@ -224,7 +236,14 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/rules/unitdiskmapping/ksg/mapping.rs b/src/rules/unitdiskmapping/ksg/mapping.rs index 23e471a58..72dd341eb 100644 --- a/src/rules/unitdiskmapping/ksg/mapping.rs +++ b/src/rules/unitdiskmapping/ksg/mapping.rs @@ -55,7 +55,6 @@ pub struct MappingResult { /// Tape entries recording gadget applications (for unapply during solution extraction). pub tape: Vec, /// Doubled cells (where two copy lines overlap) for map_config_back. - #[serde(default)] pub doubled_cells: HashSet<(usize, usize)>, } @@ -230,16 +229,8 @@ impl MappingResult { &self, grid_config: &[usize], ) -> crate::rules::ExtractionResult> { - self.map_config_back_internal(grid_config) - .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string())) - } - - fn map_config_back_internal( - &self, - grid_config: &[usize], - ) -> Result, ReductionError> { if grid_config.len() != self.positions.len() { - return Err(mapping_invalid( + return Err(crate::rules::ExtractionError::invalid( "grid configuration length must match the mapped vertex count", )); } @@ -248,12 +239,18 @@ impl MappingResult { let mut config_2d = vec![vec![0usize; cols]; rows]; for (idx, &(row, col)) in self.positions.iter().enumerate() { - let row = usize::try_from(row) - .map_err(|_| mapping_invalid("mapping result contains a negative grid row"))?; - let col = usize::try_from(col) - .map_err(|_| mapping_invalid("mapping result contains a negative grid column"))?; + let row = usize::try_from(row).map_err(|_| { + crate::rules::ExtractionError::invalid( + "mapping result contains a negative grid row", + ) + })?; + let col = usize::try_from(col).map_err(|_| { + crate::rules::ExtractionError::invalid( + "mapping result contains a negative grid column", + ) + })?; if row >= rows || col >= cols { - return Err(mapping_invalid( + return Err(crate::rules::ExtractionError::invalid( "mapping result contains a position outside its grid dimensions", )); } @@ -261,7 +258,8 @@ impl MappingResult { } // Step 2: Unapply gadgets in reverse order - unapply_gadgets(&self.tape, &mut config_2d)?; + unapply_gadgets(&self.tape, &mut config_2d) + .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string()))?; // Step 3: Extract vertex configs from copylines map_config_copyback( @@ -271,6 +269,7 @@ impl MappingResult { &config_2d, &self.doubled_cells, ) + .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string())) } } @@ -280,16 +279,8 @@ impl MappingResult { &self, grid_config: &[usize], ) -> crate::rules::ExtractionResult> { - self.map_config_back_internal(grid_config) - .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string())) - } - - fn map_config_back_internal( - &self, - grid_config: &[usize], - ) -> Result, ReductionError> { if grid_config.len() != self.positions.len() { - return Err(mapping_invalid( + return Err(crate::rules::ExtractionError::invalid( "grid configuration length must match the mapped vertex count", )); } @@ -298,12 +289,18 @@ impl MappingResult { let mut config_2d = vec![vec![0usize; cols]; rows]; for (idx, &(row, col)) in self.positions.iter().enumerate() { - let row = usize::try_from(row) - .map_err(|_| mapping_invalid("mapping result contains a negative grid row"))?; - let col = usize::try_from(col) - .map_err(|_| mapping_invalid("mapping result contains a negative grid column"))?; + let row = usize::try_from(row).map_err(|_| { + crate::rules::ExtractionError::invalid( + "mapping result contains a negative grid row", + ) + })?; + let col = usize::try_from(col).map_err(|_| { + crate::rules::ExtractionError::invalid( + "mapping result contains a negative grid column", + ) + })?; if row >= rows || col >= cols { - return Err(mapping_invalid( + return Err(crate::rules::ExtractionError::invalid( "mapping result contains a position outside its grid dimensions", )); } @@ -311,7 +308,8 @@ impl MappingResult { } // Step 2: Unapply gadgets in reverse order - unapply_weighted_gadgets(&self.tape, &mut config_2d)?; + unapply_weighted_gadgets(&self.tape, &mut config_2d) + .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string()))?; // Step 3: Extract vertex configs from copylines map_config_copyback( @@ -321,6 +319,7 @@ impl MappingResult { &config_2d, &self.doubled_cells, ) + .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string())) } } diff --git a/src/rules/unitdiskmapping/triangular/mapping.rs b/src/rules/unitdiskmapping/triangular/mapping.rs index 7fa8ebe29..e662ef529 100644 --- a/src/rules/unitdiskmapping/triangular/mapping.rs +++ b/src/rules/unitdiskmapping/triangular/mapping.rs @@ -310,28 +310,22 @@ pub fn map_config_back( result: &MappingResult, grid_config: &[usize], ) -> crate::rules::ExtractionResult> { - map_config_back_internal(result, grid_config) - .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string())) -} - -fn map_config_back_internal( - result: &MappingResult, - grid_config: &[usize], -) -> Result, ReductionError> { if grid_config.len() != result.positions.len() { - return Err(mapping_invalid( + return Err(crate::rules::ExtractionError::invalid( "grid configuration length must match the mapped vertex count", )); } - let positions = position_index(result)?; + let positions = position_index(result) + .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string()))?; - super::super::weighted::trace_centers(result)? + super::super::weighted::trace_centers(result) + .map_err(|error| crate::rules::ExtractionError::invalid(error.to_string()))? .into_iter() .map(|center| { positions .get(¢er) .map(|&index| grid_config[index]) - .ok_or(mapping_invalid( + .ok_or(crate::rules::ExtractionError::invalid( "a traced center is missing from the mapped graph", )) }) diff --git a/src/solvers/brute_force.rs b/src/solvers/brute_force.rs index fd3add8fe..1e8c413b1 100644 --- a/src/solvers/brute_force.rs +++ b/src/solvers/brute_force.rs @@ -3,13 +3,57 @@ use std::any::Any; use crate::solvers::SolveError; -use crate::traits::Problem; -use crate::types::{Aggregate, SolutionAggregate}; +use crate::traits::{EvaluationValue, Problem}; +use crate::types::{Aggregate, Extremum, Max, Min, Or}; +use serde::{de::DeserializeOwned, Serialize}; +use std::fmt; + +/// Brute-force capability for selecting witnesses from a completed aggregate. +/// +/// This is not required by model evaluation, reductions, or solvers that return +/// their solutions directly. +pub trait SolutionAggregate: Aggregate + EvaluationValue { + /// Whether a solution-level value contributes to the final aggregate value. + fn contributes_to_solution(value: &Self, total: &Self) -> bool; +} + +impl SolutionAggregate + for Max +{ + fn contributes_to_solution(value: &Self, total: &Self) -> bool { + matches!((value, total), (Max(Some(value)), Max(Some(best))) if value == best) + } +} + +impl SolutionAggregate + for Min +{ + fn contributes_to_solution(value: &Self, total: &Self) -> bool { + matches!((value, total), (Min(Some(value)), Min(Some(best))) if value == best) + } +} + +impl SolutionAggregate for Or { + fn contributes_to_solution(value: &Self, total: &Self) -> bool { + value.0 && total.0 + } +} + +impl SolutionAggregate + for Extremum +{ + fn contributes_to_solution(candidate: &Self, total: &Self) -> bool { + matches!( + (candidate.value.as_ref(), total.value.as_ref()), + (Some(value), Some(best)) if candidate.sense == total.sense && value == best + ) + } +} type CartesianWitness

= Option<(

::Solution,

::Value)>; #[doc(hidden)] -pub type BruteForceDimensionsFn = fn(&dyn Any) -> Vec; +pub type BruteForceDimensionsFn = fn(&dyn Any) -> Result, SolveError>; #[doc(hidden)] pub type BruteForceSolveFn = fn(&dyn Any) -> Result, SolveError>; @@ -34,38 +78,43 @@ inventory::collect!(BruteForceRegistration); /// A problem with a finite Cartesian coordinate space for reference solving. pub trait BruteForceProblem: Problem { - /// Cardinality of each coordinate in the brute-force search space. - fn dimensions(&self) -> Vec; + /// Number of coordinates needed to represent one candidate. + fn num_variables(&self) -> Result; + + /// Cardinality of a coordinate. `variable` must be less than `num_variables()`. + fn dimension(&self, variable: usize) -> Result; +} - /// Number of coordinates in the brute-force search space. - fn num_variables(&self) -> usize { - self.dimensions().len() +/// Materialize coordinate cardinalities for enumeration or inspection. +#[doc(hidden)] +pub fn cartesian_dimensions(problem: &P) -> Result, SolveError> { + let count = BruteForceProblem::num_variables(problem)?; + let mut dimensions = Vec::new(); + dimensions.try_reserve_exact(count)?; + for variable in 0..count { + dimensions.push(problem.dimension(variable)?); } + Ok(dimensions) } 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()]), dimensions, - remaining: total, + current, }) } } @@ -79,24 +128,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; @@ -186,7 +234,7 @@ impl BruteForce { F: Fn(Vec) -> P::Solution, { let mut total = P::Value::identity(); - for indices in CartesianIndices::new(problem.dimensions())? { + for indices in CartesianIndices::new(cartesian_dimensions(problem)?)? { total = total.combine(problem.evaluate(&decode(indices))?)?; if total.is_absorbing() { break; @@ -207,7 +255,7 @@ impl BruteForce { { let total = self.solve_cartesian(problem, &decode)?; let mut witnesses = Vec::new(); - for indices in CartesianIndices::new(problem.dimensions())? { + for indices in CartesianIndices::new(cartesian_dimensions(problem)?)? { let solution = decode(indices); let value = problem.evaluate(&solution)?; if P::Value::contributes_to_solution(&value, &total) { @@ -228,7 +276,7 @@ impl BruteForce { F: Fn(Vec) -> P::Solution, { let total = self.solve_cartesian(problem, &decode)?; - for indices in CartesianIndices::new(problem.dimensions())? { + for indices in CartesianIndices::new(cartesian_dimensions(problem)?)? { let solution = decode(indices); let value = problem.evaluate(&solution)?; if P::Value::contributes_to_solution(&value, &total) { diff --git a/src/solvers/customized/closest_vector_problem.rs b/src/solvers/customized/closest_vector_problem.rs index 7f901e8e9..b04025bd0 100644 --- a/src/solvers/customized/closest_vector_problem.rs +++ b/src/solvers/customized/closest_vector_problem.rs @@ -1,15 +1,14 @@ -//! Exact-rational CVP sphere enumeration in nearest-first (Schnorr--Euchner) order. +//! Exact CVP sphere enumeration in nearest-first (Schnorr--Euchner) order. -use crate::models::algebraic::{ClosestVectorProblem, ClosestVectorTarget}; +use crate::models::algebraic::ClosestVectorProblem; use crate::solvers::SolveError; +use num_bigint::BigInt; use num_rational::BigRational; -use num_traits::{ToPrimitive, Zero}; +use num_traits::{Signed, ToPrimitive, Zero}; type GramSchmidtData = (Vec>, Vec, Vec); -pub(crate) fn solve( - problem: &ClosestVectorProblem, -) -> Result, SolveError> { +pub(crate) fn solve(problem: &ClosestVectorProblem) -> Result, SolveError> { let n = problem.num_basis_vectors(); if n == 0 { return Ok(Vec::new()); @@ -21,28 +20,20 @@ pub(crate) fn solve( .map(|column| { column .iter() - .map(|&entry| { - crate::types::i64_to_exact_f64(entry)?; - Ok(BigRational::from_integer(entry.into())) - }) - .collect::, SolveError>>() + .map(|&entry| BigRational::from_integer(entry.into())) + .collect() }) - .collect::, _>>()?; + .collect::>>(); let target = problem .target() .iter() - .map(|coordinate| { - let value = coordinate.to_f64().map_err(SolveError::Evaluation)?; - BigRational::from_float(value).ok_or_else(|| { - SolveError::NonFiniteResult("converting a CVP target to an exact rational".into()) - }) - }) - .collect::, _>>()?; + .map(|&v| BigRational::from_integer(v.into())) + .collect::>(); let (mu, norms, alpha) = gram_schmidt(&basis, &target); let mut best_squared = (0..n).map(|i| &norms[i] * &alpha[i] * &alpha[i]).sum(); - let mut coefficients = vec![0_i64; n]; + let mut coefficients = vec![BigInt::zero(); n]; let mut best = coefficients.clone(); enumerate( n - 1, @@ -53,8 +44,13 @@ pub(crate) fn solve( &mut coefficients, &mut best, &mut best_squared, - )?; - Ok(best) + ); + best.into_iter() + .map(|v| { + v.to_i64() + .ok_or_else(|| SolveError::IntegerOverflow("returning a CVP coefficient".into())) + }) + .collect() } fn gram_schmidt(basis: &[Vec], target: &[BigRational]) -> GramSchmidtData { @@ -102,33 +98,28 @@ fn enumerate( mu: &[Vec], norms: &[BigRational], alpha: &[BigRational], - coefficients: &mut [i64], - best: &mut Vec, + coefficients: &mut [BigInt], + best: &mut [BigInt], best_squared: &mut BigRational, -) -> Result<(), SolveError> { +) { if partial_squared >= *best_squared { - return Ok(()); + return; } let mut center = alpha[level].clone(); for later in (level + 1)..coefficients.len() { - center -= &mu[later][level] * BigRational::from_integer(coefficients[later].into()); + center -= &mu[later][level] * BigRational::from_integer(coefficients[later].clone()); } - let mut candidate = - center.round().to_integer().to_i64().ok_or_else(|| { - SolveError::IntegerOverflow("rounding a CVP enumeration center".into()) - })?; - crate::types::i64_to_exact_f64(candidate)?; - let nearest = BigRational::from_integer(candidate.into()); - let mut step = if center > nearest { 1_i64 } else { -1 }; + let mut candidate = center.round().to_integer(); + let nearest = BigRational::from_integer(candidate.clone()); + let mut step = BigInt::from(if center > nearest { 1 } else { -1 }); // Visit the nearest integer, then alternate sides in increasing distance. // The first descent tries the nearest-plane candidate; every subsequent // branch uses the improved incumbent rather than a fixed initial interval. loop { - coefficients[level] = candidate; - crate::types::i64_to_exact_f64(candidate)?; - let delta = BigRational::from_integer(candidate.into()) - ¢er; + coefficients[level] = candidate.clone(); + let delta = BigRational::from_integer(candidate.clone()) - ¢er; let next_squared = &partial_squared + &norms[level] * &delta * δ if next_squared >= *best_squared { break; @@ -147,16 +138,14 @@ fn enumerate( coefficients, best, best_squared, - )?; + ); if partial_squared >= *best_squared { break; } // Differences +1,-2,+3,... (or -1,+2,-3,...) alternate around the center. - // Exact f64 coefficient transport keeps these i64 updates below 2^55. - candidate += step; - step = -step - step.signum(); + candidate += &step; + step = -&step - step.signum(); } - Ok(()) } #[cfg(test)] diff --git a/src/solvers/customized/minimum_decision_tree.rs b/src/solvers/customized/minimum_decision_tree.rs index 7422ab1dc..7302195bd 100644 --- a/src/solvers/customized/minimum_decision_tree.rs +++ b/src/solvers/customized/minimum_decision_tree.rs @@ -1,12 +1,23 @@ //! Exact minimum decision tree solver using dynamic programming over object subsets. use crate::models::misc::MinimumDecisionTree; +use crate::solvers::SolveError; -pub(crate) fn solve(problem: &MinimumDecisionTree) -> Option> { +pub(crate) fn solve(problem: &MinimumDecisionTree) -> Result, SolveError> { let n = problem.num_objects(); - let full = (1usize << n) - 1; - let mut costs = vec![usize::MAX; 1usize << n]; - let mut choices = vec![problem.num_tests(); 1usize << n]; + if n >= usize::BITS as usize { + return Err(SolveError::IntegerOverflow( + "indexing object subsets with a usize mask".into(), + )); + } + let states = 1usize << n; + let full = states - 1; + let mut costs = Vec::new(); + costs.try_reserve_exact(states)?; + costs.resize(states, usize::MAX); + let mut choices = Vec::new(); + choices.try_reserve_exact(states)?; + choices.resize(states, problem.num_tests()); for object in 0..n { costs[1 << object] = 0; } @@ -37,9 +48,11 @@ pub(crate) fn solve(problem: &MinimumDecisionTree) -> Option> { } let slots = (1usize << (n - 1)) - 1; - let mut solution = vec![problem.num_tests(); slots]; + let mut solution = Vec::new(); + solution.try_reserve_exact(slots)?; + solution.resize(slots, problem.num_tests()); write_tree(problem, full, 0, &choices, &mut solution); - Some(solution) + Ok(solution) } fn write_tree( diff --git a/src/solvers/customized/shortest_common_superstring.rs b/src/solvers/customized/shortest_common_superstring.rs index bcca60f3b..e3f5a2ea4 100644 --- a/src/solvers/customized/shortest_common_superstring.rs +++ b/src/solvers/customized/shortest_common_superstring.rs @@ -1,8 +1,9 @@ //! Exact shortest common superstring solver using subset dynamic programming. use crate::models::misc::ShortestCommonSuperstring; +use crate::solvers::SolveError; -pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Option>> { +pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Result>, SolveError> { let mut strings = problem.strings().to_vec(); strings.sort(); strings.dedup(); @@ -18,17 +19,31 @@ pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Option>; (1usize << n) * n]; + if n >= usize::BITS as usize { + return Err(SolveError::IntegerOverflow( + "indexing string subsets with a usize mask".into(), + )); + } + let states = 1usize << n; + let cells = states.checked_mul(n).ok_or_else(|| { + SolveError::IntegerOverflow("sizing the superstring dynamic-programming table".into()) + })?; + let mut dp = Vec::>>::new(); + dp.try_reserve_exact(cells)?; + dp.resize(cells, None); for (i, string) in strings.iter().enumerate() { dp[(1 << i) * n + i] = Some(string.clone()); } - for mask in 1usize..(1usize << n) { + for mask in 1usize..states { for last in 0..n { let Some(prefix) = dp[mask * n + last].clone() else { continue; @@ -51,14 +66,14 @@ pub(crate) fn solve(problem: &ShortestCommonSuperstring) -> Option>(); + solution.extend(shortest.into_iter().map(Some)); solution.resize(problem.max_length(), None); - Some(solution) + Ok(solution) } fn contains(haystack: &[usize], needle: &[usize]) -> bool { diff --git a/src/solvers/customized/solver.rs b/src/solvers/customized/solver.rs index 020fd82bc..6d708082d 100644 --- a/src/solvers/customized/solver.rs +++ b/src/solvers/customized/solver.rs @@ -1,11 +1,11 @@ -//! Exact customized solvers and their exact-variant registrations. +//! Customized solvers and their concrete-variant outcome registrations. use super::fd_subset_search::{ self, compute_closure, find_essential_attributes, find_essential_attributes_restricted, is_minimal_key, is_superkey, BranchDecision, }; use crate::models::graph::{ - MinimumCostCirculation, MinimumIntersectionGraphBasis, PartialFeedbackEdgeSet, + KColoring, MinimumCostCirculation, MinimumIntersectionGraphBasis, PartialFeedbackEdgeSet, RootedTreeArrangement, }; use crate::models::misc::{ @@ -14,9 +14,10 @@ use crate::models::misc::{ }; use crate::models::set::{MinimumCardinalityKey, PrimeAttributeName}; use crate::solvers::registry::CustomizedSolverRegistration; -use crate::topology::SimpleGraph; +use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use std::collections::HashSet; +use crate::variant::K2; +use std::collections::{HashSet, VecDeque}; macro_rules! register_customized_solver { ($problem:ty, $implementation:literal, $solve:expr) => { @@ -29,12 +30,14 @@ macro_rules! register_customized_solver { let problem = any.downcast_ref::<$problem>().expect( "customized solver registration received the wrong concrete type", ); - $solve(problem).map(|solution| { - solution.map(|solution: <$problem as Problem>::Solution| { - serde_json::to_value(solution) - .expect("customized solution serialization must succeed") - }) - }) + let result: Result::Solution>, crate::solvers::SolveError> = $solve(problem); + match result? { + Some(solution) => { + let outcome = crate::solvers::SolveOutcome::optimal(problem, solution)?; + Ok(crate::solvers::outcome_to_json(&outcome)?) + } + None => Ok(crate::solvers::SolveOutcome::Infeasible), + } }, } } @@ -70,12 +73,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", @@ -92,15 +95,55 @@ register_customized_solver!( |problem| Ok(TimetableDesign::solve_via_required_assignments(problem)) ); +register_customized_solver!(KColoring, "bipartite-coloring", |problem| { + Ok(solve_two_coloring(problem)) +}); + register_customized_solver!( - crate::models::algebraic::ClosestVectorProblem, + crate::models::algebraic::ClosestVectorProblem, "cvp-sphere-enumeration", |problem| super::closest_vector_problem::solve(problem).map(Some) ); + +fn solve_two_coloring(problem: &KColoring) -> Option> { + let mut adjacency = vec![Vec::new(); problem.num_vertices()]; + for (left, right) in problem.graph().edges() { + adjacency[left].push(right); + adjacency[right].push(left); + } + + let mut colors = vec![usize::MAX; problem.num_vertices()]; + let mut queue = VecDeque::new(); + for start in 0..problem.num_vertices() { + if colors[start] != usize::MAX { + continue; + } + colors[start] = 0; + queue.push_back(start); + while let Some(vertex) = queue.pop_front() { + let next_color = 1 - colors[vertex]; + for &neighbor in &adjacency[vertex] { + if colors[neighbor] == usize::MAX { + colors[neighbor] = next_color; + queue.push_back(neighbor); + } else if colors[neighbor] == colors[vertex] { + return None; + } + } + } + } + Some(colors) +} + 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)) + } ); /// Solve MinimumCardinalityKey: find a minimal key with smallest cardinality. diff --git a/src/solvers/decision_search.rs b/src/solvers/decision_search.rs deleted file mode 100644 index 7c38fe6ec..000000000 --- a/src/solvers/decision_search.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Decision-guided binary search for optimization via decision queries. - -use crate::models::decision::{Decision, DecisionProblemMeta}; -use crate::solvers::BruteForce; -use crate::traits::Problem; -use crate::types::{Max, Min, OptimizationValue, Or}; -use serde::de::DeserializeOwned; -use serde::Serialize; -use std::fmt; - -/// Whether a decision problem has at least one satisfying configuration. -fn is_satisfiable

(problem: &P) -> Result -where - P: Problem + 'static, - P::Solution: 'static, -{ - Ok(BruteForce::new().solve(problem)?.is_some()) -} - -fn solve_via_decision_min

( - problem: &P, - lower: i64, - upper: i64, -) -> Result, crate::solvers::SolveError> -where - P: DecisionProblemMeta + Problem> + Clone + 'static, - P::Solution: 'static, -{ - if lower > upper { - return Ok(None); - } - - if !is_satisfiable(&Decision::new(problem.clone(), upper))? { - return Ok(None); - } - - let mut lo = lower; - let mut hi = upper; - while lo < hi { - let mid = lo + (hi - lo) / 2; - if is_satisfiable(&Decision::new(problem.clone(), mid))? { - hi = mid; - } else { - lo = mid + 1; - } - } - - Ok(Some(lo)) -} - -fn solve_via_decision_max

( - problem: &P, - lower: i64, - upper: i64, -) -> Result, crate::solvers::SolveError> -where - P: DecisionProblemMeta + Problem> + Clone + 'static, - P::Solution: 'static, -{ - if lower > upper { - return Ok(None); - } - - if !is_satisfiable(&Decision::new(problem.clone(), lower))? { - return Ok(None); - } - - let mut lo = lower; - let mut hi = upper; - while lo < hi { - let mid = lo + (hi - lo + 1) / 2; - if is_satisfiable(&Decision::new(problem.clone(), mid))? { - lo = mid; - } else { - hi = mid - 1; - } - } - - Ok(Some(lo)) -} - -#[doc(hidden)] -pub trait DecisionSearchValue: - OptimizationValue + Clone + fmt::Debug + Serialize + DeserializeOwned -{ - fn solve_problem

( - problem: &P, - lower: i64, - upper: i64, - ) -> Result, crate::solvers::SolveError> - where - P: DecisionProblemMeta + Problem + Clone + 'static, - P::Solution: 'static; -} - -impl DecisionSearchValue for Min { - fn solve_problem

( - problem: &P, - lower: i64, - upper: i64, - ) -> Result, crate::solvers::SolveError> - where - P: DecisionProblemMeta + Problem + Clone + 'static, - P::Solution: 'static, - { - solve_via_decision_min(problem, lower, upper) - } -} - -impl DecisionSearchValue for Max { - fn solve_problem

( - problem: &P, - lower: i64, - upper: i64, - ) -> Result, crate::solvers::SolveError> - where - P: DecisionProblemMeta + Problem + Clone + 'static, - P::Solution: 'static, - { - solve_via_decision_max(problem, lower, upper) - } -} - -/// Recover an optimization value by querying the problem's decision wrapper. -pub fn solve_via_decision

( - problem: &P, - lower: i64, - upper: i64, -) -> Result, crate::solvers::SolveError> -where - P: DecisionProblemMeta + Clone + 'static, - P::Solution: 'static, - P::Value: DecisionSearchValue, -{ - ::solve_problem(problem, lower, upper) -} - -#[cfg(test)] -#[path = "../unit_tests/solvers/decision_search.rs"] -mod tests; diff --git a/src/solvers/ilp/adapter.rs b/src/solvers/ilp/adapter.rs new file mode 100644 index 000000000..6149dea78 --- /dev/null +++ b/src/solvers/ilp/adapter.rs @@ -0,0 +1,233 @@ +//! 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. + +use crate::models::algebraic::{Comparison, ILPCoefficient, ObjectiveSense, VariableDomain, ILP}; +use crate::types::{i64_to_exact_f64, ExactI64ToF64Error, MAX_EXACT_F64_INTEGER}; +use highs::{HighsModelStatus, HighsSolutionStatus, RowProblem, Sense}; + +/// Internal errors are mapped to the existing public solver errors by orchestration. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub(crate) enum IlpBackendError { + #[error("the ILP is infeasible")] + Infeasible, + #[error("the ILP objective is unbounded")] + Unbounded, + #[error("the ILP solver reached its time limit before proving optimality")] + Timeout, + #[error("the ILP backend failed: {0}")] + BackendFailure(String), + #[error("the ILP backend returned an invalid rounded solution: {0}")] + InvalidSolution(String), + #[error(transparent)] + InexactTransport(#[from] ExactI64ToF64Error), +} + +/// 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<(), IlpBackendError> { + match status { + HighsModelStatus::Optimal => Ok(()), + HighsModelStatus::Infeasible => Err(IlpBackendError::Infeasible), + HighsModelStatus::Unbounded => Err(IlpBackendError::Unbounded), + HighsModelStatus::ReachedTimeLimit => Err(IlpBackendError::Timeout), + other => Err(IlpBackendError::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, IlpBackendError> + where + V: VariableDomain, + C: BackendCoefficient, + { + if self + .time_limit + .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) + { + return Err(IlpBackendError::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, IlpBackendError> + where + V: VariableDomain, + C: BackendCoefficient, + { + let n = problem.num_vars(); + if n == 0 { + return if problem + .is_feasible(&[]) + .map_err(|error| IlpBackendError::InvalidSolution(error.to_string()))? + { + Ok(vec![]) + } else { + Err(IlpBackendError::Infeasible) + }; + } + + if n > i32::MAX as usize || problem.constraints().len() > i32::MAX as usize { + return Err(IlpBackendError::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::, IlpBackendError>>()?; + 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| { + IlpBackendError::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| { + IlpBackendError::BackendFailure(format!("setting {option}: {error:?}")) + })?; + } + for option in ["mip_rel_gap", "mip_abs_gap"] { + model.try_set_option(option, 0.0).map_err(|error| { + IlpBackendError::BackendFailure(format!("setting {option}: {error:?}")) + })?; + } + model.try_set_option("parallel", "off").map_err(|error| { + IlpBackendError::BackendFailure(format!("setting parallel: {error:?}")) + })?; + if let Some(seconds) = self.time_limit { + model + .try_set_option("time_limit", seconds) + .map_err(|error| { + IlpBackendError::BackendFailure(format!("setting time_limit: {error:?}")) + })?; + } + let solved = model.try_solve().map_err(|error| { + IlpBackendError::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(IlpBackendError::Unbounded); + } + accept_backend_status(solved.status())?; + if solved.primal_solution_status() != HighsSolutionStatus::Feasible { + return Err(IlpBackendError::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, IlpBackendError> { + let result = values + .into_iter() + .enumerate() + .map(|(index, value)| { + if !value.is_finite() { + return Err(IlpBackendError::InvalidSolution(format!( + "variable {index} is non-finite" + ))); + } + let rounded = value.round(); + if (value - rounded).abs() > 1e-6 { + return Err(IlpBackendError::InvalidSolution(format!( + "variable {index} has non-integral value {value}" + ))); + } + if rounded.abs() > MAX_EXACT_F64_INTEGER as f64 { + return Err(IlpBackendError::InvalidSolution(format!( + "variable {index} value {rounded} exceeds exact f64 integer transport" + ))); + } + Ok(rounded as i64) + }) + .collect::, _>>()?; + if !problem + .is_feasible(&result) + .map_err(|error| IlpBackendError::InvalidSolution(error.to_string()))? + { + return Err(IlpBackendError::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| IlpBackendError::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..ecf31b7a0 100644 --- a/src/solvers/ilp/mod.rs +++ b/src/solvers/ilp/mod.rs @@ -1,8 +1,8 @@ //! 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 the HiGHS solver through its native Rust bindings. +pub(super) 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..db74766f2 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -1,27 +1,16 @@ //! ILP solver implementation using HiGHS. -use crate::models::algebraic::{Comparison, ObjectiveSense, VariableDomain, ILP}; +use super::adapter::{HighsAdapter, IlpBackendError}; 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 completed solve establishes that the source 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 +21,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}")] @@ -43,9 +32,12 @@ pub enum ILPSolveError { /// A registered pipeline returned a solution for a different source type. #[error("registered ILP pipeline returned the wrong solution type for {0}")] PipelineTypeMismatch(String), - /// HiGHS reported an optimal solution that is invalid after integer rounding. - #[error("the ILP backend returned an invalid rounded solution: {0}")] + /// The backend or reduction pipeline returned an invalid witness. + #[error("the ILP solve returned an invalid solution: {0}")] InvalidSolution(String), + /// Evaluating the extracted source witness failed. + #[error(transparent)] + Evaluation(#[from] crate::traits::EvaluationError), /// An exact integer in the model cannot be transported through the f64 backend API. #[error("the ILP backend cannot represent an exact model integer: {0}")] InexactTransport(#[from] crate::types::ExactI64ToF64Error), @@ -57,19 +49,24 @@ 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()), +// Keep adapter details out of the public error vocabulary. +impl From for ILPSolveError { + fn from(error: IlpBackendError) -> Self { + match error { + IlpBackendError::Infeasible => Self::Infeasible, + IlpBackendError::Unbounded => Self::Unbounded, + IlpBackendError::Timeout => Self::Timeout, + IlpBackendError::BackendFailure(message) => Self::BackendFailure(message), + IlpBackendError::InvalidSolution(message) => Self::InvalidSolution(message), + IlpBackendError::InexactTransport(error) => Self::InexactTransport(error), + } } } /// 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 its native `ILP` terminal. +/// A shared adapter sends that ILP to HiGHS 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,172 +124,7 @@ 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) - } - - /// Solve a type-erased supported ILP variant directly. - pub(crate) fn solve_dyn(&self, any: &dyn std::any::Any) -> Result, ILPSolveError> { - if let Some(ilp) = any.downcast_ref::>() { - return self.solve_backend(ilp); - } - if let Some(ilp) = any.downcast_ref::>() { - return self.solve_backend(ilp); - } - Err(ILPSolveError::UnsupportedProblemType) + pipeline.solve_typed(problem, &HighsAdapter::new(self.time_limit)) } } diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index a0e39a160..a280d790b 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -2,27 +2,33 @@ mod brute_force; pub(crate) mod customized; -pub mod decision_search; +mod outcome; mod pipelines; mod registry; mod resolver; +pub(crate) use outcome::{ + check_reported_evaluation, downcast_outcome, erase_outcome, outcome_to_json, ErasedOutcome, +}; +pub use outcome::{ProblemOutcome, SolveOutcome}; pub mod ilp; #[doc(hidden)] pub use brute_force::BruteForceRegistration; -pub use brute_force::{BruteForce, BruteForceProblem}; +pub use brute_force::{cartesian_dimensions, BruteForce, BruteForceProblem, SolutionAggregate}; pub use registry::{ brute_force_dimensions, solver_capabilities, CustomizedSolverCapability, ExactProblemKey, IlpSolverCapability, RegistryBuildError, SolverCapabilities, }; -pub use resolver::{solve, SolveOutcome, SolveResult, SolverExecution, SolverRequest}; +pub use resolver::{solve, SolveResult, SolverExecution, SolverRequest}; pub use ilp::{ILPSolveError, ILPSolver}; /// Failure while solving a valid problem instance. #[derive(Debug, thiserror::Error)] pub enum SolveError { + #[error(transparent)] + Extraction(#[from] crate::rules::ExtractionError), #[error("configuration evaluation failed: {0}")] Evaluation(#[from] crate::traits::EvaluationError), #[error("aggregate combination failed: {0}")] @@ -31,8 +37,8 @@ pub enum SolveError { MissingRegistration(String), #[error("invalid reference-solver registration: {0}")] RegistrationTypeMismatch(String), - #[error("brute-force search space cardinality exceeds usize for dimensions {0:?}")] - SearchSpaceOverflow(Vec), + #[error("cannot allocate solver storage: {0}")] + Allocation(#[from] std::collections::TryReserveError), #[error("integer overflow while {0}")] IntegerOverflow(String), #[error("inexact integer-to-float conversion: {0}")] @@ -52,3 +58,9 @@ pub enum SolveError { source: ILPSolveError, }, } + +impl From for SolveError { + fn from(error: std::num::TryFromIntError) -> Self { + Self::IntegerOverflow(error.to_string()) + } +} diff --git a/src/solvers/outcome.rs b/src/solvers/outcome.rs new file mode 100644 index 000000000..e57d88c3d --- /dev/null +++ b/src/solvers/outcome.rs @@ -0,0 +1,276 @@ +//! Mathematical solve results, shared by solvers and reduction recovery. + +use crate::traits::{EvaluationError, EvaluationValue, Problem}; +use serde::{Deserialize, Serialize}; +use std::any::Any; + +/// A completed solve or a feasible incumbent whose optimality is not established. +/// Execution failures are returned separately as errors. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SolveOutcome { + Optimal { solution: S, evaluation: V }, + Feasible { solution: S, evaluation: V }, + Infeasible, +} + +/// A result retaining the model's concrete solution and value types. +pub type ProblemOutcome

= SolveOutcome<

::Solution,

::Value>; + +/// Check the optional display-form evaluation at the external JSON boundary. +pub(crate) fn check_reported_evaluation( + reported: &serde_json::Value, + actual: &V, +) -> crate::rules::ExtractionResult<()> { + use crate::types::{Extremum, Max, Min}; + use std::any::TypeId; + + let expected = actual.to_string(); + let matches = reported.as_str().is_some_and(|reported| { + if reported == expected { + return true; + } + let Some((wrapper, expected_value)) = expected.split_once('(') else { + return false; + }; + let Some(value) = reported + .strip_prefix(wrapper) + .and_then(|s| s.strip_prefix('(')) + .and_then(|s| s.strip_suffix(')')) + else { + return false; + }; + let expected_value = expected_value.trim_end_matches(')'); + if [ + TypeId::of::>(), + TypeId::of::>(), + TypeId::of::>(), + ] + .contains(&TypeId::of::()) + { + let (Ok(value), Ok(expected_value)) = + (value.parse::(), expected_value.parse::()) + else { + return false; + }; + // Absolute and relative tolerances apply only to floating-point evaluations. + value.is_finite() + && expected_value.is_finite() + && (value - expected_value).abs() + <= 1e-9 * value.abs().max(expected_value.abs()).max(1.0) + } else { + match ( + value.parse::(), + expected_value.parse::(), + ) { + (Ok(value), Ok(expected_value)) => value == expected_value, + _ => false, + } + } + }); + if !matches { + return Err(crate::rules::ExtractionError::invalid(format!( + "invalid or mismatched evaluation: received {reported}, expected {expected}" + ))); + } + Ok(()) +} + +impl SolveOutcome { + /// Evaluate and validate a candidate whose optimality is established by the caller. + /// + /// Returns an evaluation error if evaluation fails or the candidate violates + /// the constraints. This checks feasibility, not optimality. + /// + /// The value type must be an [`EvaluationValue`]: + /// + /// ``` + /// # use problemreductions::traits::{EvaluationError, Problem}; + /// # use problemreductions::types::{Max, ProblemParameters, Sum}; + /// # use problemreductions::solvers::SolveOutcome; + /// # #[derive(Clone)] + /// # struct Constant(V); + /// # impl Problem for Constant { + /// # const NAME: &'static str = "Constant"; + /// # type Solution = (); + /// # type Value = V; + /// # fn parameter_names() -> &'static [&'static str] { &[] } + /// # fn parameters(&self) -> ProblemParameters { ProblemParameters::new(vec![]) } + /// # fn evaluate(&self, _: &()) -> Result { Ok(self.0.clone()) } + /// # fn variant() -> Vec<(&'static str, &'static str)> { vec![] } + /// # } + /// assert!(SolveOutcome::optimal(&Constant(Max(Some(1_u64))), ()).is_ok()); + /// ``` + /// + /// Fold-only values such as `Sum` and `And` carry no candidate feasibility, + /// so the same problem with a `Sum` value is rejected at compile time: + /// + /// ```compile_fail,E0277 + /// # use problemreductions::traits::{EvaluationError, Problem}; + /// # use problemreductions::types::{Max, ProblemParameters, Sum}; + /// # use problemreductions::solvers::SolveOutcome; + /// # #[derive(Clone)] + /// # struct Constant(V); + /// # impl Problem for Constant { + /// # const NAME: &'static str = "Constant"; + /// # type Solution = (); + /// # type Value = V; + /// # fn parameter_names() -> &'static [&'static str] { &[] } + /// # fn parameters(&self) -> ProblemParameters { ProblemParameters::new(vec![]) } + /// # fn evaluate(&self, _: &()) -> Result { Ok(self.0.clone()) } + /// # fn variant() -> Vec<(&'static str, &'static str)> { vec![] } + /// # } + /// assert!(SolveOutcome::optimal(&Constant(Sum(1_u64)), ()).is_ok()); + /// ``` + pub fn optimal>( + problem: &P, + solution: S, + ) -> Result + where + V: EvaluationValue, + { + let evaluation = problem.evaluate(&solution)?; + if !evaluation.is_valid() { + return Err(EvaluationError::ConstraintViolation); + } + Ok(Self::Optimal { + solution, + evaluation, + }) + } + + /// Evaluate and validate a candidate without claiming optimality. + /// + /// Returns an evaluation error if evaluation fails or the candidate violates + /// the constraints; this does not establish problem infeasibility. + pub fn feasible>( + problem: &P, + solution: S, + ) -> Result + where + V: EvaluationValue, + { + let evaluation = problem.evaluate(&solution)?; + if !evaluation.is_valid() { + return Err(EvaluationError::ConstraintViolation); + } + Ok(Self::Feasible { + solution, + evaluation, + }) + } + + pub fn solution(&self) -> Option<&S> { + match self { + Self::Optimal { solution, .. } | Self::Feasible { solution, .. } => Some(solution), + Self::Infeasible => None, + } + } + + pub fn into_solution(self) -> Option { + match self { + Self::Optimal { solution, .. } | Self::Feasible { solution, .. } => Some(solution), + Self::Infeasible => None, + } + } +} + +/// Serialize a model-owned result without evaluating its solution again. +pub(crate) fn outcome_to_json( + outcome: &SolveOutcome, +) -> crate::rules::ExtractionResult { + if let SolveOutcome::Optimal { evaluation, .. } | SolveOutcome::Feasible { evaluation, .. } = + outcome + { + if !evaluation.is_valid() { + return Err(EvaluationError::ConstraintViolation.into()); + } + } + let encode = |solution| { + serde_json::to_value(solution).map_err(|error| { + crate::rules::ExtractionError::invalid(format!( + "solution serialization failed: {error}" + )) + }) + }; + Ok(match outcome { + SolveOutcome::Optimal { + solution, + evaluation, + } => SolveOutcome::Optimal { + solution: encode(solution)?, + evaluation: evaluation.to_string(), + }, + SolveOutcome::Feasible { + solution, + evaluation, + } => SolveOutcome::Feasible { + solution: encode(solution)?, + evaluation: evaluation.to_string(), + }, + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + }) +} + +pub(crate) type ErasedOutcome = SolveOutcome, Box>; + +pub(crate) fn erase_outcome(outcome: SolveOutcome) -> ErasedOutcome { + match outcome { + SolveOutcome::Optimal { + solution, + evaluation, + } => SolveOutcome::Optimal { + solution: Box::new(solution), + evaluation: Box::new(evaluation), + }, + SolveOutcome::Feasible { + solution, + evaluation, + } => SolveOutcome::Feasible { + solution: Box::new(solution), + evaluation: Box::new(evaluation), + }, + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + } +} + +pub(crate) fn downcast_outcome( + outcome: ErasedOutcome, +) -> crate::rules::ExtractionResult> { + let convert = |solution: Box, evaluation: Box| { + let solution = *solution + .downcast::() + .map_err(|_| crate::rules::ExtractionError::invalid("result solution type mismatch"))?; + let evaluation = *evaluation.downcast::().map_err(|_| { + crate::rules::ExtractionError::invalid("result evaluation type mismatch") + })?; + Ok::<_, crate::rules::ExtractionError>((solution, evaluation)) + }; + Ok(match outcome { + SolveOutcome::Optimal { + solution, + evaluation, + } => { + let (solution, evaluation) = convert(solution, evaluation)?; + SolveOutcome::Optimal { + solution, + evaluation, + } + } + SolveOutcome::Feasible { + solution, + evaluation, + } => { + let (solution, evaluation) = convert(solution, evaluation)?; + SolveOutcome::Feasible { + solution, + evaluation, + } + } + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + }) +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/outcome.rs"] +mod tests; diff --git a/src/solvers/pipelines.rs b/src/solvers/pipelines.rs index 15e5aa525..e5d154597 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,38 @@ 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")]), + ("DecisionLongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("HamiltonianPath", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("HighlyConnectedDeletion", [("graph", "SimpleGraph")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } // This exact variant also has a customized backend. Default dispatch selects the @@ -261,50 +227,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 +270,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 +313,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 +353,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 +360,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 +369,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 +383,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 +391,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 +399,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 +406,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 +438,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 +469,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 +678,102 @@ register_ilp_pipeline! { register_ilp_pipeline! { ("QUBO", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("QuadraticAssignment", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RectilinearPictureCompression", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RegisterSufficiency", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ResourceConstrainedScheduling", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RootedTreeStorageAssignment", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("RuralPostman", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("Satisfiability", []), ("NAESatisfiability", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SchedulingToMinimizeWeightedCompletionTime", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SchedulingWithIndividualDeadlines", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingToMinimizeMaximumCumulativeCost", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingToMinimizeTardyTaskWeight", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingToMinimizeWeightedTardiness", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingWithDeadlinesAndSetUpTimes", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingWithReleaseTimesAndDeadlines", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SequencingWithinIntervals", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SetSplitting", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ShortestCommonSupersequence", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ShortestWeightConstrainedPath", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SparseMatrixCompression", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { @@ -920,66 +786,122 @@ register_ilp_pipeline! { ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "i64")]), ("QUBO", [("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("StackerCrane", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("StringToStringCorrection", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("StrongConnectivityAugmentation", [("weight", "i64")]), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SubgraphIsomorphism", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("SumOfSquaresPartition", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ThreeDimensionalMatching", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("ThreePartition", []), ("ResourceConstrainedScheduling", []), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("TravelingSalesman", [("graph", "SimpleGraph"), ("weight", "i64")]), ("ILP", [("variable", "bool"), ("coefficient", "i64")]), - ("ILP", [("variable", "bool"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("UndirectedFlowLowerBounds", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), } register_ilp_pipeline! { ("UndirectedTwoCommodityIntegralFlow", []), ("ILP", [("variable", "i64"), ("coefficient", "i64")]), - ("ILP", [("variable", "i64"), ("coefficient", "f64")]), +} + +register_ilp_pipeline! { + ("DecisionLongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMaximum2Satisfiability", []), + ("Maximum2Satisfiability", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionOpenShopScheduling", []), + ("OpenShopScheduling", []), + ("ILP", [("variable", "i64"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionQUBO", [("weight", "i64")]), + ("QUBO", [("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionQuadraticAssignment", []), + ("QuadraticAssignment", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionRuralPostman", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("RuralPostman", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("ILP", [("variable", "i64"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionSequencingToMinimizeTardyTaskWeight", []), + ("SequencingToMinimizeTardyTaskWeight", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionSpinGlass", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "i64")]), + ("QUBO", [("weight", "i64")]), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), +} + +register_ilp_pipeline! { + ("DecisionStackerCrane", []), + ("StackerCrane", []), + ("ILP", [("variable", "bool"), ("coefficient", "i64")]), } diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index c044e3d36..dfe4484a4 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -1,13 +1,37 @@ //! Deterministic solver capabilities for exact problem variants. +use super::ilp::adapter::HighsAdapter; +use crate::models::algebraic::ILP; use crate::registry::VariantEntry; -use crate::rules::registry::{reduction_entries, AggregateReduceFn, ReduceFn, ReductionEntry}; +use crate::rules::registry::{reduction_entries, ReduceFn, ReductionEntry}; use crate::rules::DynReductionResult; use serde::Serialize; use std::any::Any; use std::collections::{BTreeMap, BTreeSet}; use std::sync::OnceLock; +/// Type erasure is resolved at the registry boundary, never inside the adapter. +fn solve_ilp_terminal( + source: &dyn Any, + adapter: &HighsAdapter, +) -> Result { + macro_rules! dispatch { + ($($v:ty, $c:ty);* $(;)?) => { $( + if let Some(ilp) = source.downcast_ref::>() { + let outcome = match adapter.solve(ilp) { + Ok(solution) => super::SolveOutcome::optimal(ilp, solution) + .map_err(crate::rules::ExtractionError::from)?, + Err(super::ilp::adapter::IlpBackendError::Infeasible) => super::SolveOutcome::Infeasible, + Err(error) => return Err(error.into()), + }; + return Ok(super::erase_outcome(outcome)); + } + )* }; + } + dispatch! { bool, i64; i64, i64; bool, f64; i64, f64; } + Err(super::ILPSolveError::UnsupportedProblemType) +} + /// Canonical identity of one concrete problem variant. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] pub struct ExactProblemKey { @@ -53,7 +77,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") + ) } } @@ -75,7 +102,7 @@ pub(crate) struct IlpPipelineRegistration { inventory::collect!(IlpPipelineRegistration); -type CustomizedSolveFn = fn(&dyn Any) -> Result, super::SolveError>; +type CustomizedSolveFn = fn(&dyn Any) -> Result; /// A dedicated solver registered for one exact problem variant. #[derive(Debug)] @@ -103,7 +130,7 @@ inventory::collect!(CustomizedSolverRegistration); #[derive(Debug)] pub(crate) struct CompiledIlpPipeline { path: Vec, - reducers: Vec<(ReduceFn, Option)>, + reducers: Vec, } impl CompiledIlpPipeline { @@ -118,90 +145,77 @@ impl CompiledIlpPipeline { fn solve_with( &self, source: &dyn Any, - solver: &super::ILPSolver, + adapter: &HighsAdapter, finish: impl FnOnce( - Box, + super::ErasedOutcome, Option<&dyn DynReductionResult>, ) -> Result, ) -> Result { - if self.reducers.is_empty() { - return finish(Box::new(solver.solve_dyn(source)?), None); - } - - let mut reductions: Vec> = Vec::new(); - for (reducer, _) in &self.reducers { - let input = reductions - .last() - .map(|step| step.target_problem_any()) - .unwrap_or(source); - reductions.push(reducer(input)?); + let chain = if self.reducers.is_empty() { + None + } else { + Some(crate::rules::ReductionChain::execute( + source, + &self.reducers, + )?) + }; + let target_problem = chain + .as_ref() + .map_or(source, |chain| chain.target_problem_any()); + let target = solve_ilp_terminal(target_problem, adapter)?; + let recovered = match &chain { + Some(chain) => chain.recover_erased(source, target)?, + None => target, + }; + if matches!(recovered, super::SolveOutcome::Feasible { .. }) { + return Err(crate::rules::ExtractionError::InsufficientSolutionQuality.into()); } - - let target = reductions - .last() - .expect("non-empty fixed pipeline must produce a 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(), - )); - } - } - source_solution = step.extract_solution_dyn(source_solution.as_ref())?; - } - finish(source_solution, Some(reductions[0].as_ref())) + finish( + recovered, + chain.as_ref().map(|chain| chain.steps[0].witness.as_ref()), + ) } pub(crate) fn solve( &self, source: &dyn Any, - solver: &super::ILPSolver, - ) -> Result { - self.solve_with(source, solver, |solution, first_reduction| { + adapter: &HighsAdapter, + ) -> Result { + self.solve_with(source, adapter, |outcome, first_reduction| { if let Some(reduction) = first_reduction { - return reduction - .source_solution_json(solution.as_ref()) - .map_err(super::ILPSolveError::from); + return Ok(reduction.source_result_json(outcome)?); + } + // The terminal already validated the native ILP type. Recover its + // coefficient type here to format the stored evaluation. + if source.is::>() || source.is::>() { + let outcome = + super::downcast_outcome::, crate::types::Extremum>(outcome)?; + Ok(super::outcome_to_json(&outcome)?) + } else { + let outcome = + super::downcast_outcome::, crate::types::Extremum>(outcome)?; + Ok(super::outcome_to_json(&outcome)?) } - Ok(serde_json::to_value( - *solution - .downcast::>() - .expect("ILP backend returned the wrong solution type"), - ) - .expect("ILP solution serialization failed")) }) } - pub(crate) fn solve_typed( + pub(crate) fn solve_typed

( &self, - source: &dyn Any, - solver: &super::ILPSolver, - ) -> Result { - self.solve_with(source, solver, |solution, _| { - solution - .downcast::() - .map(|solution| *solution) - .map_err(|_| { - super::ILPSolveError::PipelineTypeMismatch( - self.path - .first() - .expect("compiled pipeline has a source") - .label(), - ) - }) + source: &P, + adapter: &HighsAdapter, + ) -> Result + where + P: crate::traits::Problem + 'static, + P::Solution: 'static, + { + self.solve_with(source, adapter, |outcome, _| { + let solution = outcome + .into_solution() + .ok_or(super::ILPSolveError::Infeasible)?; + let solution = solution + .downcast::() + .map_err(|_| super::ILPSolveError::PipelineTypeMismatch(self.path[0].label()))?; + Ok(*solution) }) } } @@ -287,7 +301,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 native ILP")] UnsupportedTarget(String), #[error("ILP pipeline for {0} continues after reaching a supported ILP node")] ContinuesAfterIlp(String), @@ -411,12 +425,11 @@ fn build_registry( matches: matches.len(), }); } - reducers.push(( + reducers.push( matches[0] .reduce_fn .expect("indexed only entries with reduce_fn"), - matches[0].reduce_aggregate_fn, - )); + ); } if registry @@ -485,10 +498,12 @@ pub(crate) fn brute_force_registration( #[doc(hidden)] pub fn brute_force_dimensions( problem: &crate::registry::LoadedDynProblem, -) -> Result>, &'static RegistryBuildError> { +) -> Result>, crate::solvers::SolveError> { let key = ExactProblemKey::new(problem.problem_name(), problem.variant_map()); - Ok(brute_force_registration(&key)? - .map(|registration| (registration.dimensions_fn)(problem.as_any()))) + brute_force_registration(&key) + .map_err(crate::solvers::SolveError::InvalidRegistry)? + .map(|registration| (registration.dimensions_fn)(problem.as_any())) + .transpose() } #[cfg(test)] diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs index 6444d1254..9c314d8f4 100644 --- a/src/solvers/resolver.rs +++ b/src/solvers/resolver.rs @@ -2,6 +2,7 @@ use super::registry::CompiledIlpPipeline; use super::registry::{solver_capability_registry, CustomizedSolverRegistration, ExactProblemKey}; +use super::SolveOutcome; use crate::registry::LoadedDynProblem; use serde::Serialize; @@ -19,6 +20,7 @@ pub enum SolverRequest { #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum SolverExecution { + External, Customized { implementation: &'static str }, Ilp { reduction_path: Vec }, BruteForce, @@ -31,20 +33,6 @@ pub struct SolveResult { pub outcome: SolveOutcome, } -/// Semantic result of a completed solve under the selected backend's numerical contract. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum SolveOutcome { - /// The selected backend established optimality and returned a solution. - /// ILP optimality is subject to backend numerical tolerances. - Optimal { - solution: serde_json::Value, - evaluation: String, - }, - /// The selected backend established infeasibility under its numerical contract. - Infeasible, -} - fn problem_key(problem: &LoadedDynProblem) -> ExactProblemKey { ExactProblemKey::new(problem.problem_name(), problem.variant_map()) } @@ -53,13 +41,7 @@ fn solve_customized( problem: &LoadedDynProblem, registration: &'static CustomizedSolverRegistration, ) -> Result { - let outcome = match (registration.solve_fn)(problem.as_any())? { - Some(solution) => SolveOutcome::Optimal { - evaluation: problem.evaluate_dyn(&solution)?, - solution, - }, - None => SolveOutcome::Infeasible, - }; + let outcome = (registration.solve_fn)(problem.as_any())?; Ok(SolveResult { solver: SolverExecution::Customized { implementation: registration.implementation, @@ -72,19 +54,15 @@ fn solve_ilp( problem: &LoadedDynProblem, pipeline: &CompiledIlpPipeline, ) -> Result { - let outcome = match pipeline.solve(problem.as_any(), &super::ILPSolver::new()) { - Ok(solution) => SolveOutcome::Optimal { - evaluation: problem.evaluate_dyn(&solution)?, - solution, - }, - Err(super::ILPSolveError::Infeasible) => SolveOutcome::Infeasible, - Err(source) => { - return Err(super::SolveError::IlpSolve { - problem: problem_key(problem).label(), - source, - }); - } - }; + let outcome = pipeline + .solve( + problem.as_any(), + &super::ilp::adapter::HighsAdapter::new(None), + ) + .map_err(|source| super::SolveError::IlpSolve { + problem: problem_key(problem).label(), + source, + })?; Ok(SolveResult { solver: SolverExecution::Ilp { reduction_path: pipeline.path_labels(), diff --git a/src/topology/bipartite_graph.rs b/src/topology/bipartite_graph.rs index a99d5dbdf..306d3f8ac 100644 --- a/src/topology/bipartite_graph.rs +++ b/src/topology/bipartite_graph.rs @@ -22,6 +22,7 @@ use serde::{Deserialize, Serialize}; /// assert!(g.has_edge(0, 2)); // left 0 -> right 0 (unified index 2) /// ``` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "BipartiteGraphData")] pub struct BipartiteGraph { left_size: usize, right_size: usize, @@ -29,6 +30,20 @@ pub struct BipartiteGraph { edges: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct BipartiteGraphData { + left_size: usize, + right_size: usize, + edges: Vec<(usize, usize)>, +} + +impl TryFrom for BipartiteGraph { + type Error = crate::registry::ConstructionError; + fn try_from(data: BipartiteGraphData) -> Result { + Self::try_new(data.left_size, data.right_size, data.edges) + } +} + impl BipartiteGraph { /// Create a new bipartite graph. /// @@ -42,25 +57,36 @@ impl BipartiteGraph { /// /// Panics if any edge references an out-of-bounds left or right vertex index. pub fn new(left_size: usize, right_size: usize, edges: Vec<(usize, usize)>) -> Self { + Self::try_new(left_size, right_size, edges).unwrap_or_else(|error| panic!("{error}")) + } + + pub(crate) fn try_new( + left_size: usize, + right_size: usize, + edges: Vec<(usize, usize)>, + ) -> Result { + left_size + .checked_add(right_size) + .ok_or("bipartite vertex count overflows usize")?; for &(u, v) in &edges { - assert!( - u < left_size, - "left vertex {} out of bounds (left_size={})", - u, - left_size - ); - assert!( - v < right_size, - "right vertex {} out of bounds (right_size={})", - v, - right_size - ); + if !(u < left_size) { + return Err( + format!("left vertex {} out of bounds (left_size={})", u, left_size).into(), + ); + } + if !(v < right_size) { + return Err(format!( + "right vertex {} out of bounds (right_size={})", + v, right_size + ) + .into()); + } } - Self { + Ok(Self { left_size, right_size, edges, - } + }) } /// Returns the number of vertices in the left partition. diff --git a/src/topology/directed_graph.rs b/src/topology/directed_graph.rs index 84fe30027..bd2b7f2c8 100644 --- a/src/topology/directed_graph.rs +++ b/src/topology/directed_graph.rs @@ -53,21 +53,28 @@ impl DirectedGraph { /// /// Panics if any arc references a vertex index >= `num_vertices`. pub fn new(num_vertices: usize, arcs: Vec<(usize, usize)>) -> Self { + Self::try_new(num_vertices, arcs).unwrap_or_else(|error| panic!("{error}")) + } + + pub(crate) fn try_new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + ) -> Result { let mut inner = DiGraph::new(); for _ in 0..num_vertices { inner.add_node(()); } for (u, v) in arcs { - assert!( - u < num_vertices && v < num_vertices, - "arc ({}, {}) references vertex >= num_vertices ({})", - u, - v, - num_vertices - ); + if !(u < num_vertices && v < num_vertices) { + return Err(format!( + "arc ({}, {}) references vertex >= num_vertices ({})", + u, v, num_vertices + ) + .into()); + } inner.add_edge(NodeIndex::new(u), NodeIndex::new(v), ()); } - Self { inner } + Ok(Self { inner }) } /// Creates an empty directed graph with the given number of vertices and no arcs. @@ -263,7 +270,7 @@ impl<'de> Deserialize<'de> for DirectedGraph { arcs: Vec<(usize, usize)>, } let data = GraphData::deserialize(deserializer)?; - Ok(DirectedGraph::new(data.num_vertices, data.arcs)) + DirectedGraph::try_new(data.num_vertices, data.arcs).map_err(serde::de::Error::custom) } } diff --git a/src/topology/graph.rs b/src/topology/graph.rs index 263b64aeb..6038b6cd2 100644 --- a/src/topology/graph.rs +++ b/src/topology/graph.rs @@ -116,21 +116,28 @@ impl SimpleGraph { /// /// Panics if any edge references a vertex index >= num_vertices. pub fn new(num_vertices: usize, edges: Vec<(usize, usize)>) -> Self { + Self::try_new(num_vertices, edges).unwrap_or_else(|error| panic!("{error}")) + } + + pub(crate) fn try_new( + num_vertices: usize, + edges: Vec<(usize, usize)>, + ) -> Result { let mut inner = UnGraph::new_undirected(); for _ in 0..num_vertices { inner.add_node(()); } for (u, v) in edges { - assert!( - u < num_vertices && v < num_vertices, - "edge ({}, {}) references vertex >= num_vertices ({})", - u, - v, - num_vertices - ); + if !(u < num_vertices && v < num_vertices) { + return Err(format!( + "edge ({}, {}) references vertex >= num_vertices ({})", + u, v, num_vertices + ) + .into()); + } inner.add_edge(NodeIndex::new(u), NodeIndex::new(v), ()); } - Self { inner } + Ok(Self { inner }) } /// Creates an empty graph with the given number of vertices. @@ -279,7 +286,7 @@ impl<'de> Deserialize<'de> for SimpleGraph { edges: Vec<(usize, usize)>, } let data = GraphData::deserialize(deserializer)?; - Ok(SimpleGraph::new(data.num_vertices, data.edges)) + SimpleGraph::try_new(data.num_vertices, data.edges).map_err(serde::de::Error::custom) } } diff --git a/src/topology/mixed_graph.rs b/src/topology/mixed_graph.rs index 9d95fbac9..94c908783 100644 --- a/src/topology/mixed_graph.rs +++ b/src/topology/mixed_graph.rs @@ -12,12 +12,27 @@ use serde::{Deserialize, Serialize}; /// so higher-level models can use that order as part of their configuration /// semantics, but edge-membership queries treat them as unordered pairs. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "MixedGraphData")] pub struct MixedGraph { num_vertices: usize, arcs: Vec<(usize, usize)>, edges: Vec<(usize, usize)>, } +#[derive(Deserialize)] +struct MixedGraphData { + num_vertices: usize, + arcs: Vec<(usize, usize)>, + edges: Vec<(usize, usize)>, +} + +impl TryFrom for MixedGraph { + type Error = crate::registry::ConstructionError; + fn try_from(data: MixedGraphData) -> Result { + Self::try_new(data.num_vertices, data.arcs, data.edges) + } +} + impl MixedGraph { /// Create a new mixed graph. /// @@ -25,31 +40,39 @@ impl MixedGraph { /// /// Panics if any endpoint references a vertex outside `0..num_vertices`. pub fn new(num_vertices: usize, arcs: Vec<(usize, usize)>, edges: Vec<(usize, usize)>) -> Self { + Self::try_new(num_vertices, arcs, edges).unwrap_or_else(|error| panic!("{error}")) + } + + pub(crate) fn try_new( + num_vertices: usize, + arcs: Vec<(usize, usize)>, + edges: Vec<(usize, usize)>, + ) -> Result { for &(u, v) in &arcs { - assert!( - u < num_vertices && v < num_vertices, - "arc ({}, {}) references vertex >= num_vertices ({})", - u, - v, - num_vertices - ); + if !(u < num_vertices && v < num_vertices) { + return Err(format!( + "arc ({}, {}) references vertex >= num_vertices ({})", + u, v, num_vertices + ) + .into()); + } } for &(u, v) in &edges { - assert!( - u < num_vertices && v < num_vertices, - "edge ({}, {}) references vertex >= num_vertices ({})", - u, - v, - num_vertices - ); + if !(u < num_vertices && v < num_vertices) { + return Err(format!( + "edge ({}, {}) references vertex >= num_vertices ({})", + u, v, num_vertices + ) + .into()); + } } - Self { + Ok(Self { num_vertices, arcs, edges, - } + }) } /// Create an empty mixed graph with no arcs or undirected edges. diff --git a/src/topology/planar_graph.rs b/src/topology/planar_graph.rs index a29a2b042..c37b752ef 100644 --- a/src/topology/planar_graph.rs +++ b/src/topology/planar_graph.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; /// assert_eq!(g.num_vertices(), 4); /// assert_eq!(g.num_edges(), 6); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct PlanarGraph { inner: SimpleGraph, } @@ -30,18 +30,14 @@ impl PlanarGraph { /// # Panics /// Panics if the graph violates the necessary planarity condition |E| <= 3|V| - 6. pub fn new(num_vertices: usize, edges: Vec<(usize, usize)>) -> Self { - let inner = SimpleGraph::new(num_vertices, edges); - if num_vertices >= 3 { - let max_edges = 3 * num_vertices - 6; - assert!( - inner.num_edges() <= max_edges, - "graph has {} edges but a planar graph on {} vertices can have at most {} edges", - inner.num_edges(), - num_vertices, - max_edges - ); - } - Self { inner } + Self::try_new(num_vertices, edges).unwrap_or_else(|error| panic!("{error}")) + } + + pub(crate) fn try_new( + num_vertices: usize, + edges: Vec<(usize, usize)>, + ) -> Result { + Self::try_from(SimpleGraph::try_new(num_vertices, edges)?) } /// Get a reference to the underlying SimpleGraph. @@ -50,6 +46,31 @@ impl PlanarGraph { } } +impl TryFrom for PlanarGraph { + type Error = crate::registry::ConstructionError; + fn try_from(inner: SimpleGraph) -> Result { + let num_vertices = inner.num_vertices(); + if num_vertices >= 3 { + let max_edges = 3 * (num_vertices as u128) - 6; + if inner.num_edges() as u128 > max_edges { + return Err(format!("graph has {} edges but a planar graph on {num_vertices} vertices can have at most {max_edges} edges", inner.num_edges()).into()); + } + } + Ok(Self { inner }) + } +} + +impl<'de> Deserialize<'de> for PlanarGraph { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct PlanarGraphData { + inner: SimpleGraph, + } + let data = PlanarGraphData::deserialize(deserializer)?; + Self::try_from(data.inner).map_err(serde::de::Error::custom) + } +} + impl Graph for PlanarGraph { const NAME: &'static str = "PlanarGraph"; diff --git a/src/traits.rs b/src/traits.rs index 4c1cf333d..a68fffe77 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -2,11 +2,14 @@ use crate::types::ProblemParameters; -/// Failure while evaluating one configuration of a valid problem instance. +/// Failure while evaluating a candidate or validating its claimed feasibility. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum EvaluationError { #[error("invalid configuration: {0}")] InvalidConfiguration(String), + /// A candidate being labeled feasible or optimal violates the problem constraints. + #[error("candidate solution violates the problem constraints")] + ConstraintViolation, #[error("integer overflow while {0}")] IntegerOverflow(String), #[error("inexact integer-to-float conversion while {0}")] @@ -15,6 +18,17 @@ pub enum EvaluationError { NonFiniteResult(String), } +/// Feasibility of one candidate, as determined by the model's evaluation. +/// +/// Independent of aggregation and solver capabilities. An invalid value rejects +/// this candidate; it does not establish that the problem has no feasible solution. +/// Required wherever a candidate is labeled feasible: solve outcomes, reduction +/// recovery, and registration. `Problem::Value` itself does not require it, so +/// fold-only values such as `Sum` and `And` remain usable for evaluation. +pub trait EvaluationValue: Clone { + fn is_valid(&self) -> bool; +} + /// Minimal problem trait — a problem maps a solution to a value or an /// evaluation error. /// diff --git a/src/truth_table.rs b/src/truth_table.rs index 479b02983..a829ba264 100644 --- a/src/truth_table.rs +++ b/src/truth_table.rs @@ -3,6 +3,7 @@ //! This module provides a `TruthTable` type for representing boolean functions //! and their truth tables, useful for constructing logic gadgets in reductions. +use crate::registry::ConstructionError; use bitvec::prelude::*; use serde::{Deserialize, Serialize}; @@ -45,10 +46,8 @@ 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(), - }) + Self::from_outputs(serde_repr.num_inputs, serde_repr.outputs) + .map_err(serde::de::Error::custom) } } @@ -57,42 +56,64 @@ impl TruthTable { /// /// 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() - ); - - let bits: BitVec = outputs.into_iter().collect(); - Self { + pub fn from_outputs(num_inputs: usize, outputs: Vec) -> Result { + let expected_len = Self::row_count(num_inputs)?; + if outputs.len() != expected_len { + return Err(ConstructionError::InvalidInput(format!( + "outputs length must be 2^num_inputs = {expected_len}, got {}", + outputs.len() + ))); + } + let mut bits = Self::allocate_outputs(expected_len)?; + for (mut bit, output) in bits.iter_mut().zip(outputs) { + *bit = output; + } + Ok(Self { num_inputs, outputs: bits, - } + }) } - /// Create a truth table from a function. - /// - /// The function takes a slice of booleans (the input) and returns the output. - pub fn from_function(num_inputs: usize, f: F) -> Self + /// Create a truth table by evaluating a function for each input combination. + pub fn from_function(num_inputs: usize, f: F) -> Result where F: Fn(&[bool]) -> bool, { - let num_rows = 1 << num_inputs; - let mut outputs = BitVec::with_capacity(num_rows); - + let num_rows = Self::row_count(num_inputs)?; + let mut outputs = Self::allocate_outputs(num_rows)?; + let mut input = vec![false; num_inputs]; for i in 0..num_rows { - let input: Vec = (0..num_inputs).map(|j| (i >> j) & 1 == 1).collect(); - outputs.push(f(&input)); + for (j, bit) in input.iter_mut().enumerate() { + *bit = (i >> j) & 1 == 1; + } + outputs.set(i, f(&input)); } - - Self { + Ok(Self { num_inputs, outputs, - } + }) + } + + 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(|| { + ConstructionError::IntegerOverflow("representing truth-table rows".into()) + }) + } + + fn allocate_outputs(num_rows: usize) -> Result { + let words = num_rows.div_ceil(usize::BITS as usize); + let mut storage = Vec::::new(); + storage.try_reserve_exact(words).map_err(|error| { + ConstructionError::Conversion(format!("allocating truth-table storage: {error}")) + })?; + storage.resize(words, 0); + let mut outputs = BitVec::from_vec(storage); + outputs.truncate(num_rows); + Ok(outputs) } /// Get the number of input variables. @@ -102,7 +123,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. @@ -183,39 +204,39 @@ impl TruthTable { } /// Create an AND gate truth table. - pub fn and(num_inputs: usize) -> Self { + pub fn and(num_inputs: usize) -> Result { Self::from_function(num_inputs, |input| input.iter().all(|&b| b)) } /// Create an OR gate truth table. - pub fn or(num_inputs: usize) -> Self { + pub fn or(num_inputs: usize) -> Result { Self::from_function(num_inputs, |input| input.iter().any(|&b| b)) } /// Create a NOT gate truth table (1 input). pub fn not() -> Self { - Self::from_outputs(1, vec![true, false]) + Self::from_outputs(1, vec![true, false]).expect("NOT has two rows") } /// Create an XOR gate truth table. - pub fn xor(num_inputs: usize) -> Self { + pub fn xor(num_inputs: usize) -> Result { Self::from_function(num_inputs, |input| { input.iter().filter(|&&b| b).count() % 2 == 1 }) } /// Create a NAND gate truth table. - pub fn nand(num_inputs: usize) -> Self { + pub fn nand(num_inputs: usize) -> Result { Self::from_function(num_inputs, |input| !input.iter().all(|&b| b)) } /// Create a NOR gate truth table. - pub fn nor(num_inputs: usize) -> Self { + pub fn nor(num_inputs: usize) -> Result { Self::from_function(num_inputs, |input| !input.iter().any(|&b| b)) } /// Create an XNOR gate truth table. - pub fn xnor(num_inputs: usize) -> Self { + pub fn xnor(num_inputs: usize) -> Result { Self::from_function(num_inputs, |input| { input.iter().filter(|&&b| b).count().is_multiple_of(2) }) @@ -225,7 +246,7 @@ impl TruthTable { /// Input 0 is 'a', input 1 is 'b'. pub fn implies() -> Self { // Index 0: [F,F] -> T, Index 1: [T,F] -> F, Index 2: [F,T] -> T, Index 3: [T,T] -> T - Self::from_outputs(2, vec![true, false, true, true]) + Self::from_outputs(2, vec![true, false, true, true]).expect("implication has four rows") } /// Combine two truth tables using AND. diff --git a/src/types.rs b/src/types.rs index 03146feba..352e2972c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -4,13 +4,13 @@ use serde::de::{self, DeserializeOwned, Visitor}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; -/// Largest integer magnitude represented exactly by an IEEE 754 `f64`. +/// Maximum integer magnitude accepted by the exact `i64` to `f64` conversion. pub const MAX_EXACT_F64_INTEGER: i64 = (1_i64 << 53) - 1; -/// An `i64` cannot cross an exact-integer `f64` boundary without precision loss. +/// An `i64` is outside the supported exact-integer `f64` conversion range. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error( - "integer {value} is outside the exactly representable f64 range [{min}, {max}]", + "integer {value} is outside the supported exact-integer f64 conversion range [{min}, {max}]", min = -MAX_EXACT_F64_INTEGER, max = MAX_EXACT_F64_INTEGER )] @@ -29,7 +29,8 @@ pub enum NumericArithmeticError { NonFiniteResult, } -/// Convert an `i64` to `f64` only when the integer value remains exact. +/// Convert an `i64` to `f64` within the supported range ±(2^53 − 1). +/// Values outside this range are rejected even if individually representable. pub fn i64_to_exact_f64(value: i64) -> Result { if (-MAX_EXACT_F64_INTEGER..=MAX_EXACT_F64_INTEGER).contains(&value) { Ok(value as f64) @@ -38,7 +39,8 @@ pub fn i64_to_exact_f64(value: i64) -> Result { } } -/// Bound for objective value types (i64, f64, etc.) +/// Bound for objective value types (i64, f64, etc.). +/// Integers reject overflow; floats allow rounding and reject non-finite results. pub trait NumericSize: Clone + Default @@ -49,9 +51,9 @@ pub trait NumericSize: + std::ops::AddAssign + 'static { - /// Add two values when the exact result remains representable and finite. + /// Checked addition. fn checked_add_value(self, other: Self) -> Result; - /// Multiply two values when the exact result remains representable and finite. + /// Checked multiplication. fn checked_mul_value(self, other: Self) -> Result; } @@ -304,12 +306,6 @@ pub trait Aggregate: Clone + fmt::Debug + Serialize + DeserializeOwned { } } -/// Aggregate value whose optimum identifies contributing solutions. -pub trait SolutionAggregate: Aggregate { - /// Whether a solution-level value contributes to the final aggregate value. - fn contributes_to_solution(value: &Self, total: &Self) -> bool; -} - /// Maximum aggregate over feasible values. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Max(pub Option); @@ -338,14 +334,6 @@ impl Aggregat } } -impl SolutionAggregate - for Max -{ - fn contributes_to_solution(value: &Self, total: &Self) -> bool { - matches!((value, total), (Max(Some(value)), Max(Some(best))) if value == best) - } -} - impl fmt::Display for Max { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.0 { @@ -355,6 +343,12 @@ impl fmt::Display for Max { } } +impl crate::traits::EvaluationValue for Max { + fn is_valid(&self) -> bool { + Max::is_valid(self) + } +} + impl Max { pub fn is_valid(&self) -> bool { self.0.is_some() @@ -397,14 +391,6 @@ impl Aggregat } } -impl SolutionAggregate - for Min -{ - fn contributes_to_solution(value: &Self, total: &Self) -> bool { - matches!((value, total), (Min(Some(value)), Min(Some(best))) if value == best) - } -} - impl fmt::Display for Min { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.0 { @@ -414,6 +400,12 @@ impl fmt::Display for Min { } } +impl crate::traits::EvaluationValue for Min { + fn is_valid(&self) -> bool { + Min::is_valid(self) + } +} + impl Min { pub fn is_valid(&self) -> bool { self.0.is_some() @@ -429,7 +421,7 @@ impl Min { } /// Trait for aggregate values that represent optimization objectives. -pub trait OptimizationValue: Aggregate { +pub trait OptimizationValue: Aggregate + crate::traits::EvaluationValue { /// The inner numeric type used for comparisons with decision bounds. type Inner: Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned; @@ -484,6 +476,12 @@ impl fmt::Display for Sum { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Or(pub bool); +impl crate::traits::EvaluationValue for Or { + fn is_valid(&self) -> bool { + Or::is_valid(self) + } +} + impl Or { pub fn is_valid(&self) -> bool { self.0 @@ -508,12 +506,6 @@ impl Aggregate for Or { } } -impl SolutionAggregate for Or { - fn contributes_to_solution(value: &Self, total: &Self) -> bool { - value.0 && total.0 - } -} - impl fmt::Display for Or { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Or({})", self.0) @@ -576,6 +568,12 @@ pub struct Extremum { pub value: Option, } +impl crate::traits::EvaluationValue for Extremum { + fn is_valid(&self) -> bool { + Extremum::is_valid(self) + } +} + impl Extremum { pub fn maximize(value: Option) -> Self { Self { @@ -648,17 +646,6 @@ impl Aggregat } } -impl SolutionAggregate - for Extremum -{ - fn contributes_to_solution(candidate: &Self, total: &Self) -> bool { - matches!( - (candidate.value.as_ref(), total.value.as_ref()), - (Some(value), Some(best)) if candidate.sense == total.sense && value == best - ) - } -} - impl fmt::Display for Extremum { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match (&self.sense, &self.value) { diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 73d85007f..b6d791a32 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -4,6 +4,7 @@ use crate::example_db::{ use crate::export::ProblemRef; use crate::registry::load_dyn; use crate::rules::{registry::reduction_entries, ReductionGraph}; +use crate::solvers::SolveOutcome; use std::collections::{BTreeMap, BTreeSet, HashSet}; #[test] @@ -559,6 +560,9 @@ fn model_specs_are_optimal() { let loaded = load_dyn(name, &variant, spec.instance.serialize_json()).ok()?; match solve(&loaded, request).ok()?.outcome { SolveOutcome::Optimal { solution, .. } => Some(solution), + SolveOutcome::Feasible { .. } => { + panic!("exact solver returned only a feasible incumbent") + } SolveOutcome::Infeasible => None, } }; @@ -650,41 +654,19 @@ fn rule_specs_solution_pairs_are_consistent() { .into_iter() .find(|path| path.len() == 1); if witness_path.is_none() { - let has_aggregate_path = graph - .find_all_paths_mode( + assert!( + graph + .has_direct_reduction_by_name(&example.source.problem, &example.target.problem), + "No direct reduction for {label}" + ); + assert!( + !graph.has_direct_reduction_by_name_mode( &example.source.problem, - &example.source.variant, &example.target.problem, - &example.target.variant, - crate::rules::ReductionMode::Aggregate, - ) - .iter() - .any(|path| path.len() == 1); - if !has_aggregate_path { - assert!( - graph.has_direct_reduction_by_name( - &example.source.problem, - &example.target.problem - ), - "No direct witness, aggregate, or proof-only reduction for {label}" - ); - assert!( - !graph.has_direct_reduction_by_name_mode( - &example.source.problem, - &example.target.problem, - crate::rules::ReductionMode::Witness, - ), - "Proof-only edge unexpectedly exposed witness mode for {label}" - ); - assert!( - !graph.has_direct_reduction_by_name_mode( - &example.source.problem, - &example.target.problem, - crate::rules::ReductionMode::Aggregate, - ), - "Proof-only edge unexpectedly exposed aggregate mode for {label}" - ); - } + crate::rules::ReductionMode::Witness + ), + "Proof-only edge unexpectedly executable for {label}" + ); } // Only do witness round-trip when a witness path exists @@ -712,35 +694,25 @@ fn rule_specs_solution_pairs_are_consistent() { .unwrap_or_else(|error| { panic!("Rule {label}: source configuration evaluation failed: {error}") }); - assert_ne!( - source_eval, "Max(None)", - "Rule {label}: source_config evaluates to Max(None)" - ); - assert_ne!( - source_eval, "Min(None)", - "Rule {label}: source_config evaluates to Min(None)" - ); - assert_ne!( - source_eval, "Or(false)", - "Rule {label}: source_config evaluates to Or(false)" + assert!( + source_eval.1, + "Rule {label}: infeasible source configuration: {}", + source_eval.0 ); - assert_ne!( - target_eval, "Max(None)", - "Rule {label}: target_config evaluates to Max(None)" - ); - assert_ne!( - target_eval, "Min(None)", - "Rule {label}: target_config evaluates to Min(None)" - ); - assert_ne!( - target_eval, "Or(false)", - "Rule {label}: target_config evaluates to Or(false)" + assert!( + target_eval.1, + "Rule {label}: infeasible target configuration: {}", + target_eval.0 ); // 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 { let extracted = chain - .extract_solution_json(pair.target_config.clone()) + .recover_result_json( + source.as_any(), + serde_json::json!({"status": "optimal", "solution": pair.target_config, "evaluation": target_eval.0}), + ) + .map(|(outcome, _)| outcome.into_solution().unwrap()) .unwrap(); let extracted_val = source .evaluate_json(&extracted) @@ -753,9 +725,37 @@ fn rule_specs_solution_pairs_are_consistent() { extracted_val, source_val, extracted, pair.source_config ); + assert_eq!( + chain + .recover_result_json( + source.as_any(), + serde_json::json!({"status": "infeasible"}) + ) + .unwrap() + .0, + SolveOutcome::Infeasible, + "Rule {label}: target infeasibility must propagate" + ); + match chain.recover_result_json(source.as_any(), serde_json::json!({ + "status": "feasible", "solution": pair.target_config, "evaluation": target_eval.0, + })) { + Ok((SolveOutcome::Feasible { solution, evaluation }, _)) => { + let (actual, valid) = source.evaluate_dyn(&solution).unwrap(); + assert!(valid, "Rule {label}: feasible recovery returned an invalid source witness"); + assert_eq!(evaluation, actual); + } + Err(error) if error.is_insufficient_quality() => {} + result => panic!("Rule {label}: feasible recovery returned an unjustified status: {result:?}"), + } + let malformed = serde_json::json!({"invalid_solution": true}); assert!( - chain.extract_solution_json(malformed).is_err(), + chain + .recover_result_json( + source.as_any(), + serde_json::json!({"status": "optimal", "solution": malformed}) + ) + .is_err(), "Rule {label}: extraction accepted malformed target-solution JSON" ); } @@ -896,15 +896,16 @@ fn test_find_rule_example_ksatisfiability_to_minimumvertexcover() { variant: BTreeMap::from([("k".to_string(), "K3".to_string())]), }; let target = ProblemRef { - name: "MinimumVertexCover".to_string(), + name: "DecisionMinimumVertexCover".to_string(), variant: BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), - ("weight".to_string(), "i64".to_string()), + ("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"); + assert_eq!(example.target.variant, target.variant); } #[test] @@ -980,12 +981,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 +996,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 +1004,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 +1032,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 +1103,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 +1111,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 +1267,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 +1275,7 @@ fn test_find_rule_example_naesatisfiability_to_maxcut() { }; let example = find_rule_example(&source, &target).unwrap(); assert_eq!(example.source.problem, "NAESatisfiability"); - assert_eq!(example.target.problem, "MaxCut"); + assert_eq!(example.target.problem, "DecisionMaxCut"); } #[test] diff --git a/src/unit_tests/graph_models.rs b/src/unit_tests/graph_models.rs index 675a9c487..190bd9705 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 @@ -29,7 +29,7 @@ mod maximum_independent_set { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.num_variables(), 4); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] @@ -268,7 +268,7 @@ mod minimum_vertex_cover { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.num_variables(), 4); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] @@ -502,7 +502,10 @@ mod integral_flow_homologous_arcs { ); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 8); - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); } } @@ -519,7 +522,7 @@ mod kcoloring { assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); assert_eq!(problem.num_colors(), 3); - assert_eq!(problem.num_variables(), 4); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] @@ -600,7 +603,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 +614,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/algebraic_equations_over_gf2.rs b/src/unit_tests/models/algebraic/algebraic_equations_over_gf2.rs index 87df06fc5..804ad6c59 100644 --- a/src/unit_tests/models/algebraic/algebraic_equations_over_gf2.rs +++ b/src/unit_tests/models/algebraic/algebraic_equations_over_gf2.rs @@ -1,6 +1,5 @@ use crate::models::algebraic::AlgebraicEquationsOverGF2; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Or; @@ -39,7 +38,10 @@ fn test_algebraic_equations_over_gf2_creation_and_accessors() { assert_eq!(p.num_variables(), 3); assert_eq!(p.num_equations(), 3); assert_eq!(p.equations().len(), 3); - assert_eq!(p.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); assert_eq!(p.num_variables(), 3); assert_eq!( ::NAME, @@ -71,7 +73,10 @@ fn test_algebraic_equations_over_gf2_evaluate_satisfiable() { #[test] fn test_algebraic_equations_over_gf2_evaluate_unsatisfiable() { let p = unsatisfiable_problem(); - assert_eq!(p.dimensions(), vec![2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2] + ); // All 4 assignments should fail assert_eq!(p.evaluate(&vec![false, false]).unwrap(), Or(false)); // eq0: 0+0=0 ✓, eq1: 0+0+1=1 ✗ assert_eq!(p.evaluate(&vec![false, true]).unwrap(), Or(false)); // eq0: 0+1=1 ✗ diff --git a/src/unit_tests/models/algebraic/bmf.rs b/src/unit_tests/models/algebraic/bmf.rs index a32c2a23c..9dfc763ab 100644 --- a/src/unit_tests/models/algebraic/bmf.rs +++ b/src/unit_tests/models/algebraic/bmf.rs @@ -11,7 +11,7 @@ fn test_bmf_creation() { assert_eq!(problem.rows(), 2); assert_eq!(problem.cols(), 2); assert_eq!(problem.rank(), 2); - assert_eq!(problem.num_variables(), 8); // 2*2 + 2*2 + assert_eq!(problem.num_variables().unwrap(), 8); // 2*2 + 2*2 } #[test] @@ -168,7 +168,7 @@ fn test_matrix_hamming_distance_function() { fn test_empty_matrix() { let matrix: Vec> = vec![]; let problem = BMF::new(matrix, 1); - assert_eq!(problem.num_variables(), 0); + assert_eq!(problem.num_variables().unwrap(), 0); // Empty matrix factors exactly with zero factor size. assert_eq!( Problem::evaluate(&problem, &(vec![], vec![vec![]])).unwrap(), @@ -179,7 +179,10 @@ fn test_empty_matrix() { #[test] fn test_rank_zero_exactness() { let nonzero = BMF::new(vec![vec![true, false]], 0); - assert_eq!(nonzero.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&nonzero).unwrap(), + Vec::::new() + ); let empty_factors = (vec![vec![]], vec![]); assert_eq!(nonzero.hamming_distance(&empty_factors).unwrap(), 1); assert!(!nonzero.is_exact(&empty_factors).unwrap()); @@ -218,7 +221,10 @@ fn test_bmf_problem() { let problem = BMF::new(matrix, 2); // dims: B(2*2) + C(2*2) = 8 binary variables - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); // Exact factorization: B = I, C = I — total factor size = 4 assert_eq!( @@ -246,7 +252,10 @@ fn test_bmf_problem() { // 1x1 matrix let matrix = vec![vec![true]]; let problem = BMF::new(matrix, 1); - assert_eq!(problem.dimensions(), vec![2; 2]); // B(1*1) + C(1*1) + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 2] + ); // B(1*1) + C(1*1) assert_eq!( Problem::evaluate(&problem, &(vec![vec![true]], vec![vec![true]])).unwrap(), Min(Some(2)) @@ -290,3 +299,19 @@ fn test_bmf_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert!(problem.is_exact(&best).unwrap()); } + +#[test] +fn json_rejects_invalid_instance() { + assert!( + serde_json::from_value::(serde_json::json!({"matrix":[[true],[]],"k":1})).is_err() + ); +} + +#[test] +fn deserialize_rebuilds_matrix_dimensions() { + let model: BMF = serde_json::from_value(serde_json::json!({ + "matrix": [[true, false]], "k": 1, "m": 99, "n": 99 + })) + .unwrap(); + assert_eq!((model.rows(), model.cols(), model.rank()), (1, 2, 1)); +} diff --git a/src/unit_tests/models/algebraic/closest_vector_problem.rs b/src/unit_tests/models/algebraic/closest_vector_problem.rs index c78712962..fd7065289 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_coordinates() { 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,82 @@ fn test_cvp_rejects_invalid_basis() { } #[test] -fn test_cvp_reports_rank_arithmetic_overflow() { - let error = +fn test_cvp_rank_uses_exact_integer_elimination() { + let problem = ClosestVectorProblem::new(vec![vec![i64::MAX, 1], vec![1, i64::MAX]], vec![0_i64, 0]) - .unwrap_err(); - assert!(matches!(error, ConstructionError::IntegerOverflow(_))); -} - -#[test] -fn test_cvp_rejects_non_finite_real_target() { - assert!(matches!( - ClosestVectorProblem::new(vec![vec![1_i64]], vec![f64::NAN]), - Err(ConstructionError::NonFiniteFloat(_)) - )); - assert!(matches!( - ClosestVectorProblem::new(vec![vec![1_i64]], vec![f64::INFINITY]), - Err(ConstructionError::NonFiniteFloat(_)) - )); + .unwrap(); + assert_eq!(problem.independent_rows(), vec![0, 1]); + // Swapped pivots and a redundant ambient row preserve column rank. + let rectangular = + ClosestVectorProblem::new(vec![vec![0, 0, 1], vec![0, 1, 0]], vec![0_i64; 3]).unwrap(); + assert_eq!(rectangular.independent_rows(), vec![2, 1]); } #[test] -fn test_cvp_reports_exact_to_float_boundary() { - let problem = ClosestVectorProblem::new( - vec![vec![crate::types::MAX_EXACT_F64_INTEGER + 1]], - vec![0_i64], +fn test_cvp_integer_coordinates_preserve_zero_and_unit_distance() { + let target = (1_i64 << 53) + 1; + let problem = ClosestVectorProblem::new(vec![vec![1]], vec![target]).unwrap(); + assert_eq!( + crate::solvers::customized::closest_vector_problem::solve(&problem).unwrap(), + vec![target] + ); + assert_eq!(problem.squared_distance(&[target]).unwrap(), 0); + assert_eq!(problem.squared_distance(&[target - 1]).unwrap(), 1); + let cancellation = ClosestVectorProblem::new( + vec![vec![i64::MAX, 1], vec![i64::MAX - 1, 1]], + vec![1_i64, 0], ) .unwrap(); - assert!(matches!( - problem.evaluate(&vec![1]), - Err(crate::traits::EvaluationError::InexactFloatConversion(_)) - )); + assert_eq!(cancellation.squared_distance(&[1, -1]).unwrap(), 0); } #[test] -fn test_cvp_serialization_round_trips_both_targets() { +fn test_cvp_serialization_round_trips() { 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() { - let mut variants = crate::registry::variant_entries() +fn test_cvp_registers_integer_variant() { + let 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))); +} + +#[test] +fn test_cvp_reports_declared_arithmetic_errors() { + let integer = ClosestVectorProblem::new(vec![vec![2]], vec![0]).unwrap(); + assert!(matches!( + integer.evaluate(&vec![i64::MAX]), + Err(EvaluationError::IntegerOverflow(_)) + )); } diff --git a/src/unit_tests/models/algebraic/consecutive_block_minimization.rs b/src/unit_tests/models/algebraic/consecutive_block_minimization.rs index 92ae4a385..40bf02f8b 100644 --- a/src/unit_tests/models/algebraic/consecutive_block_minimization.rs +++ b/src/unit_tests/models/algebraic/consecutive_block_minimization.rs @@ -26,8 +26,11 @@ fn test_consecutive_block_minimization_basic() { assert_eq!(problem.num_rows(), 2); assert_eq!(problem.num_cols(), 3); assert_eq!(problem.bound(), 2); - assert_eq!(problem.num_variables(), 3); - assert_eq!(problem.dimensions(), vec![3; 3]); + assert_eq!(problem.num_variables().unwrap(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 3] + ); } #[test] diff --git a/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs b/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs index b3cce7057..b1c5bd6fc 100644 --- a/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs +++ b/src/unit_tests/models/algebraic/consecutive_ones_matrix_augmentation.rs @@ -43,8 +43,11 @@ fn test_consecutive_ones_matrix_augmentation_basic() { assert_eq!(problem.num_rows(), 4); assert_eq!(problem.num_cols(), 5); assert_eq!(problem.bound(), 2); - assert_eq!(problem.num_variables(), 5); - assert_eq!(problem.dimensions(), vec![5; 5]); + assert_eq!(problem.num_variables().unwrap(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 5] + ); assert_eq!( ::NAME, "ConsecutiveOnesMatrixAugmentation" @@ -159,3 +162,31 @@ fn test_consecutive_ones_matrix_augmentation_rejects_ragged_matrix() { fn test_consecutive_ones_matrix_augmentation_rejects_negative_bound() { ConsecutiveOnesMatrixAugmentation::new(issue_yes_matrix(), -1); } + +#[test] +fn test_consecutive_ones_matrix_augmentation_deserialization_rejects_invalid_instances() { + let valid = serde_json::json!({ + "matrix": [[true, false, true], [false, true, true], [true, true, false]], + "bound": 1, + }); + let restored: ConsecutiveOnesMatrixAugmentation = + serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), valid); + + let cases = [ + ( + "matrix", + serde_json::json!([[true, false, true], [false, true], [true, true, false]]), + "all matrix rows must have the same length", + ), + ("bound", serde_json::json!(-1), "bound must be nonnegative"), + ]; + for (field, value, expected) in cases { + let mut json = valid.clone(); + json[field] = value; + let error = serde_json::from_value::(json) + .unwrap_err() + .to_string(); + assert_eq!(error, format!("problem construction failed: {expected}")); + } +} diff --git a/src/unit_tests/models/algebraic/consecutive_ones_submatrix.rs b/src/unit_tests/models/algebraic/consecutive_ones_submatrix.rs index bc20d02df..586277a8d 100644 --- a/src/unit_tests/models/algebraic/consecutive_ones_submatrix.rs +++ b/src/unit_tests/models/algebraic/consecutive_ones_submatrix.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; /// Tucker matrix (3×4) — the classic C1P obstruction. @@ -18,7 +17,10 @@ fn test_consecutive_ones_submatrix_basic() { assert_eq!(problem.num_rows(), 3); assert_eq!(problem.num_cols(), 4); assert_eq!(problem.bound(), 3); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!( ::NAME, "ConsecutiveOnesSubmatrix" @@ -205,7 +207,10 @@ fn test_consecutive_ones_submatrix_empty_matrix_vacuous_case() { assert!(problem.matrix().is_empty()); assert_eq!(problem.num_rows(), 0); assert_eq!(problem.num_cols(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } @@ -233,3 +238,11 @@ fn test_consecutive_ones_submatrix_inconsistent_rows() { let matrix = vec![vec![true, false], vec![true]]; ConsecutiveOnesSubmatrix::new(matrix, 1); } + +#[test] +fn json_rejects_invalid_instance() { + assert!(serde_json::from_value::( + serde_json::json!({"matrix":[[true]],"bound":2}) + ) + .is_err()); +} diff --git a/src/unit_tests/models/algebraic/equilibrium_point.rs b/src/unit_tests/models/algebraic/equilibrium_point.rs index dd30d1d37..2ab175c18 100644 --- a/src/unit_tests/models/algebraic/equilibrium_point.rs +++ b/src/unit_tests/models/algebraic/equilibrium_point.rs @@ -51,8 +51,11 @@ fn test_equilibrium_point_creation_and_accessors() { assert_eq!(p.range_sets()[0], vec![0, 1]); assert_eq!(p.range_sets()[1], vec![0, 1]); assert_eq!(p.range_sets()[2], vec![0, 1]); - assert_eq!(p.dimensions(), vec![2, 2, 2]); - assert_eq!(p.num_variables(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); + assert_eq!(p.num_variables().unwrap(), 3); assert_eq!(::NAME, "EquilibriumPoint"); assert_eq!(::variant(), vec![]); } diff --git a/src/unit_tests/models/algebraic/feasible_basis_extension.rs b/src/unit_tests/models/algebraic/feasible_basis_extension.rs index 489d91788..6bd9fff2e 100644 --- a/src/unit_tests/models/algebraic/feasible_basis_extension.rs +++ b/src/unit_tests/models/algebraic/feasible_basis_extension.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_validates_matrix_shape() { @@ -41,7 +40,10 @@ fn test_feasible_basis_extension_creation() { assert_eq!(problem.num_rows(), 3); assert_eq!(problem.num_columns(), 6); assert_eq!(problem.num_required(), 2); - assert_eq!(problem.dimensions(), vec![2; 4]); // 6 - 2 = 4 free columns + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); // 6 - 2 = 4 free columns assert_eq!( ::NAME, "FeasibleBasisExtension" @@ -248,3 +250,11 @@ fn test_feasible_basis_extension_duplicate_required() { vec![0, 0], ); } + +#[test] +fn json_rejects_invalid_instance() { + assert!(serde_json::from_value::( + serde_json::json!({"matrix":[[1]],"rhs":[1],"required_columns":[]}) + ) + .is_err()); +} diff --git a/src/unit_tests/models/algebraic/ilp.rs b/src/unit_tests/models/algebraic/ilp.rs index 64bdc8143..7cc5d34d3 100644 --- a/src/unit_tests/models/algebraic/ilp.rs +++ b/src/unit_tests/models/algebraic/ilp.rs @@ -54,10 +54,43 @@ fn float_constraints_use_float_arithmetic() { ) .unwrap(); - assert!(ilp.is_feasible(&[1, 1]).unwrap()); + assert!(!ilp.is_feasible(&[1, 1]).unwrap()); + assert!(LinearConstraint::eq(vec![(0, 0.1), (1, 0.2)], 0.1 + 0.2) + .is_satisfied(&[1, 1]) + .unwrap()); assert_eq!(ilp.evaluate_objective(&[1, 0]).unwrap(), 0.5); } +#[test] +fn float_ilp_rejects_integer_values_without_an_exact_f64_image() { + use crate::traits::EvaluationError; + use crate::types::MAX_EXACT_F64_INTEGER; + + let ilp = ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, 1.0)], 1.0e16)], + vec![(0, 1.0)], + ObjectiveSense::Maximize, + ) + .unwrap(); + + assert_eq!( + ilp.evaluate_objective(&[MAX_EXACT_F64_INTEGER]).unwrap(), + MAX_EXACT_F64_INTEGER as f64 + ); + assert!(ilp.is_feasible(&[MAX_EXACT_F64_INTEGER]).unwrap()); + assert!(matches!( + ilp.is_feasible(&[MAX_EXACT_F64_INTEGER + 1]), + Err(EvaluationError::InexactFloatConversion(_)) + )); + for value in [MAX_EXACT_F64_INTEGER + 1, -MAX_EXACT_F64_INTEGER - 1] { + assert!(matches!( + ilp.evaluate_objective(&[value]), + Err(EvaluationError::InexactFloatConversion(_)) + )); + } +} + #[test] fn float_ilp_rejects_non_finite_coefficients() { assert!(matches!( diff --git a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs index 89eb9ad5f..41a312085 100644 --- a/src/unit_tests/models/algebraic/minimum_matrix_cover.rs +++ b/src/unit_tests/models/algebraic/minimum_matrix_cover.rs @@ -12,11 +12,14 @@ fn test_minimum_matrix_cover_creation() { vec![1, 0, 0, 4], vec![0, 2, 4, 0], ]; - let problem = MinimumMatrixCover::new(matrix.clone()); + let problem = MinimumMatrixCover::new(matrix.clone()).unwrap(); assert_eq!(problem.num_rows(), 4); assert_eq!(problem.matrix(), &matrix); - assert_eq!(problem.dimensions(), vec![2; 4]); - assert_eq!(problem.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] @@ -29,7 +32,7 @@ fn test_minimum_matrix_cover_evaluate_all_minus() { vec![1, 0, 0, 4], vec![0, 2, 4, 0], ]; - let problem = MinimumMatrixCover::new(matrix); + let problem = MinimumMatrixCover::new(matrix).unwrap(); let value = problem.evaluate(&vec![false, false, false, false]).unwrap(); // Sum of all entries = 0+3+1+0 + 3+0+0+2 + 1+0+0+4 + 0+2+4+0 = 20 assert_eq!(value, Min(Some(20))); @@ -43,7 +46,7 @@ fn test_minimum_matrix_cover_evaluate_mixed() { vec![1, 0, 0, 4], vec![0, 2, 4, 0], ]; - let problem = MinimumMatrixCover::new(matrix); + let problem = MinimumMatrixCover::new(matrix).unwrap(); // Config [0,1,1,0] → f=(-1,+1,+1,-1) // Compute: Σ a_ij * f(i) * f(j) @@ -64,7 +67,7 @@ fn test_minimum_matrix_cover_evaluate_mixed() { #[test] fn test_minimum_matrix_cover_evaluate_invalid() { - let problem = MinimumMatrixCover::new(vec![vec![0, 1], vec![1, 0]]); + let problem = MinimumMatrixCover::new(vec![vec![0, 1], vec![1, 0]]).unwrap(); // Wrong length assert!(matches!( @@ -86,7 +89,7 @@ fn test_minimum_matrix_cover_solver() { vec![1, 0, 0, 4], vec![0, 2, 4, 0], ]; - let problem = MinimumMatrixCover::new(matrix); + let problem = MinimumMatrixCover::new(matrix).unwrap(); let solver = BruteForce::new(); let value_solution = solver.solve(&problem).unwrap().unwrap(); @@ -103,7 +106,7 @@ fn test_minimum_matrix_cover_solver() { #[test] fn test_minimum_matrix_cover_serialization() { let matrix = vec![vec![0, 1], vec![1, 0]]; - let problem = MinimumMatrixCover::new(matrix); + let problem = MinimumMatrixCover::new(matrix).unwrap(); let json = serde_json::to_string(&problem).unwrap(); let deserialized: MinimumMatrixCover = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.num_rows(), 2); @@ -114,7 +117,7 @@ fn test_minimum_matrix_cover_serialization() { fn test_minimum_matrix_cover_1x1() { // 1×1 matrix: only one variable, f(1) = ±1 // value = a_11 * f(1)^2 = a_11 regardless of sign - let problem = MinimumMatrixCover::new(vec![vec![5]]); + let problem = MinimumMatrixCover::new(vec![vec![5]]).unwrap(); assert_eq!(problem.evaluate(&vec![false]).unwrap(), Min(Some(5))); assert_eq!(problem.evaluate(&vec![true]).unwrap(), Min(Some(5))); @@ -136,7 +139,7 @@ fn test_minimum_matrix_cover_paper_example() { vec![1, 0, 0, 4], vec![0, 2, 4, 0], ]; - let problem = MinimumMatrixCover::new(matrix); + let problem = MinimumMatrixCover::new(matrix).unwrap(); let solver = BruteForce::new(); // Verify the claimed optimal from the issue @@ -167,3 +170,17 @@ fn test_minimum_matrix_cover_canonical_example_spec() { serde_json::json!([false, true, true, false]) ); } + +#[test] +fn construction_and_deserialization_enforce_nonnegative_square_matrices() { + for matrix in [vec![vec![-1]], vec![vec![0, 1]], vec![vec![0, 1], vec![1]]] { + assert!(matches!( + MinimumMatrixCover::new(matrix.clone()), + Err(ConstructionError::InvalidInput(_)) + )); + assert!(serde_json::from_value::( + serde_json::json!({"matrix": matrix}) + ) + .is_err()); + } +} diff --git a/src/unit_tests/models/algebraic/minimum_matrix_domination.rs b/src/unit_tests/models/algebraic/minimum_matrix_domination.rs index 7e31fa47e..b9ece5831 100644 --- a/src/unit_tests/models/algebraic/minimum_matrix_domination.rs +++ b/src/unit_tests/models/algebraic/minimum_matrix_domination.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -22,7 +21,10 @@ fn test_minimum_matrix_domination_creation() { assert_eq!(problem.num_rows(), 6); assert_eq!(problem.num_cols(), 6); assert_eq!(problem.num_ones(), 10); - assert_eq!(problem.dimensions(), vec![2; 10]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 10] + ); assert_eq!( ::NAME, "MinimumMatrixDomination" @@ -152,7 +154,10 @@ fn test_minimum_matrix_domination_single_row() { fn test_minimum_matrix_domination_empty_matrix() { let problem = MinimumMatrixDomination::new(vec![]); assert_eq!(problem.num_ones(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); // Empty config: vacuously valid with 0 selected assert_eq!(problem.evaluate(&vec![]).unwrap(), Min(Some(0))); } @@ -200,3 +205,20 @@ fn test_minimum_matrix_domination_inconsistent_rows() { let matrix = vec![vec![true, false], vec![true]]; MinimumMatrixDomination::new(matrix); } + +#[test] +fn json_rejects_invalid_instance() { + assert!(serde_json::from_value::( + serde_json::json!({"matrix":[[true],[]]}) + ) + .is_err()); +} + +#[test] +fn deserialize_rebuilds_nonzero_positions() { + let model: MinimumMatrixDomination = serde_json::from_value(serde_json::json!({ + "matrix": [[true, false], [false, true]], "ones": [[99, 99]] + })) + .unwrap(); + assert_eq!(model.ones(), &[(0, 0), (1, 1)]); +} diff --git a/src/unit_tests/models/algebraic/minimum_weight_decoding.rs b/src/unit_tests/models/algebraic/minimum_weight_decoding.rs index 352d1b2f8..e098cbb22 100644 --- a/src/unit_tests/models/algebraic/minimum_weight_decoding.rs +++ b/src/unit_tests/models/algebraic/minimum_weight_decoding.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_maps_rhs_to_target() { @@ -30,7 +29,10 @@ fn test_minimum_weight_decoding_creation() { let problem = example_instance(); assert_eq!(problem.num_rows(), 3); assert_eq!(problem.num_cols(), 4); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!( ::NAME, "MinimumWeightDecoding" @@ -176,3 +178,11 @@ fn test_minimum_weight_decoding_target_mismatch() { let matrix = vec![vec![true, false], vec![false, true]]; MinimumWeightDecoding::new(matrix, vec![true]); } + +#[test] +fn json_rejects_invalid_instance() { + assert!(serde_json::from_value::( + serde_json::json!({"matrix":[[true]],"target":[]}) + ) + .is_err()); +} diff --git a/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs b/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs index b4603a584..8d9b0b31a 100644 --- a/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs +++ b/src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_rhs_length_mismatch() { @@ -27,7 +26,10 @@ fn test_minimum_weight_solution_creation() { let problem = example_instance(); assert_eq!(problem.num_equations(), 2); assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!( ::NAME, "MinimumWeightSolutionToLinearEquations" @@ -160,3 +162,13 @@ fn test_minimum_weight_solution_rhs_mismatch() { let matrix = vec![vec![1, 2], vec![3, 4]]; MinimumWeightSolutionToLinearEquations::new(matrix, vec![1]); } + +#[test] +fn json_rejects_invalid_instance() { + assert!( + serde_json::from_value::( + serde_json::json!({"matrix":[[1]],"rhs":[]}) + ) + .is_err() + ); +} diff --git a/src/unit_tests/models/algebraic/quadratic_assignment.rs b/src/unit_tests/models/algebraic/quadratic_assignment.rs index 66f2de170..d25e32d92 100644 --- a/src/unit_tests/models/algebraic/quadratic_assignment.rs +++ b/src/unit_tests/models/algebraic/quadratic_assignment.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -40,7 +39,10 @@ fn test_quadratic_assignment_creation() { let qap = make_test_instance(); assert_eq!(qap.num_facilities(), 4); assert_eq!(qap.num_locations(), 4); - assert_eq!(qap.dimensions(), vec![4, 4, 4, 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&qap).unwrap(), + vec![4, 4, 4, 4] + ); assert_eq!(qap.cost_matrix().len(), 4); assert_eq!(qap.distance_matrix().len(), 4); } @@ -121,7 +123,10 @@ fn test_quadratic_assignment_rectangular() { let qap = QuadraticAssignment::new(cost_matrix, distance_matrix); assert_eq!(qap.num_facilities(), 2); assert_eq!(qap.num_locations(), 3); - assert_eq!(qap.dimensions(), vec![3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&qap).unwrap(), + vec![3, 3] + ); // Assignment f=(0,1): cost = C[0][1]*D[0][1] + C[1][0]*D[1][0] = 3*1 + 3*1 = 6 assert_eq!(Problem::evaluate(&qap, &vec![0, 1]).unwrap(), Min(Some(6))); // Assignment f=(0,2): cost = 3*D[0][2] + 3*D[2][0] = 3*4 + 3*4 = 24 @@ -160,3 +165,11 @@ fn test_quadratic_assignment_solver() { Min(Some(56)) ); } + +#[test] +fn json_rejects_invalid_instance() { + assert!(serde_json::from_value::( + serde_json::json!({"cost_matrix":[[1]],"distance_matrix":[[]]}) + ) + .is_err()); +} diff --git a/src/unit_tests/models/algebraic/quadratic_congruences.rs b/src/unit_tests/models/algebraic/quadratic_congruences.rs index 41a805acc..fada632db 100644 --- a/src/unit_tests/models/algebraic/quadratic_congruences.rs +++ b/src/unit_tests/models/algebraic/quadratic_congruences.rs @@ -33,8 +33,11 @@ fn test_quadratic_congruences_creation_and_accessors() { assert_eq!(p.bit_length_b(), 4); assert_eq!(p.bit_length_c(), 4); // x is encoded as 4 binary digits because c - 1 = 9 has 4 bits. - assert_eq!(p.dimensions(), vec![2, 2, 2, 2]); - assert_eq!(p.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2, 2] + ); + assert_eq!(p.num_variables().unwrap(), 4); assert_eq!( ::NAME, "QuadraticCongruences" @@ -56,7 +59,10 @@ fn test_quadratic_congruences_evaluate_yes() { fn test_quadratic_congruences_evaluate_no() { let p = no_problem(); // c - 1 = 6 has 3 bits. - assert_eq!(p.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); for x in 1..7 { // quadratic residues mod 7 are {0,1,2,4}; 3 is not one assert_eq!(p.evaluate(&config_for_x(&p, x)).unwrap(), Or(false)); @@ -74,7 +80,10 @@ fn test_quadratic_congruences_evaluate_invalid_config() { fn test_quadratic_congruences_c_le_1() { // c=1: search space {1..0} is empty let p = QuadraticCongruences::new(0, 5, 1); - assert_eq!(p.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + Vec::::new() + ); assert_eq!(p.evaluate(&BigUint::default()).unwrap(), Or(false)); assert_eq!(p.evaluate(&bu(1)).unwrap(), Or(false)); } @@ -86,7 +95,10 @@ fn test_quadratic_congruences_bigint_witness_encoding_round_trip() { let x = (BigUint::from(1u32) << 100usize) + BigUint::from(1u32); let config = p.encode_witness(&x).expect("x should be encodable"); - assert_eq!(config.len(), p.dimensions().len()); + assert_eq!( + config.len(), + crate::solvers::cartesian_dimensions(&p).unwrap().len() + ); assert_eq!(p.decode_witness(&config), Some(x)); } diff --git a/src/unit_tests/models/algebraic/quadratic_diophantine_equations.rs b/src/unit_tests/models/algebraic/quadratic_diophantine_equations.rs index 09d0c968b..e6d0b59ec 100644 --- a/src/unit_tests/models/algebraic/quadratic_diophantine_equations.rs +++ b/src/unit_tests/models/algebraic/quadratic_diophantine_equations.rs @@ -33,8 +33,11 @@ fn test_quadratic_diophantine_equations_creation_and_accessors() { assert_eq!(problem.bit_length_b(), 3); assert_eq!(problem.bit_length_c(), 6); // max_x = floor(sqrt(53 / 3)) = 4, encoded in 3 binary digits. - assert_eq!(problem.dimensions(), vec![2, 2, 2]); - assert_eq!(problem.num_variables(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2] + ); + assert_eq!(problem.num_variables().unwrap(), 3); assert_eq!( ::NAME, "QuadraticDiophantineEquations" @@ -69,7 +72,10 @@ fn test_quadratic_diophantine_equations_evaluate_yes() { #[test] fn test_quadratic_diophantine_equations_evaluate_no() { let problem = no_problem(); - assert_eq!(problem.dimensions(), vec![2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2] + ); assert_eq!( problem.evaluate(&config_for_x(&problem, 1)).unwrap(), Or(false) @@ -86,7 +92,10 @@ fn test_quadratic_diophantine_equations_evaluate_invalid_config() { #[test] fn test_quadratic_diophantine_equations_c_le_a() { let problem = QuadraticDiophantineEquations::new(10, 1, 5); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&BigUint::default()).unwrap(), Or(false)); } diff --git a/src/unit_tests/models/algebraic/qubo.rs b/src/unit_tests/models/algebraic/qubo.rs index 448fef75b..8b3968291 100644 --- a/src/unit_tests/models/algebraic/qubo.rs +++ b/src/unit_tests/models/algebraic/qubo.rs @@ -1,31 +1,31 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; -include!("../../jl_helpers.rs"); #[test] fn test_qubo_from_matrix() { let problem = QUBO::from_matrix(vec![vec![1, 2], vec![0, 3]]).unwrap(); assert_eq!(problem.num_vars(), 2); - assert_eq!(problem.get(0, 0), Some(&1)); - assert_eq!(problem.get(0, 1), Some(&2)); - assert_eq!(problem.get(1, 1), Some(&3)); + assert_eq!(problem.get(0, 0), Some(1)); + assert_eq!(problem.get(0, 1), Some(2)); + assert_eq!(problem.get(1, 1), Some(3)); } #[test] fn test_qubo_new() { let problem = QUBO::new(vec![1.0, 2.0], vec![((0, 1), 3.0)]).unwrap(); - assert_eq!(problem.get(0, 0), Some(&1.0)); - assert_eq!(problem.get(1, 1), Some(&2.0)); - assert_eq!(problem.get(0, 1), Some(&3.0)); + assert_eq!(problem.get(0, 0), Some(1.0)); + assert_eq!(problem.get(1, 1), Some(2.0)); + assert_eq!(problem.get(0, 1), Some(3.0)); } #[test] fn test_num_variables() { let problem = QUBO::::from_matrix(vec![vec![0.0; 5]; 5]).unwrap(); - assert_eq!(problem.num_variables(), 5); + assert_eq!(problem.num_variables().unwrap(), 5); } #[test] @@ -37,8 +37,8 @@ fn test_matrix_access() { ]) .unwrap(); let matrix = problem.matrix(); - assert_eq!(matrix.len(), 3); - assert_eq!(matrix[0], vec![1.0, 2.0, 3.0]); + assert_eq!(matrix.rows(), 3); + assert_eq!(matrix.outer_view(0).unwrap().data(), &[1.0, 2.0, 3.0]); } #[test] @@ -70,7 +70,7 @@ fn test_qubo_rejects_invalid_configurations() { fn test_qubo_new_reverse_indices() { // Test the case where (j, i) is provided with i < j let problem = QUBO::new(vec![1.0, 2.0], vec![((1, 0), 3.0)]).unwrap(); // j > i - assert_eq!(problem.get(0, 1), Some(&3.0)); // Should be stored at (0, 1) + assert_eq!(problem.get(0, 1), Some(3.0)); // Should be stored at (0, 1) } #[test] @@ -157,8 +157,8 @@ fn test_qubo_f64_create_spec() { }) .unwrap(); - assert_eq!(problem.get(0, 0), Some(&0.5)); - assert_eq!(problem.get(0, 1), Some(&-1.25)); + assert_eq!(problem.get(0, 0), Some(0.5)); + assert_eq!(problem.get(0, 1), Some(-1.25)); } #[test] @@ -172,10 +172,10 @@ fn test_qubo_rejects_non_square_matrix() { #[test] fn test_qubo_rejects_non_finite_coefficients() { - let error = QUBO::from_matrix(vec![vec![f64::NAN]]).unwrap_err(); + let error = QUBO::from_matrix(vec![vec![0.0, f64::NAN], vec![0.0, 0.0]]).unwrap_err(); assert!(matches!( error, - crate::registry::ConstructionError::NonFiniteFloat(_) + crate::registry::ConstructionError::NonFiniteFloat(message) if message.contains("(0, 1)") )); let error = QUBO::new(vec![f64::INFINITY], vec![]).unwrap_err(); assert!(matches!( @@ -202,3 +202,275 @@ fn test_integer_qubo_reports_objective_overflow() { Err(crate::traits::EvaluationError::IntegerOverflow(_)) )); } + +#[test] +fn sparse_storage_preserves_every_assignment_and_sum_order() { + let integer = vec![ + vec![3, -5, 0, 2], + vec![99, 0, 7, -4], + vec![0, 0, -6, 0], + vec![0, 0, 0, 1], + ]; + let floating = vec![ + vec![1e16, 1.0, -1e16, 0.0], + vec![99.0, 0.5, 0.0, -0.25], + vec![0.0, 0.0, -2.0, 0.0], + vec![0.0, 0.0, 0.0, 1.0], + ]; + let int_problem = QUBO::from_matrix(integer.clone()).unwrap(); + let float_problem = QUBO::from_matrix(floating.clone()).unwrap(); + for mask in 0..16 { + let solution: Vec = (0..4).map(|i| mask & (1 << i) != 0).collect(); + let mut int_value = 0i64; + let mut float_value = 0.0f64; + for i in 0..4 { + for j in i..4 { + if solution[i] && solution[j] { + int_value = int_value.checked_add(integer[i][j]).unwrap(); + float_value += floating[i][j]; + } + } + } + assert_eq!( + int_problem.evaluate(&solution).unwrap(), + Min(Some(int_value)) + ); + assert_eq!( + float_problem + .evaluate(&solution) + .unwrap() + .unwrap() + .to_bits(), + float_value.to_bits() + ); + } +} + +#[test] +fn sparse_qubo_keeps_unused_variables_and_last_assignment() { + let problem = QUBO::new( + vec![0i64; 10_000], + vec![((2, 7), i64::MAX), ((7, 2), 5), ((9, 9), 3), ((9, 9), 0)], + ) + .unwrap(); + assert_eq!(problem.num_vars(), 10_000); + assert_eq!(problem.matrix().nnz(), 1); + assert_eq!(problem.get(9, 9), Some(0)); + assert_eq!(problem.get(2, 7), Some(5)); + let json = serde_json::to_string(&problem).unwrap(); + assert!(json.len() < 100_000); + let restored: QUBO = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.matrix(), problem.matrix()); + let mut solution = vec![false; 10_000]; + solution[2] = true; + solution[7] = true; + assert_eq!(restored.evaluate(&solution).unwrap(), Min(Some(5))); +} + +#[test] +fn sparse_qubo_validates_shape_values_and_serialized_structure() { + assert!(QUBO::from_sparse(CsMat::::zero((2, 3))).is_err()); + let invalid = CsMat::new((1, 1), vec![0, 1], vec![0], vec![f64::INFINITY]); + assert!(QUBO::from_sparse(invalid).is_err()); + let column_matrix = CsMat::new_csc((2, 2), vec![0, 1, 2], vec![0, 0], vec![2i64, 3]); + let problem = QUBO::from_sparse(column_matrix).unwrap(); + assert!(problem.matrix().is_csr()); + assert_eq!(problem.evaluate(&vec![true, true]).unwrap(), Min(Some(5))); + assert_eq!( + serde_json::to_string(&problem).unwrap(), + r#"{"num_vars":2,"entries":[[0,0,2],[0,1,3]]}"# + ); +} + +fn assignments(n: usize) -> impl Iterator> { + (0..1usize << n).map(move |mask| (0..n).map(|i| mask & (1 << i) != 0).collect()) +} + +fn load_error(json: &str) -> String +where + W: WeightElement + serde::de::DeserializeOwned, +{ + serde_json::from_str::>(json) + .err() + .expect("QUBO JSON should be rejected") + .to_string() +} + +#[test] +fn qubo_json_writes_row_major_entries() { + // The lower-triangle 99 is stored by from_matrix and therefore persisted, though never evaluated. + let problem = QUBO::from_matrix(vec![vec![3, -5, 0], vec![99, 0, 7], vec![0, 0, -6]]).unwrap(); + assert_eq!( + serde_json::to_string(&problem).unwrap(), + r#"{"num_vars":3,"entries":[[0,0,3],[0,1,-5],[1,0,99],[1,2,7],[2,2,-6]]}"# + ); + let floating = QUBO::new(vec![0.5, 0.0, -2.0], vec![((0, 2), 1e16)]).unwrap(); + assert_eq!( + serde_json::to_string(&floating).unwrap(), + r#"{"num_vars":3,"entries":[[0,0,0.5],[0,2,1e+16],[2,2,-2.0]]}"# + ); + assert_eq!( + serde_json::to_string(&QUBO::::from_matrix(vec![]).unwrap()).unwrap(), + r#"{"num_vars":0,"entries":[]}"# + ); +} + +#[test] +fn qubo_json_round_trip_preserves_storage_and_every_evaluation() { + let integer = QUBO::from_matrix(vec![vec![3, -5, 0], vec![99, 0, 7], vec![0, 0, -6]]).unwrap(); + let restored: QUBO = + serde_json::from_str(&serde_json::to_string(&integer).unwrap()).unwrap(); + assert_eq!(restored.matrix(), integer.matrix()); + for solution in assignments(3) { + assert_eq!( + restored.evaluate(&solution).unwrap(), + integer.evaluate(&solution).unwrap() + ); + } + + let floating = QUBO::from_matrix(vec![ + vec![1e16, 1.0, -1e16, 0.0], + vec![99.0, 0.5, 0.0, -0.25], + vec![0.0, 0.0, -2.0, 0.0], + vec![0.0, 0.0, 0.0, 1.0], + ]) + .unwrap(); + let restored: QUBO = + serde_json::from_str(&serde_json::to_string(&floating).unwrap()).unwrap(); + assert_eq!(restored.matrix(), floating.matrix()); + for solution in assignments(4) { + assert_eq!( + restored.evaluate(&solution).unwrap().unwrap().to_bits(), + floating.evaluate(&solution).unwrap().unwrap().to_bits() + ); + } +} + +#[test] +fn qubo_json_entries_behave_like_from_sparse() { + // Any entry order is accepted; explicit zeros and lower-triangle entries stay stored. + let restored: QUBO = serde_json::from_str( + r#"{"num_vars":3,"entries":[[2,2,-6],[1,0,99],[0,1,-5],[1,1,0],[0,0,3]]}"#, + ) + .unwrap(); + let expected = QUBO::from_sparse(CsMat::new( + (3, 3), + vec![0, 2, 4, 5], + vec![0, 1, 0, 1, 2], + vec![3i64, -5, 99, 0, -6], + )) + .unwrap(); + assert_eq!(restored.matrix(), expected.matrix()); + assert_eq!( + serde_json::to_string(&restored).unwrap(), + r#"{"num_vars":3,"entries":[[0,0,3],[0,1,-5],[1,0,99],[1,1,0],[2,2,-6]]}"# + ); + assert_eq!( + restored.evaluate(&vec![true, true, false]).unwrap(), + Min(Some(-2)) + ); +} + +#[test] +fn qubo_json_legacy_dense_matrix_loads_like_from_matrix() { + let legacy = include_str!("../../../../tests/data/qubo_legacy_dense.json"); + let restored: QUBO = serde_json::from_str(legacy).unwrap(); + let expected = QUBO::from_matrix(vec![vec![3, -5, 0], vec![99, 0, 7], vec![0, 0, -6]]).unwrap(); + assert_eq!(restored.matrix(), expected.matrix()); + for solution in assignments(3) { + assert_eq!( + restored.evaluate(&solution).unwrap(), + expected.evaluate(&solution).unwrap() + ); + } + let floating: QUBO = + serde_json::from_str(r#"{"num_vars":3,"matrix":[[0.5,1,0],[0,0,0],[0,0,-2]]}"#).unwrap(); + assert_eq!( + serde_json::to_string(&floating).unwrap(), + r#"{"num_vars":3,"entries":[[0,0,0.5],[0,1,1.0],[2,2,-2.0]]}"# + ); +} + +#[test] +fn qubo_json_rejects_malformed_shapes() { + const SHAPE: &str = "problem construction failed: QUBO JSON must contain exactly one of \ + `entries` (sparse) or `matrix` (legacy dense)"; + assert_eq!(load_error::(r#"{"num_vars":3}"#), SHAPE); + assert_eq!( + load_error::(r#"{"num_vars":1,"entries":[],"matrix":[[0]]}"#), + SHAPE + ); + assert_eq!( + load_error::(r#"{"num_vars":3,"entries":[[0,3,1]]}"#), + "problem construction failed: QUBO entry index (0, 3) is outside 0..3" + ); + assert_eq!( + load_error::(r#"{"num_vars":3,"entries":[[3,0,1]]}"#), + "problem construction failed: QUBO entry index (3, 0) is outside 0..3" + ); + assert_eq!( + load_error::(r#"{"num_vars":3,"entries":[[1,2,4],[0,0,1],[1,2,5]]}"#), + "problem construction failed: QUBO entry (1, 2) is listed more than once" + ); + assert_eq!( + load_error::(r#"{"num_vars":3,"matrix":[[1,2,3],[0,4],[0,0,5]]}"#), + "problem construction failed: QUBO matrix row 1 has length 2, expected 3" + ); + assert_eq!( + load_error::(r#"{"num_vars":3,"matrix":[[1,2],[0,4]]}"#), + "problem construction failed: QUBO num_vars is 3, but the dense matrix has 2 rows" + ); + assert!(load_error::(r#"{"entries":[]}"#).starts_with("missing field `num_vars`")); + // The unreleased sprs CSR layout is not a supported shape. + assert!(load_error::( + r#"{"matrix":{"storage":"CSR","nrows":1,"ncols":1,"indptr":[0,1],"indices":[0],"data":[1]}}"# + ) + .starts_with("invalid type: map, expected a sequence")); + assert!(load_error::(r#"{"num_vars":1,"entries":[[0,0,0.5]]}"#) + .starts_with("invalid type: floating point `0.5`, expected i64")); +} + +#[test] +fn qubo_json_rejects_non_finite_coefficients() { + // JSON cannot spell a non-finite number, so the rejection is checked on the parsed mirror. + let sparse = QuboData { + num_vars: 3, + entries: Some(vec![(0, 0, 1.0), (1, 2, f64::INFINITY)]), + matrix: None, + }; + assert_eq!( + QUBO::try_from(sparse).unwrap_err().to_string(), + "non-finite floating-point construction value: QUBO coefficient must be finite at (1, 2)" + ); + let dense = QuboData { + num_vars: 3, + entries: None, + matrix: Some(vec![ + vec![0.0, 0.0, 0.0], + vec![0.0, 0.0, 0.0], + vec![0.0, 0.0, f64::NAN], + ]), + }; + assert_eq!( + QUBO::try_from(dense).unwrap_err().to_string(), + "non-finite floating-point construction value: QUBO coefficient must be finite at (2, 2)" + ); + assert!( + load_error::(r#"{"num_vars":1,"entries":[[0,0,1e999]]}"#) + .starts_with("number out of range") + ); +} + +#[test] +fn qubo_json_rejects_unallocatable_num_vars() { + assert_eq!( + QUBO::::from_entries(usize::MAX, vec![]) + .unwrap_err() + .to_string(), + "integer overflow during construction: counting QUBO rows" + ); + assert!(QUBO::::from_entries(usize::MAX - 1, vec![]) + .unwrap_err() + .to_string() + .starts_with("problem construction failed: allocating QUBO rows:")); +} diff --git a/src/unit_tests/models/algebraic/simultaneous_incongruences.rs b/src/unit_tests/models/algebraic/simultaneous_incongruences.rs index 0506d3829..d7a3edff6 100644 --- a/src/unit_tests/models/algebraic/simultaneous_incongruences.rs +++ b/src/unit_tests/models/algebraic/simultaneous_incongruences.rs @@ -25,9 +25,9 @@ fn test_simultaneous_incongruences_creation_and_accessors() { assert_eq!(p.num_pairs(), 4); assert_eq!(p.pairs(), &[(2, 2), (1, 3), (2, 5), (3, 7)]); // lcm(2,3,5,7) = 210 - assert_eq!(p.lcm_moduli(), 210); - assert_eq!(p.dimensions(), vec![210]); - assert_eq!(p.num_variables(), 1); + assert_eq!(p.lcm_moduli().unwrap(), 210); + assert_eq!(crate::solvers::cartesian_dimensions(&p).unwrap(), vec![210]); + assert_eq!(p.num_variables().unwrap(), 1); assert_eq!( ::NAME, "SimultaneousIncongruences" @@ -49,7 +49,7 @@ fn test_simultaneous_incongruences_evaluate_no() { let p = covering_system(); // pairs (2,2) and (1,2): together require x≡0 (mod 2) AND x≡1 (mod 2), // which is impossible. - let lcm = p.lcm_moduli(); + let lcm = p.lcm_moduli().unwrap(); assert_eq!(lcm, 2); // All x in {0,1} should fail for x in 0..lcm { @@ -72,8 +72,8 @@ fn test_simultaneous_incongruences_evaluate_invalid_config() { fn test_simultaneous_incongruences_empty_pairs() { let p = SimultaneousIncongruences::new(vec![]).unwrap(); assert_eq!(p.num_pairs(), 0); - assert_eq!(p.lcm_moduli(), 1); - assert_eq!(p.dimensions(), vec![1]); + assert_eq!(p.lcm_moduli().unwrap(), 1); + assert_eq!(crate::solvers::cartesian_dimensions(&p).unwrap(), vec![1]); // Any x (here x=0) satisfies vacuously assert_eq!(p.evaluate(&0).unwrap(), Or(true)); } @@ -131,3 +131,19 @@ fn test_simultaneous_incongruences_paper_example() { let witness = solver.solve(&p).unwrap().unwrap(); assert_eq!(p.evaluate(&witness).unwrap(), Or(true)); } + +#[test] +fn period_overflow_does_not_restrict_model_evaluation() { + let problem = SimultaneousIncongruences::new(vec![(1, i64::MAX), (1, i64::MAX - 1)]).unwrap(); + let restored: SimultaneousIncongruences = + serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); + assert_eq!(restored.evaluate(&0).unwrap(), Or(true)); + assert_eq!(restored.evaluate(&-1).unwrap(), Or(false)); + assert_eq!(restored.parameters(), problem.parameters()); + assert!(matches!( + crate::solvers::cartesian_dimensions(&restored), + Err(crate::solvers::SolveError::Evaluation( + crate::traits::EvaluationError::IntegerOverflow(_) + )) + )); +} diff --git a/src/unit_tests/models/algebraic/sparse_matrix_compression.rs b/src/unit_tests/models/algebraic/sparse_matrix_compression.rs index 7ee9784c7..bf13a8773 100644 --- a/src/unit_tests/models/algebraic/sparse_matrix_compression.rs +++ b/src/unit_tests/models/algebraic/sparse_matrix_compression.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_zero_bound() { @@ -32,7 +31,10 @@ fn test_sparse_matrix_compression_basic() { assert_eq!(problem.num_cols(), 4); assert_eq!(problem.bound_k(), 2); assert_eq!(problem.storage_len(), 6); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!( ::NAME, "SparseMatrixCompression" @@ -144,3 +146,11 @@ fn test_sparse_matrix_compression_rejects_zero_bound() { fn test_sparse_matrix_compression_rejects_ragged_matrix() { let _ = SparseMatrixCompression::new(vec![vec![true, false], vec![true]], 2); } + +#[test] +fn json_rejects_invalid_instance() { + assert!(serde_json::from_value::( + serde_json::json!({"matrix":[[true]],"bound_k":0}) + ) + .is_err()); +} diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs index 9bce00a5b..4a6dd3d23 100644 --- a/src/unit_tests/models/decision.rs +++ b/src/unit_tests/models/decision.rs @@ -1,7 +1,7 @@ use crate::models::decision::Decision; use crate::models::graph::{MaximumIndependentSet, MinimumDominatingSet, MinimumVertexCover}; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::{One, Or}; @@ -84,7 +84,10 @@ fn test_decision_max_evaluate() { #[test] fn test_decision_dims() { let decision = Decision::new(triangle_mvc(), 2); - assert_eq!(decision.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&decision).unwrap(), + vec![2, 2, 2] + ); } #[test] @@ -156,38 +159,36 @@ fn construction_contract_decision_rejects_nested_persisted_shape() { #[test] fn test_decision_reduce_to_aggregate() { - use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + use crate::rules::{ReduceTo, ReductionResult}; let decision = Decision::new(triangle_mvc(), 2); - let result = decision - .reduce_to_aggregate() + let result = ReduceTo::>::reduce_to(&decision) .expect("reduction should succeed"); let target = result.target_problem(); assert_eq!(target.num_vertices(), 3); let target_val = target.evaluate(&vec![true, true, false]).unwrap(); - let source_val = result.extract_value(target_val); + let source_val = result.map_value(target_val); assert_eq!(source_val, Or(true)); let target_val = target.evaluate(&vec![true, true, true]).unwrap(); - let source_val = result.extract_value(target_val); + let source_val = result.map_value(target_val); assert_eq!(source_val, Or(false)); } #[test] fn test_decision_reduce_to_aggregate_infeasible_bound() { - use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + use crate::rules::{ReduceTo, ReductionResult}; let decision = Decision::new(triangle_mvc(), 1); - let result = decision - .reduce_to_aggregate() + let result = ReduceTo::>::reduce_to(&decision) .expect("reduction should succeed"); let target = result.target_problem(); for mask in 0..8 { let config = vec![mask & 0b001 != 0, mask & 0b010 != 0, mask & 0b100 != 0]; let target_val = target.evaluate(&config).unwrap(); - let source_val = result.extract_value(target_val); + let source_val = result.map_value(target_val); assert_eq!( source_val, Or(false), @@ -228,11 +229,10 @@ fn test_decision_mds_evaluate_infeasible_cost() { #[test] fn test_decision_mds_reduce_to_aggregate() { - use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + use crate::rules::{ReduceTo, ReductionResult}; let decision = Decision::new(star_mds(), 1); - let result = decision - .reduce_to_aggregate() + let result = ReduceTo::>::reduce_to(&decision) .expect("reduction should succeed"); let target = result.target_problem(); assert_eq!(target.num_vertices(), 5); @@ -240,13 +240,13 @@ fn test_decision_mds_reduce_to_aggregate() { let target_val = target .evaluate(&vec![true, false, false, false, false]) .unwrap(); - let source_val = result.extract_value(target_val); + let source_val = result.map_value(target_val); assert_eq!(source_val, Or(true)); let target_val = target .evaluate(&vec![true, true, false, false, false]) .unwrap(); - let source_val = result.extract_value(target_val); + let source_val = result.map_value(target_val); assert_eq!(source_val, Or(false)); } @@ -319,30 +319,36 @@ fn test_decision_mis_unit_dynamic_identity_edges() { assert_eq!((edge.parameter_declarations_fn)().fields.len(), 2); let witness = vec![true, false, true]; let reduced = (edge.reduce_fn.unwrap())(&decision).unwrap(); + let target = reduced + .witness + .target_problem_any() + .downcast_ref::>() + .unwrap(); assert_eq!( - *reduced - .extract_solution_dyn(&witness) - .unwrap() - .downcast::>() - .unwrap(), - witness + target.evaluate(&witness).unwrap(), + crate::types::Max(Some(2)) + ); + let target_result = SolveOutcome::optimal(target, witness.clone()).unwrap(); + let recovered = reduced + .witness + .recover_result_dyn(&decision, crate::solvers::erase_outcome(target_result)) + .unwrap(); + assert_eq!( + crate::solvers::downcast_outcome::, Or>(recovered).unwrap(), + SolveOutcome::Optimal { + solution: witness, + evaluation: Or(true) + } ); assert!(matches!( (edge.reduce_fn.unwrap())(decision.inner()), Err(crate::rules::ReductionError::SourceTypeMismatch { .. }) )); - let aggregate = (edge.reduce_aggregate_fn.unwrap())(&decision).unwrap(); - assert_eq!( - *aggregate - .extract_value_from_solution_dyn(&witness) - .unwrap() - .downcast::() - .unwrap(), - Or(true) - ); assert!(matches!( - (edge.reduce_aggregate_fn.unwrap())(decision.inner()), - Err(crate::rules::ReductionError::SourceTypeMismatch { .. }) + reduced + .witness + .recover_result_dyn(decision.inner(), SolveOutcome::Infeasible), + Err(crate::rules::ExtractionError::InvalidTargetSolution(_)) )); let reverse = entries .iter() @@ -356,3 +362,97 @@ 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 unit_vertex_cover_uses_registered_construction_and_solver() { + use crate::models::decision::DecisionCreateSpec; + type Unit = MinimumVertexCover; + let spec: DecisionCreateSpec = serde_json::from_value(serde_json::json!({ + "graph": {"num_vertices": 3, "edges": [[0, 1], [1, 2]]}, "bound": 1 + })) + .unwrap(); + let problem: Decision = spec.into(); + let solution = BruteForce::new().solve(&problem).unwrap().unwrap(); + assert_eq!(solution, vec![false, true, false]); + assert_eq!(problem.evaluate(&solution), Ok(Or(true))); + let restored: Decision = + serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); + assert_eq!(restored.evaluate(&solution), Ok(Or(true))); + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.num_edges(), 2); +} + +#[test] +fn decision_executed_result_maps_witness_and_bound_together() { + use crate::rules::{ReduceTo, ReductionResult}; + use crate::types::Min; + + let witness = vec![true, true, false]; + for bound in [1, 2] { + let decision = Decision::new(triangle_mvc(), bound); + let result = + as ReduceTo>>::reduce_to(&decision) + .unwrap(); + let target = ReductionResult::target_problem(&result); + assert!(std::ptr::eq( + target, + crate::rules::ReductionResult::target_problem(&result), + )); + let value = target.evaluate(&witness).unwrap(); + assert_eq!(result.map_value(value), Or(bound == 2)); + assert_eq!(result.map_value(Min(None)), Or(false)); + if bound == 2 { + assert_eq!( + result + .recover_result( + &decision, + SolveOutcome::optimal(result.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + witness + ); + } + } +} + +#[test] +fn recovery_distinguishes_a_proved_no_from_an_insufficient_incumbent() { + use crate::rules::{ExtractionError, ReduceTo, ReductionResult}; + use SolveOutcome; + let source = Decision::new(triangle_mvc(), 2); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let candidate = vec![true, true, true]; + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(reduction.target_problem(), candidate).unwrap() + ), + Err(ExtractionError::InsufficientSolutionQuality) + )); + let optimum = vec![true, true, false]; + for outcome in [ + SolveOutcome::optimal(reduction.target_problem(), optimum.clone()).unwrap(), + SolveOutcome::feasible(reduction.target_problem(), optimum).unwrap(), + ] { + let recovered = reduction.recover_result(&source, outcome).unwrap(); + assert_eq!( + source.evaluate(recovered.solution().unwrap()).unwrap(), + Or(true) + ); + } + let no_source = Decision::new(triangle_mvc(), 1); + let no_reduction = + ReduceTo::>::reduce_to(&no_source).unwrap(); + assert_eq!( + no_reduction + .recover_result( + &no_source, + SolveOutcome::optimal(no_reduction.target_problem(), vec![true, true, false]) + .unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); +} diff --git a/src/unit_tests/models/formula/circuit.rs b/src/unit_tests/models/formula/circuit.rs index 8e692d141..8616db9b5 100644 --- a/src/unit_tests/models/formula/circuit.rs +++ b/src/unit_tests/models/formula/circuit.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -124,7 +123,10 @@ fn test_circuit_sat_creation() { )]); let problem = CircuitSAT::new(circuit); assert_eq!(problem.num_variables(), 3); // c, x, y - assert_eq!(problem.dimensions(), vec![2, 2, 2]); // binary variables + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2] + ); // binary variables } #[test] @@ -227,7 +229,10 @@ fn test_circuit_sat_problem() { let p = CircuitSAT::new(circuit); // Variables sorted: c, x, y - assert_eq!(p.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); // c=1, x=1, y=1: c = 1 AND 1 = 1 => satisfied assert!(p.evaluate(&vec![true, true, true]).unwrap()); diff --git a/src/unit_tests/models/formula/ksat.rs b/src/unit_tests/models/formula/ksat.rs index 4ec78e2a4..d2a58fbd3 100644 --- a/src/unit_tests/models/formula/ksat.rs +++ b/src/unit_tests/models/formula/ksat.rs @@ -1,9 +1,8 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::variant::{K2, K3, KN}; -include!("../../jl_helpers.rs"); #[test] fn test_3sat_creation() { @@ -129,7 +128,10 @@ fn test_ksat_problem_v2() { ], ); - assert_eq!(p.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); assert!(p.evaluate(&vec![true, false, false]).unwrap()); assert!(!p.evaluate(&vec![true, true, true]).unwrap()); assert!(!p.evaluate(&vec![false, false, false]).unwrap()); @@ -146,7 +148,10 @@ fn test_ksat_problem_v2_2sat() { vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, -2])], ); - assert_eq!(p.dimensions(), vec![2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2] + ); assert!(p.evaluate(&vec![true, false]).unwrap()); assert!(p.evaluate(&vec![false, true]).unwrap()); assert!(!p.evaluate(&vec![true, true]).unwrap()); diff --git a/src/unit_tests/models/formula/maximum_2_satisfiability.rs b/src/unit_tests/models/formula/maximum_2_satisfiability.rs index fec3102ca..a062b2932 100644 --- a/src/unit_tests/models/formula/maximum_2_satisfiability.rs +++ b/src/unit_tests/models/formula/maximum_2_satisfiability.rs @@ -1,7 +1,6 @@ use super::*; use crate::models::formula::CNFClause; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Max; @@ -25,7 +24,10 @@ fn test_maximum_2_satisfiability_creation() { let problem = issue_instance(); assert_eq!(problem.num_vars(), 4); assert_eq!(problem.num_clauses(), 7); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); } #[test] diff --git a/src/unit_tests/models/formula/nae_satisfiability.rs b/src/unit_tests/models/formula/nae_satisfiability.rs index 1e164b6ee..31f52e7b0 100644 --- a/src/unit_tests/models/formula/nae_satisfiability.rs +++ b/src/unit_tests/models/formula/nae_satisfiability.rs @@ -24,7 +24,7 @@ fn test_nae_satisfiability_creation() { assert_eq!(problem.num_vars(), 5); assert_eq!(problem.num_clauses(), 5); assert_eq!(problem.num_literals(), 15); - assert_eq!(problem.num_variables(), 5); + assert_eq!(problem.num_variables().unwrap(), 5); } #[test] diff --git a/src/unit_tests/models/formula/non_tautology.rs b/src/unit_tests/models/formula/non_tautology.rs index f3c41161a..d15c2d574 100644 --- a/src/unit_tests/models/formula/non_tautology.rs +++ b/src/unit_tests/models/formula/non_tautology.rs @@ -8,8 +8,11 @@ fn test_non_tautology_creation() { let problem = NonTautology::new(3, vec![vec![1, 2, 3], vec![-1, -2, -3]]).unwrap(); assert_eq!(problem.num_vars(), 3); assert_eq!(problem.num_disjuncts(), 2); - assert_eq!(problem.num_variables(), 3); - assert_eq!(problem.dimensions(), vec![2, 2, 2]); + assert_eq!(problem.num_variables().unwrap(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2] + ); } #[test] diff --git a/src/unit_tests/models/formula/one_in_three_satisfiability.rs b/src/unit_tests/models/formula/one_in_three_satisfiability.rs index 601ee47bc..fdcbcd8f3 100644 --- a/src/unit_tests/models/formula/one_in_three_satisfiability.rs +++ b/src/unit_tests/models/formula/one_in_three_satisfiability.rs @@ -15,8 +15,11 @@ fn test_one_in_three_satisfiability_creation() { ); assert_eq!(problem.num_vars(), 4); assert_eq!(problem.num_clauses(), 3); - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); } #[test] diff --git a/src/unit_tests/models/formula/planar_3_satisfiability.rs b/src/unit_tests/models/formula/planar_3_satisfiability.rs index 7ecbe2c52..eac779e21 100644 --- a/src/unit_tests/models/formula/planar_3_satisfiability.rs +++ b/src/unit_tests/models/formula/planar_3_satisfiability.rs @@ -16,8 +16,11 @@ fn test_planar_3_satisfiability_creation() { ); assert_eq!(problem.num_vars(), 4); assert_eq!(problem.num_clauses(), 4); - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); } #[test] diff --git a/src/unit_tests/models/formula/qbf.rs b/src/unit_tests/models/formula/qbf.rs index 32bee8687..cb4a35295 100644 --- a/src/unit_tests/models/formula/qbf.rs +++ b/src/unit_tests/models/formula/qbf.rs @@ -21,7 +21,7 @@ fn test_qbf_creation() { ); assert_eq!(problem.num_vars(), 3); assert_eq!(problem.num_clauses(), 2); - assert_eq!(problem.num_variables(), 0); + assert_eq!(problem.num_variables().unwrap(), 0); assert_eq!(problem.quantifiers().len(), 3); assert_eq!(problem.clauses().len(), 2); } @@ -47,7 +47,10 @@ fn test_qbf_evaluate_true() { ); // dims() is empty; evaluate([]) runs the game-tree search - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&()).unwrap()); assert!(problem.is_true()); } @@ -131,7 +134,10 @@ fn test_qbf_zero_vars() { let problem = QuantifiedBooleanFormulas::new(0, vec![], vec![]); assert!(problem.evaluate(&()).unwrap()); assert!(problem.is_true()); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); } #[test] @@ -204,7 +210,10 @@ fn test_qbf_serialization() { assert_eq!(deserialized.num_vars(), problem.num_vars()); assert_eq!(deserialized.num_clauses(), problem.num_clauses()); assert_eq!(deserialized.quantifiers(), problem.quantifiers()); - assert_eq!(deserialized.dimensions(), problem.dimensions()); + assert_eq!( + crate::solvers::cartesian_dimensions(&deserialized).unwrap(), + crate::solvers::cartesian_dimensions(&problem).unwrap() + ); } #[test] @@ -238,7 +247,10 @@ fn test_qbf_dims() { vec![CNFClause::new(vec![1, 2, 3, 4])], ); // dims() is always empty — QBF has no external config variables - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); } #[test] diff --git a/src/unit_tests/models/formula/sat.rs b/src/unit_tests/models/formula/sat.rs index 41621a4ca..006995b4b 100644 --- a/src/unit_tests/models/formula/sat.rs +++ b/src/unit_tests/models/formula/sat.rs @@ -1,8 +1,8 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; -include!("../../jl_helpers.rs"); #[test] fn test_cnf_clause_creation() { @@ -40,7 +40,7 @@ fn test_sat_creation() { ); assert_eq!(problem.num_vars(), 3); assert_eq!(problem.num_clauses(), 2); - assert_eq!(problem.num_variables(), 3); + assert_eq!(problem.num_variables().unwrap(), 3); } #[test] @@ -149,7 +149,7 @@ fn test_is_satisfying_assignment_defaults() { #[test] fn test_num_variables() { let problem = Satisfiability::new(5, vec![CNFClause::new(vec![1])]); - assert_eq!(problem.num_variables(), 5); + assert_eq!(problem.num_variables().unwrap(), 5); } #[test] diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs index 75ab99132..72548dd95 100644 --- a/src/unit_tests/models/graph/acyclic_partition.rs +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -81,7 +81,10 @@ fn test_acyclic_partition_creation_and_accessors() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 8); - assert_eq!(problem.dimensions(), vec![6; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6; 6] + ); assert_eq!(problem.graph().arcs().len(), 8); assert_eq!(problem.vertex_weights(), &[2, 3, 2, 1, 3, 1]); assert_eq!(problem.arc_costs(), &[1, 1, 1, 1, 1, 1, 1, 1]); @@ -211,7 +214,7 @@ fn test_acyclic_partition_serialization() { #[test] fn test_acyclic_partition_num_variables() { let problem = yes_instance(); - assert_eq!(problem.num_variables(), 6); + assert_eq!(problem.num_variables().unwrap(), 6); } #[test] diff --git a/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs b/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs index af225e573..c9b2ea52d 100644 --- a/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs +++ b/src/unit_tests/models/graph/balanced_complete_bipartite_subgraph.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_builds_bipartite_graph_and_rejects_invalid_edges() { @@ -80,7 +79,10 @@ fn test_balanced_complete_bipartite_subgraph_creation() { assert_eq!(problem.num_vertices(), 8); assert_eq!(problem.num_edges(), 10); assert_eq!(problem.k(), 2); - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); } #[test] diff --git a/src/unit_tests/models/graph/biclique_cover.rs b/src/unit_tests/models/graph/biclique_cover.rs index ffcfb1f4a..c1d9e11b6 100644 --- a/src/unit_tests/models/graph/biclique_cover.rs +++ b/src/unit_tests/models/graph/biclique_cover.rs @@ -85,7 +85,7 @@ fn test_biclique_cover_creation() { assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 3); assert_eq!(problem.k(), 2); - assert_eq!(problem.num_variables(), 8); // 4 vertices * 2 bicliques + assert_eq!(problem.num_variables().unwrap(), 8); // 4 vertices * 2 bicliques } #[test] @@ -243,7 +243,10 @@ fn test_biclique_problem() { let problem = BicliqueCover::new(graph, 1); // dims: 4 vertices * 1 biclique = 4 binary variables - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); // Valid cover: vertex 0 and vertex 2 in biclique 0 // Config: [v0_b0=1, v1_b0=0, v2_b0=1, v3_b0=0] @@ -311,7 +314,12 @@ fn test_complexity_includes_number_of_bicliques() { .find(|entry| entry.name == "BicliqueCover") .expect("BicliqueCover variant should be registered"); - assert_eq!(problem.dimensions().len(), 8); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 8 + ); assert_eq!( (entry.complexity_eval_fn)(&problem as &dyn std::any::Any), 256.0 diff --git a/src/unit_tests/models/graph/biconnectivity_augmentation.rs b/src/unit_tests/models/graph/biconnectivity_augmentation.rs index eae27d5eb..a8045edb8 100644 --- a/src/unit_tests/models/graph/biconnectivity_augmentation.rs +++ b/src/unit_tests/models/graph/biconnectivity_augmentation.rs @@ -28,8 +28,11 @@ fn test_biconnectivity_augmentation_creation() { assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 3); assert_eq!(problem.num_potential_edges(), 2); - assert_eq!(problem.dimensions(), vec![2, 2]); - assert_eq!(problem.num_variables(), 2); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2] + ); + assert_eq!(problem.num_variables().unwrap(), 2); assert!(problem.is_weighted()); assert_eq!( as Problem>::NAME, @@ -159,10 +162,20 @@ fn test_biconnectivity_augmentation_paper_example() { #[test] fn test_is_biconnected() { - assert!(is_biconnected(&SimpleGraph::cycle(4))); - assert!(is_biconnected(&SimpleGraph::complete(3))); - assert!(!is_biconnected(&SimpleGraph::path(4))); - assert!(!is_biconnected(&SimpleGraph::new(4, vec![(0, 1), (2, 3)]))); + for (graph, expected) in [ + (SimpleGraph::empty(0), true), + (SimpleGraph::empty(1), true), + (SimpleGraph::empty(2), false), + (SimpleGraph::path(2), true), + (SimpleGraph::cycle(4), true), + (SimpleGraph::complete(3), true), + (SimpleGraph::path(4), false), + (SimpleGraph::new(4, vec![(0, 1), (2, 3)]), false), + (SimpleGraph::new(2, vec![(0, 0), (0, 1), (0, 1)]), true), + ] { + let problem = BiconnectivityAugmentation::<_, i64>::new(graph, vec![], 0); + assert_eq!(problem.evaluate(&vec![]).unwrap().0, expected); + } } #[test] diff --git a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs index 9aa70a24d..1f55bf05e 100644 --- a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs +++ b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs @@ -34,8 +34,11 @@ fn test_bottleneck_traveling_salesman_creation_and_parameter_getters() { assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.graph().num_edges(), 10); assert_eq!(problem.num_edges(), 10); - assert_eq!(problem.dimensions(), vec![2; 10]); - assert_eq!(problem.num_variables(), 10); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 10] + ); + assert_eq!(problem.num_variables().unwrap(), 10); assert_eq!(problem.weights(), vec![5, 4, 4, 5, 4, 1, 2, 1, 5, 4]); assert_eq!( problem.edges(), 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..ae5a5e49d 100644 --- a/src/unit_tests/models/graph/bounded_component_spanning_forest.rs +++ b/src/unit_tests/models/graph/bounded_component_spanning_forest.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use std::alloc::{GlobalAlloc, Layout, System}; @@ -104,7 +103,10 @@ fn test_bounded_component_spanning_forest_creation() { assert_eq!(problem.max_weight(), &6); assert_eq!(problem.num_vertices(), 8); assert_eq!(problem.num_edges(), 10); - assert_eq!(problem.dimensions(), vec![3; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 8] + ); assert!(problem.is_weighted()); } 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..d720743f7 100644 --- a/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs +++ b/src/unit_tests/models/graph/bounded_diameter_spanning_tree.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -26,7 +25,10 @@ fn test_bounded_diameter_spanning_tree_creation() { assert_eq!(problem.num_edges(), 7); assert_eq!(problem.weight_bound(), &5); assert_eq!(problem.diameter_bound(), 3); - assert_eq!(problem.dimensions(), vec![2; 7]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 7] + ); assert_eq!(problem.graph().num_vertices(), 5); assert_eq!(problem.edge_list().len(), 7); assert_eq!(problem.edge_weights().len(), 7); 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..bceb8e40f 100644 --- a/src/unit_tests/models/graph/degree_constrained_spanning_tree.rs +++ b/src/unit_tests/models/graph/degree_constrained_spanning_tree.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -21,7 +20,10 @@ fn test_degree_constrained_spanning_tree_creation() { assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_edges(), 7); assert_eq!(problem.max_degree(), 2); - assert_eq!(problem.dimensions(), vec![2; 7]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 7] + ); assert_eq!(problem.graph().num_vertices(), 5); assert_eq!(problem.edge_list().len(), 7); } diff --git a/src/unit_tests/models/graph/directed_hamiltonian_path.rs b/src/unit_tests/models/graph/directed_hamiltonian_path.rs index d00fa1c5f..48fd4c763 100644 --- a/src/unit_tests/models/graph/directed_hamiltonian_path.rs +++ b/src/unit_tests/models/graph/directed_hamiltonian_path.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -12,7 +11,10 @@ fn test_directed_hamiltonian_path_creation() { assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_arcs(), 3); // Lehmer dims: [4, 3, 2, 1] - assert_eq!(problem.dimensions(), vec![4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 3, 2, 1] + ); } #[test] @@ -131,7 +133,10 @@ fn test_parameter_getters() { assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_arcs(), 4); // Lehmer dims: [5, 4, 3, 2, 1] - assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 4, 3, 2, 1] + ); } #[test] diff --git a/src/unit_tests/models/graph/directed_two_commodity_integral_flow.rs b/src/unit_tests/models/graph/directed_two_commodity_integral_flow.rs index 444a2e69f..c9850c139 100644 --- a/src/unit_tests/models/graph/directed_two_commodity_integral_flow.rs +++ b/src/unit_tests/models/graph/directed_two_commodity_integral_flow.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -36,8 +35,16 @@ fn test_directed_two_commodity_integral_flow_creation() { let problem = yes_instance(); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 8); - assert_eq!(problem.dimensions().len(), 16); // 2 * 8 - assert!(problem.dimensions().iter().all(|&d| d == 2)); // capacity 1 -> domain {0,1} + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 16 + ); // 2 * 8 + assert!(crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .iter() + .all(|&d| d == 2)); // capacity 1 -> domain {0,1} assert_eq!(problem.source_1(), 0); assert_eq!(problem.sink_1(), 4); assert_eq!(problem.source_2(), 1); @@ -207,7 +214,10 @@ fn test_directed_two_commodity_integral_flow_higher_capacity() { 1, 1, ); - assert_eq!(problem.dimensions(), vec![3, 3, 3, 3]); // each variable in {0,1,2} + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3, 3, 3] + ); // each variable in {0,1,2} // Both commodities can share: f1=1, f2=1 on both arcs let config = vec![1, 1, 1, 1]; diff --git a/src/unit_tests/models/graph/disjoint_connecting_paths.rs b/src/unit_tests/models/graph/disjoint_connecting_paths.rs index c1e93c582..bad4d1a8c 100644 --- a/src/unit_tests/models/graph/disjoint_connecting_paths.rs +++ b/src/unit_tests/models/graph/disjoint_connecting_paths.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_reused_terminal() { assert!( @@ -43,7 +42,10 @@ fn test_disjoint_connecting_paths_creation() { assert_eq!(problem.num_edges(), 7); assert_eq!(problem.num_pairs(), 2); assert_eq!(problem.terminal_pairs(), &[(0, 3), (2, 5)]); - assert_eq!(problem.dimensions(), vec![2; 7]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 7] + ); assert_eq!( problem.ordered_edges(), vec![(0, 1), (0, 2), (1, 3), (1, 4), (2, 4), (3, 5), (4, 5)] diff --git a/src/unit_tests/models/graph/eulerian_path.rs b/src/unit_tests/models/graph/eulerian_path.rs index 2b6724a77..ef7930041 100644 --- a/src/unit_tests/models/graph/eulerian_path.rs +++ b/src/unit_tests/models/graph/eulerian_path.rs @@ -21,8 +21,11 @@ fn test_eulerian_path_creation() { assert_eq!(problem.num_vertices(), 3); assert_eq!(problem.num_arcs(), 4); // m = 4 position variables, each with domain {0..3}. - assert_eq!(problem.dimensions(), vec![4, 4, 4, 4]); - assert_eq!(problem.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 4, 4, 4] + ); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] @@ -103,8 +106,11 @@ fn test_eulerian_path_empty_arcs_instance() { // m = 0 (only isolated vertices): dims = [] and the empty witness is valid. let graph = DirectedGraph::new(3, vec![]); let problem = EulerianPath::new(graph); - assert_eq!(problem.dimensions(), Vec::::new()); - assert_eq!(problem.num_variables(), 0); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); + assert_eq!(problem.num_variables().unwrap(), 0); assert_eq!(problem.evaluate(&vec![]).unwrap(), Or(true)); let solver = BruteForce::new(); diff --git a/src/unit_tests/models/graph/generalized_hex.rs b/src/unit_tests/models/graph/generalized_hex.rs index dad6efcb1..fb558b646 100644 --- a/src/unit_tests/models/graph/generalized_hex.rs +++ b/src/unit_tests/models/graph/generalized_hex.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -57,7 +56,10 @@ fn test_generalized_hex_creation_and_getters() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 7); assert_eq!(problem.num_playable_vertices(), 4); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.graph().num_vertices(), 6); } diff --git a/src/unit_tests/models/graph/graph_partitioning.rs b/src/unit_tests/models/graph/graph_partitioning.rs index e29077c1a..a8d572c74 100644 --- a/src/unit_tests/models/graph/graph_partitioning.rs +++ b/src/unit_tests/models/graph/graph_partitioning.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -29,7 +28,10 @@ fn test_graphpartitioning_basic() { let problem = issue_example(); // Check dims: 6 binary variables - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2, 2, 2] + ); // Evaluate a valid balanced partition: A={0,1,2}, B={3,4,5} // config: [0, 0, 0, 1, 1, 1] diff --git a/src/unit_tests/models/graph/hamiltonian_circuit.rs b/src/unit_tests/models/graph/hamiltonian_circuit.rs index fc2d684cb..2ec72a576 100644 --- a/src/unit_tests/models/graph/hamiltonian_circuit.rs +++ b/src/unit_tests/models/graph/hamiltonian_circuit.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -26,7 +25,10 @@ fn test_hamiltonian_circuit_basic() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 9); - assert_eq!(problem.dimensions(), vec![6; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6; 6] + ); // Valid Hamiltonian circuit: 0->1->2->5->4->3->0 // Edges used: (0,1), (1,2), (2,5), (5,4), (4,3), (3,0) -- all present @@ -129,7 +131,10 @@ fn test_hamiltonian_circuit_serialization() { let json = serde_json::to_string(&problem).unwrap(); let restored: HamiltonianCircuit = serde_json::from_str(&json).unwrap(); - assert_eq!(problem.dimensions(), restored.dimensions()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + crate::solvers::cartesian_dimensions(&restored).unwrap() + ); // Valid circuit gives the same result on both instances assert_eq!( diff --git a/src/unit_tests/models/graph/hamiltonian_path.rs b/src/unit_tests/models/graph/hamiltonian_path.rs index c86819a08..4e55d9cfa 100644 --- a/src/unit_tests/models/graph/hamiltonian_path.rs +++ b/src/unit_tests/models/graph/hamiltonian_path.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; #[test] @@ -11,7 +10,10 @@ fn test_hamiltonian_path_basic() { let problem = HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)])); assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 3); - assert_eq!(problem.dimensions(), vec![4, 4, 4, 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 4, 4, 4] + ); // Valid path: 0->1->2->3 assert!(problem.evaluate(&vec![0, 1, 2, 3]).unwrap()); 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..87450667c 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,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; #[test] @@ -17,7 +16,10 @@ fn test_hamiltonian_path_between_two_vertices_basic() { assert_eq!(problem.num_edges(), 3); assert_eq!(problem.source_vertex(), 0); assert_eq!(problem.target_vertex(), 3); - assert_eq!(problem.dimensions(), vec![4, 4, 4, 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 4, 4, 4] + ); // Valid path: 0->1->2->3 assert!(problem.evaluate(&vec![0, 1, 2, 3]).unwrap()); diff --git a/src/unit_tests/models/graph/highly_connected_deletion.rs b/src/unit_tests/models/graph/highly_connected_deletion.rs index f0bffa6cb..290089933 100644 --- a/src/unit_tests/models/graph/highly_connected_deletion.rs +++ b/src/unit_tests/models/graph/highly_connected_deletion.rs @@ -28,8 +28,11 @@ fn test_highly_connected_deletion_creation() { assert_eq!(problem.graph().num_edges(), 4); assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 4); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); - assert_eq!(problem.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] diff --git a/src/unit_tests/models/graph/integral_flow_bundles.rs b/src/unit_tests/models/graph/integral_flow_bundles.rs index d8c7b3f79..af4d99c6a 100644 --- a/src/unit_tests/models/graph/integral_flow_bundles.rs +++ b/src/unit_tests/models/graph/integral_flow_bundles.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_requires_bundle_coverage() { assert!( @@ -61,7 +60,10 @@ fn test_integral_flow_bundles_creation_and_getters() { #[test] fn test_integral_flow_bundles_dims_use_tight_arc_bounds() { let problem = yes_instance(); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2, 2, 2] + ); } #[test] @@ -114,3 +116,51 @@ fn test_integral_flow_bundles_problem_name() { "IntegralFlowBundles" ); } + +#[test] +fn creation_and_deserialization_enforce_the_same_flow_constraints() { + let input = serde_json::json!({ + "arcs": [[0, 1], [1, 2]], "num_vertices": 3, + "source": 0, "sink": 2, "requirement": 1, + "bundles": [[0], [1]], "bundle_capacities": [1, 1], + }); + let problem = IntegralFlowBundles::try_from( + serde_json::from_value::(input.clone()).unwrap(), + ) + .unwrap(); + let persisted = serde_json::to_value(&problem).unwrap(); + let restored: IntegralFlowBundles = serde_json::from_value(persisted.clone()).unwrap(); + assert_eq!( + restored.evaluate(&vec![1, 1]).unwrap(), + crate::types::Or(true) + ); + + for (field, value, message) in [ + ("source", serde_json::json!(3), "source"), + ("sink", serde_json::json!(3), "sink"), + ("sink", serde_json::json!(0), "distinct"), + ("bundle_capacities", serde_json::json!([1]), "length"), + ("requirement", serde_json::json!(0), "positive"), + ("requirement", serde_json::json!(-1), "positive"), + ("bundle_capacities", serde_json::json!([0, 1]), "positive"), + ("bundle_capacities", serde_json::json!([-1, 1]), "positive"), + ("bundles", serde_json::json!([[2], [1]]), "out of range"), + ("bundles", serde_json::json!([[0, 0], [1]]), "duplicate"), + ( + "bundles", + serde_json::json!([[0], []]), + "at least one bundle", + ), + ] { + let mut invalid_input = input.clone(); + invalid_input[field] = value.clone(); + let spec = serde_json::from_value::(invalid_input).unwrap(); + let error = IntegralFlowBundles::try_from(spec).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + + let mut invalid_persisted = persisted.clone(); + invalid_persisted[field] = value; + let error = serde_json::from_value::(invalid_persisted).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} diff --git a/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs b/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs index e15116232..53dab6324 100644 --- a/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs +++ b/src/unit_tests/models/graph/integral_flow_homologous_arcs.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_capacities() { let problem = IntegralFlowHomologousArcs::try_from(IntegralFlowHomologousArcsCreateSpec { @@ -50,7 +49,10 @@ fn test_integral_flow_homologous_arcs_creation() { assert_eq!(problem.requirement(), 2); assert_eq!(problem.max_capacity(), 1); assert_eq!(problem.homologous_pairs(), &[(2, 5), (4, 3)]); - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); } #[test] @@ -141,7 +143,10 @@ fn test_integral_flow_homologous_arcs_non_unit_capacity() { // equal flow. R=2 is satisfiable: f=[2,2]. let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); let problem = IntegralFlowHomologousArcs::new(graph, vec![3, 3], 0, 2, 2, vec![(0, 1)]); - assert_eq!(problem.dimensions(), vec![4, 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 4] + ); assert_eq!(problem.max_capacity(), 3); assert!(problem.evaluate(&vec![2, 2]).unwrap()); assert!(problem.evaluate(&vec![3, 3]).unwrap()); @@ -165,3 +170,52 @@ fn test_integral_flow_homologous_arcs_paper_example() { .iter() .all(|solution| problem.evaluate(solution).unwrap().0)); } + +#[test] +fn creation_and_deserialization_enforce_the_same_flow_constraints() { + let input = serde_json::json!({ + "arcs": [[0, 1], [1, 2]], "num_vertices": 3, + "source": 0, "sink": 2, "requirement": 1, + "capacities": [1, 1], "homologous_pairs": [[0, 1]], + }); + let problem = IntegralFlowHomologousArcs::try_from( + serde_json::from_value::(input.clone()).unwrap(), + ) + .unwrap(); + let persisted = serde_json::to_value(&problem).unwrap(); + let restored: IntegralFlowHomologousArcs = serde_json::from_value(persisted.clone()).unwrap(); + assert_eq!( + restored.evaluate(&vec![1, 1]).unwrap(), + crate::types::Or(true) + ); + + for (field, value, message) in [ + ("capacities", serde_json::json!([1]), "length"), + ("source", serde_json::json!(3), "source"), + ("sink", serde_json::json!(3), "sink"), + ( + "homologous_pairs", + serde_json::json!([[2, 1]]), + "out of range", + ), + ( + "homologous_pairs", + serde_json::json!([[0, 2]]), + "out of range", + ), + ("capacities", serde_json::json!([-1, 1]), "nonnegative"), + ] { + let mut invalid_input = input.clone(); + invalid_input[field] = value.clone(); + let spec = + serde_json::from_value::(invalid_input).unwrap(); + let error = IntegralFlowHomologousArcs::try_from(spec).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + + let mut invalid_persisted = persisted.clone(); + invalid_persisted[field] = value; + let error = + serde_json::from_value::(invalid_persisted).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} diff --git a/src/unit_tests/models/graph/integral_flow_with_multipliers.rs b/src/unit_tests/models/graph/integral_flow_with_multipliers.rs index 394d59128..e43b34bba 100644 --- a/src/unit_tests/models/graph/integral_flow_with_multipliers.rs +++ b/src/unit_tests/models/graph/integral_flow_with_multipliers.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_zero_internal_multiplier() { assert!( @@ -69,7 +68,7 @@ fn test_integral_flow_with_multipliers_creation_accessors_and_dimensions() { assert_eq!(problem.multipliers(), &[1, 2, 3, 4, 5, 6, 4, 1]); assert_eq!(problem.capacities(), &[1, 1, 1, 1, 1, 1, 2, 3, 4, 5, 6, 4]); assert_eq!( - problem.dimensions(), + crate::solvers::cartesian_dimensions(&problem).unwrap(), vec![2, 2, 2, 2, 2, 2, 3, 4, 5, 6, 7, 5] ); } @@ -175,3 +174,46 @@ fn test_integral_flow_with_multipliers_paper_example() { let all_solutions = solver.find_all_witnesses(&problem).unwrap(); assert!(all_solutions.iter().any(|solution| solution == &config)); } + +#[test] +fn creation_and_deserialization_enforce_the_same_flow_constraints() { + let input = serde_json::json!({ + "arcs": [[0, 1], [1, 2]], "num_vertices": 3, + "source": 0, "sink": 2, "requirement": 1, + "multipliers": [1, 1, 1], "capacities": [1, 1], + }); + let problem = IntegralFlowWithMultipliers::try_from( + serde_json::from_value::(input.clone()).unwrap(), + ) + .unwrap(); + let persisted = serde_json::to_value(&problem).unwrap(); + let restored: IntegralFlowWithMultipliers = serde_json::from_value(persisted.clone()).unwrap(); + assert_eq!( + restored.evaluate(&vec![1, 1]).unwrap(), + crate::types::Or(true) + ); + + for (field, value, message) in [ + ("capacities", serde_json::json!([1]), "length"), + ("multipliers", serde_json::json!([1, 1]), "length"), + ("source", serde_json::json!(3), "source"), + ("sink", serde_json::json!(3), "sink"), + ("sink", serde_json::json!(0), "distinct"), + ("multipliers", serde_json::json!([1, 0, 1]), "positive"), + ("multipliers", serde_json::json!([1, -1, 1]), "positive"), + ("capacities", serde_json::json!([-1, 1]), "nonnegative"), + ] { + let mut invalid_input = input.clone(); + invalid_input[field] = value.clone(); + let spec = + serde_json::from_value::(invalid_input).unwrap(); + let error = IntegralFlowWithMultipliers::try_from(spec).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + + let mut invalid_persisted = persisted.clone(); + invalid_persisted[field] = value; + let error = + serde_json::from_value::(invalid_persisted).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} diff --git a/src/unit_tests/models/graph/isomorphic_spanning_tree.rs b/src/unit_tests/models/graph/isomorphic_spanning_tree.rs index 3f7f244c5..91d5064ee 100644 --- a/src/unit_tests/models/graph/isomorphic_spanning_tree.rs +++ b/src/unit_tests/models/graph/isomorphic_spanning_tree.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; @@ -12,7 +11,10 @@ fn test_isomorphicspanningtree_basic() { let problem: IsomorphicSpanningTree = IsomorphicSpanningTree::new(graph.clone(), tree.clone()); - assert_eq!(problem.dimensions(), vec![3, 3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3, 3] + ); assert_eq!(problem.graph(), &graph); assert_eq!(problem.tree(), &tree); assert_eq!(problem.num_vertices(), 3); diff --git a/src/unit_tests/models/graph/kclique.rs b/src/unit_tests/models/graph/kclique.rs index 910275599..488ddfebe 100644 --- a/src/unit_tests/models/graph/kclique.rs +++ b/src/unit_tests/models/graph/kclique.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_k_above_vertex_count() { assert!(KClique::try_from(KCliqueCreateSpec { @@ -30,7 +29,10 @@ fn test_kclique_creation() { assert_eq!(problem.k(), 3); assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_edges(), 6); - assert_eq!(problem.dimensions(), vec![2; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); } #[test] diff --git a/src/unit_tests/models/graph/kcoloring.rs b/src/unit_tests/models/graph/kcoloring.rs index 588c5721e..912b6bf3a 100644 --- a/src/unit_tests/models/graph/kcoloring.rs +++ b/src/unit_tests/models/graph/kcoloring.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_specs_separate_runtime_and_fixed_color_counts() { @@ -32,10 +31,10 @@ fn fixed_and_runtime_variants_report_num_colors_parameter() { as Problem>::parameter_names() ); } +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::topology::SimpleGraph; -use crate::variant::{K1, K2, K3, K4}; -include!("../../jl_helpers.rs"); +use crate::variant::{K2, K3, KN}; #[test] fn test_kcoloring_creation() { @@ -43,7 +42,10 @@ fn test_kcoloring_creation() { assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); assert_eq!(problem.num_colors(), 3); - assert_eq!(problem.dimensions(), vec![3, 3, 3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3, 3, 3] + ); } #[test] @@ -134,7 +136,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 +152,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(); @@ -176,7 +178,10 @@ fn test_kcoloring_problem() { // Triangle graph with 3 colors let p = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); - assert_eq!(p.dimensions(), vec![3, 3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![3, 3, 3] + ); // Valid: each vertex different color assert!(p.evaluate(&vec![0, 1, 2]).unwrap()); // Invalid: vertices 0 and 1 same color diff --git a/src/unit_tests/models/graph/kernel.rs b/src/unit_tests/models/graph/kernel.rs index 3e4ed36f7..4efaeaa50 100644 --- a/src/unit_tests/models/graph/kernel.rs +++ b/src/unit_tests/models/graph/kernel.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -13,7 +12,10 @@ fn test_kernel_creation() { let problem = Kernel::new(graph); assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_arcs(), 7); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2, 2] + ); } #[test] 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 bb266893d..ed5380b86 100644 --- a/src/unit_tests/models/graph/kth_best_spanning_tree.rs +++ b/src/unit_tests/models/graph/kth_best_spanning_tree.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -37,7 +36,10 @@ fn yes_witness_config() -> Vec> { fn test_kthbestspanningtree_creation() { let problem = yes_instance(); - assert_eq!(problem.dimensions(), vec![2; 12]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 12] + ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 6); assert_eq!(problem.num_vertices(), 4); 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..b042d2fb7 100644 --- a/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs +++ b/src/unit_tests/models/graph/length_bounded_disjoint_paths.rs @@ -35,7 +35,10 @@ fn test_length_bounded_disjoint_paths_creation() { assert_eq!(problem.max_paths(), 3); assert_eq!(problem.max_length(), 3); // 3 slots * 6 edges = 18 binary variables - assert_eq!(problem.dimensions(), vec![2; 18]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 18] + ); } #[test] @@ -186,6 +189,17 @@ fn test_length_bounded_disjoint_paths_serialization() { assert_eq!(round_trip.max_length(), 3); } +#[test] +fn test_length_bounded_disjoint_paths_rejects_inconsistent_max_paths() { + let mut json = serde_json::to_value(sample_problem()).unwrap(); + json["max_paths"] = serde_json::json!(2); + let error = + serde_json::from_value::>(json).unwrap_err(); + assert!(error + .to_string() + .contains("max_paths must equal min(deg(source), deg(sink)): expected 3, got 2")); +} + #[test] fn test_length_bounded_disjoint_paths_graph_getter() { let problem = sample_problem(); @@ -196,7 +210,7 @@ fn test_length_bounded_disjoint_paths_graph_getter() { #[test] fn test_length_bounded_disjoint_paths_num_variables() { let problem = sample_problem(); - assert_eq!(problem.num_variables(), 18); + assert_eq!(problem.num_variables().unwrap(), 18); } #[test] diff --git a/src/unit_tests/models/graph/longest_circuit.rs b/src/unit_tests/models/graph/longest_circuit.rs index 1789804b2..5f8d3119b 100644 --- a/src/unit_tests/models/graph/longest_circuit.rs +++ b/src/unit_tests/models/graph/longest_circuit.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Max; @@ -32,7 +31,10 @@ fn test_longest_circuit_creation() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 10); assert_eq!(problem.edge_lengths(), &[3, 2, 4, 1, 5, 2, 3, 2, 1, 2]); - assert_eq!(problem.dimensions(), vec![2; 10]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 10] + ); assert!(problem.is_weighted()); } diff --git a/src/unit_tests/models/graph/longest_path.rs b/src/unit_tests/models/graph/longest_path.rs index f3d81c1c8..dbbc4f69a 100644 --- a/src/unit_tests/models/graph/longest_path.rs +++ b/src/unit_tests/models/graph/longest_path.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_nonpositive_lengths() { assert!(LongestPath::try_from(LongestPathI64CreateSpec { @@ -61,7 +60,10 @@ fn test_longest_path_creation() { assert_eq!(problem.num_edges(), 10); assert_eq!(problem.source_vertex(), 0); assert_eq!(problem.target_vertex(), 6); - assert_eq!(problem.dimensions(), vec![2; 10]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 10] + ); assert_eq!(problem.edge_lengths(), &[3, 2, 4, 1, 5, 2, 3, 2, 4, 1]); assert!(problem.is_weighted()); diff --git a/src/unit_tests/models/graph/max_cut.rs b/src/unit_tests/models/graph/max_cut.rs index cb7497e77..9a0f98bd1 100644 --- a/src/unit_tests/models/graph/max_cut.rs +++ b/src/unit_tests/models/graph/max_cut.rs @@ -1,8 +1,7 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; -include!("../../jl_helpers.rs"); #[test] fn test_maxcut_creation() { @@ -12,7 +11,10 @@ fn test_maxcut_creation() { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); } #[test] @@ -176,3 +178,11 @@ fn create_specs_use_edge_weights_for_both_weight_variants() { assert_eq!(unit.edge_weights(), vec![One]); assert_eq!(MaxCutI64CreateSpec::FIELDS[2].name, "edge_weights"); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(2, vec![(0, 1)]).unwrap(); + assert!(MaxCut::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "edge_weights": []}); + assert!(serde_json::from_value::>(json).is_err()); +} diff --git a/src/unit_tests/models/graph/maximal_is.rs b/src/unit_tests/models/graph/maximal_is.rs index f68ef7c18..52c9e94e7 100644 --- a/src/unit_tests/models/graph/maximal_is.rs +++ b/src/unit_tests/models/graph/maximal_is.rs @@ -10,9 +10,9 @@ fn create_spec_rejects_weight_count_mismatch() { }); assert!(result.is_err()); } +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::topology::SimpleGraph; -include!("../../jl_helpers.rs"); #[test] fn test_maximal_is_creation() { @@ -22,8 +22,11 @@ fn test_maximal_is_creation() { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); } #[test] @@ -212,3 +215,16 @@ fn test_maximal_is_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap().unwrap(), 3); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(1, vec![]).unwrap(); + assert!(MaximalIS::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "weights": []}); + assert!(serde_json::from_value::>(json.clone()).is_err()); + let variant = std::collections::BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]); + assert!(crate::registry::load_dyn("MaximalIS", &variant, json).is_err()); +} diff --git a/src/unit_tests/models/graph/maximum_achromatic_number.rs b/src/unit_tests/models/graph/maximum_achromatic_number.rs index 9bafd0ea9..8a19aaee5 100644 --- a/src/unit_tests/models/graph/maximum_achromatic_number.rs +++ b/src/unit_tests/models/graph/maximum_achromatic_number.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Max; @@ -12,7 +11,10 @@ fn test_maximum_achromatic_number_c6() { let problem = MaximumAchromaticNumber::new(graph); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 6); - assert_eq!(problem.dimensions(), vec![6; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6; 6] + ); // [0,1,2,0,1,2] is a valid complete proper 3-coloring let config = vec![0, 1, 2, 0, 1, 2]; diff --git a/src/unit_tests/models/graph/maximum_clique.rs b/src/unit_tests/models/graph/maximum_clique.rs index f83c6107d..dfe9d6800 100644 --- a/src/unit_tests/models/graph/maximum_clique.rs +++ b/src/unit_tests/models/graph/maximum_clique.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_weight_count_mismatch() { @@ -22,7 +21,10 @@ fn test_clique_creation() { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); } #[test] @@ -291,7 +293,10 @@ fn test_clique_problem() { SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), vec![1i64; 3], ); - assert_eq!(p.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); // Valid clique: select all 3 vertices (triangle is a clique) assert_eq!(p.evaluate(&vec![true, true, true]).unwrap(), Max(Some(3))); // Valid clique: select just vertex 0 @@ -364,3 +369,16 @@ fn test_clique_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap().unwrap(), 3); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(1, vec![]).unwrap(); + assert!(MaximumClique::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "weights": []}); + assert!(serde_json::from_value::>(json.clone()).is_err()); + let variant = std::collections::BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]); + assert!(crate::registry::load_dyn("MaximumClique", &variant, json).is_err()); +} 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..78d23a56e 100644 --- a/src/unit_tests/models/graph/maximum_co_k_plex.rs +++ b/src/unit_tests/models/graph/maximum_co_k_plex.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::{Max, One}; @@ -34,7 +33,10 @@ fn test_maximum_co_k_plex_creation() { assert_eq!(problem.graph().num_edges(), 5); assert_eq!(problem.weights(), &[5, 1, 4, 1, 3]); assert_eq!(problem.bound_k(), 2); - assert_eq!(problem.dimensions(), vec![2; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_edges(), 5); assert!(problem.is_weighted()); @@ -187,3 +189,14 @@ fn test_maximum_co_k_plex_rejects_missing_bound_k_on_load() { "error should mention the missing field `bound_k`, got: {msg}" ); } + +#[test] +fn deserialize_checks_fixed_k_and_weight_count() { + for (weights, bound_k) in [(vec![1, 1], 2), (vec![1], 1)] { + assert!(serde_json::from_value::>( + serde_json::json!({ + "graph": {"num_vertices": 2, "edges": []}, "weights": weights, "bound_k": bound_k + }) + ).is_err()); + } +} diff --git a/src/unit_tests/models/graph/maximum_common_edge_subgraph.rs b/src/unit_tests/models/graph/maximum_common_edge_subgraph.rs index 00118979c..4d451cfcb 100644 --- a/src/unit_tests/models/graph/maximum_common_edge_subgraph.rs +++ b/src/unit_tests/models/graph/maximum_common_edge_subgraph.rs @@ -41,8 +41,11 @@ fn test_maximum_common_edge_subgraph_creation() { assert_eq!(problem.num_arcs_2(), 6); assert_eq!(problem.bottom_index(), 4); // dims must be [|V2| + 1; |V1|] = [5; 5]. - assert_eq!(problem.dimensions(), vec![5; 5]); - assert_eq!(problem.num_variables(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 5] + ); + assert_eq!(problem.num_variables().unwrap(), 5); } #[test] @@ -193,3 +196,20 @@ fn test_labelled_digraph_deduplicates_arcs() { ); assert_eq!(g.num_arcs(), 2); } + +#[test] +fn deserialize_checks_and_normalizes_labelled_arcs() { + assert!( + serde_json::from_value::(serde_json::json!({ + "num_vertices": 2, "arcs": [{"src": 0, "label": 1, "dst": 2}] + })) + .is_err() + ); + let graph: LabelledDigraph = serde_json::from_value(serde_json::json!({ + "num_vertices": 2, "arcs": [ + {"src": 0, "label": 1, "dst": 1}, {"src": 0, "label": 1, "dst": 1} + ] + })) + .unwrap(); + assert_eq!(graph.arcs(), &[LabelledArc::new(0, 1, 1)]); +} diff --git a/src/unit_tests/models/graph/maximum_contact_map_overlap.rs b/src/unit_tests/models/graph/maximum_contact_map_overlap.rs index 03cb0e25d..d255687c4 100644 --- a/src/unit_tests/models/graph/maximum_contact_map_overlap.rs +++ b/src/unit_tests/models/graph/maximum_contact_map_overlap.rs @@ -20,8 +20,11 @@ fn test_maximum_contact_map_overlap_creation() { assert_eq!(problem.num_contacts_1(), 2); assert_eq!(problem.num_contacts_2(), 3); // dims must be [|V_2| + 1; |V_1|] = [6; 4]. - assert_eq!(problem.dimensions(), vec![6; 4]); - assert_eq!(problem.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6; 4] + ); + assert_eq!(problem.num_variables().unwrap(), 4); // Contacts get normalized so the smaller endpoint comes first. let contacts_2 = problem.contacts_2(); assert!(contacts_2.contains(&(0, 2))); @@ -195,3 +198,67 @@ fn test_maximum_contact_map_overlap_panics_on_duplicate_contact() { fn test_maximum_contact_map_overlap_panics_on_endpoint_out_of_range() { let _ = MaximumContactMapOverlap::new(3, vec![(0, 3)], 2, vec![]); } + +#[test] +fn test_maximum_contact_map_overlap_try_new_and_deserialization_reject_invalid_contacts() { + let valid = serde_json::json!({ + "num_vertices_1": 4, + "contacts_1": [[0, 1], [1, 2], [2, 3]], + "num_vertices_2": 3, + "contacts_2": [[0, 1], [0, 2]], + }); + let restored: MaximumContactMapOverlap = serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), valid); + + let cases = [ + ( + "contacts_1", + vec![(0, 1), (2, 2)], + "G_1 contact has self-loop: (2, 2)", + ), + ( + "contacts_2", + vec![(0, 1), (1, 0)], + "G_2 has duplicate contact after normalization: (0, 1)", + ), + ( + "contacts_1", + vec![(0, 4)], + "G_1 contact endpoint out of range for num_vertices = 4: (0, 4)", + ), + ( + "contacts_2", + vec![(3, 0)], + "G_2 contact endpoint out of range for num_vertices = 3: (3, 0)", + ), + ]; + for (field, contacts, expected) in cases { + let expected = format!("problem construction failed: {expected}"); + let mut json = valid.clone(); + json[field] = serde_json::json!(contacts); + let error = serde_json::from_value::(json).unwrap_err(); + assert_eq!(error.to_string(), expected); + + let (contacts_1, contacts_2) = match field { + "contacts_1" => (contacts, vec![(0, 1), (0, 2)]), + _ => (vec![(0, 1), (1, 2), (2, 3)], contacts), + }; + let error = MaximumContactMapOverlap::try_new(4, contacts_1, 3, contacts_2).unwrap_err(); + assert_eq!(error.to_string(), expected); + } +} + +#[test] +fn test_maximum_contact_map_overlap_deserialization_canonicalizes_contacts() { + let restored: MaximumContactMapOverlap = serde_json::from_value(serde_json::json!({ + "num_vertices_1": 3, + "contacts_1": [[2, 0]], + "num_vertices_2": 3, + "contacts_2": [[1, 0]], + })) + .unwrap(); + assert_eq!( + restored, + MaximumContactMapOverlap::new(3, vec![(0, 2)], 3, vec![(0, 1)]) + ); +} diff --git a/src/unit_tests/models/graph/maximum_domatic_number.rs b/src/unit_tests/models/graph/maximum_domatic_number.rs index f922d8cea..46d09bb50 100644 --- a/src/unit_tests/models/graph/maximum_domatic_number.rs +++ b/src/unit_tests/models/graph/maximum_domatic_number.rs @@ -11,8 +11,11 @@ fn test_maximum_domatic_number_creation() { let problem = MaximumDomaticNumber::new(graph); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![4; 4]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 4] + ); } #[test] 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..dd84986cb 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 @@ -35,8 +35,11 @@ fn test_maximum_edge_weighted_k_clique_creation() { assert_eq!(problem.num_edges(), 5); assert_eq!(problem.k(), 3); assert_eq!(problem.edge_weights(), &[5, 4, -1, 1, 0]); - assert_eq!(problem.dimensions(), vec![2; 4]); - assert_eq!(problem.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); + assert_eq!(problem.num_variables().unwrap(), 4); assert!(problem.graph().has_edge(0, 1)); assert!(!problem.graph().has_edge(2, 3)); } diff --git a/src/unit_tests/models/graph/maximum_independent_set.rs b/src/unit_tests/models/graph/maximum_independent_set.rs index e5fb725ca..f9e7ebbb8 100644 --- a/src/unit_tests/models/graph/maximum_independent_set.rs +++ b/src/unit_tests/models/graph/maximum_independent_set.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_simple_weights() { let problem = MaximumIndependentSet::try_from(MaximumIndependentSetSimpleI64CreateSpec { @@ -10,10 +9,10 @@ fn create_spec_defaults_simple_weights() { .unwrap(); assert_eq!(problem.weights(), &[1, 1, 1]); } +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; -include!("../../jl_helpers.rs"); #[test] fn test_independent_set_creation() { @@ -23,7 +22,12 @@ fn test_independent_set_creation() { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.dimensions().len(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 4 + ); } #[test] @@ -249,3 +253,18 @@ fn test_mis_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap().unwrap(), 4); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(1, vec![]).unwrap(); + assert!(MaximumIndependentSet::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "weights": []}); + assert!( + serde_json::from_value::>(json.clone()).is_err() + ); + let variant = std::collections::BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]); + assert!(crate::registry::load_dyn("MaximumIndependentSet", &variant, json).is_err()); +} 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..d73026687 100644 --- a/src/unit_tests/models/graph/maximum_leaf_spanning_tree.rs +++ b/src/unit_tests/models/graph/maximum_leaf_spanning_tree.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; use crate::{solvers::BruteForce, topology::SimpleGraph, traits::Problem}; /// Issue #897 example: 6 vertices, 9 edges. @@ -29,8 +28,16 @@ fn test_maximum_leaf_spanning_tree_creation() { assert_eq!(problem.graph().num_edges(), 9); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 9); - assert_eq!(problem.dimensions().len(), 9); - assert!(problem.dimensions().iter().all(|&d| d == 2)); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 9 + ); + assert!(crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .iter() + .all(|&d| d == 2)); } #[test] @@ -128,7 +135,10 @@ fn test_maximum_leaf_spanning_tree_small_path() { // Path graph P3: 0-1-2, only spanning tree is the path itself -> 2 leaves let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let problem = MaximumLeafSpanningTree::new(graph); - assert_eq!(problem.dimensions(), vec![2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2] + ); let config = vec![true, true]; assert_eq!(problem.evaluate(&config).unwrap(), Max(Some(2))); } diff --git a/src/unit_tests/models/graph/maximum_matching.rs b/src/unit_tests/models/graph/maximum_matching.rs index d39e31e4c..a328fe508 100644 --- a/src/unit_tests/models/graph/maximum_matching.rs +++ b/src/unit_tests/models/graph/maximum_matching.rs @@ -1,10 +1,10 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Max; -include!("../../jl_helpers.rs"); #[test] fn test_matching_creation() { @@ -14,7 +14,7 @@ fn test_matching_creation() { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.num_variables(), 3); + assert_eq!(problem.num_variables().unwrap(), 3); } #[test] @@ -199,3 +199,11 @@ fn create_spec_uses_edge_weights_and_defaults_to_one() { assert_eq!(problem.weights(), vec![1]); assert_eq!(MaximumMatchingCreateSpec::FIELDS[2].name, "edge_weights"); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(2, vec![(0, 1)]).unwrap(); + assert!(MaximumMatching::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "edge_weights": []}); + assert!(serde_json::from_value::>(json).is_err()); +} diff --git a/src/unit_tests/models/graph/min_max_multicenter.rs b/src/unit_tests/models/graph/min_max_multicenter.rs index 8db1f88fa..a98630e6f 100644 --- a/src/unit_tests/models/graph/min_max_multicenter.rs +++ b/src/unit_tests/models/graph/min_max_multicenter.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -24,7 +23,10 @@ fn test_minmaxmulticenter_basic() { assert_eq!(problem.k(), 2); assert_eq!(problem.vertex_weights(), &[1, 1, 1, 1, 1, 1]); assert_eq!(problem.edge_lengths(), &[1, 1, 1, 1, 1, 1, 1]); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 7); assert_eq!(problem.num_centers(), 2); 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..a11302c6e 100644 --- a/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs +++ b/src/unit_tests/models/graph/minimum_capacitated_spanning_tree.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_edge_weights() { @@ -55,7 +54,12 @@ fn test_creation() { assert_eq!(problem.root(), 0); assert_eq!(problem.requirements(), &[0, 1, 1, 1, 1]); assert_eq!(*problem.capacity(), 3); - assert_eq!(problem.dimensions().len(), 8); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 8 + ); assert!(problem.is_weighted()); } diff --git a/src/unit_tests/models/graph/minimum_cost_circulation.rs b/src/unit_tests/models/graph/minimum_cost_circulation.rs index c22563730..97dedae29 100644 --- a/src/unit_tests/models/graph/minimum_cost_circulation.rs +++ b/src/unit_tests/models/graph/minimum_cost_circulation.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; @@ -32,7 +31,10 @@ fn test_minimum_cost_circulation_creation() { assert_eq!(problem.num_arcs(), 4); assert_eq!(problem.capacities(), &[2, 2, 1, 1]); assert_eq!(problem.costs(), &[2, -3, 1, -4]); - assert_eq!(problem.dimensions(), vec![3, 3, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3, 2, 2] + ); assert_eq!( ::NAME, "MinimumCostCirculation" diff --git a/src/unit_tests/models/graph/minimum_cost_maximum_flow.rs b/src/unit_tests/models/graph/minimum_cost_maximum_flow.rs index 5c56ee644..26802d61c 100644 --- a/src/unit_tests/models/graph/minimum_cost_maximum_flow.rs +++ b/src/unit_tests/models/graph/minimum_cost_maximum_flow.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; @@ -58,7 +57,10 @@ fn test_minimum_cost_maximum_flow_creation() { assert_eq!(problem.sink(), 3); assert_eq!(problem.capacities(), &[2, 1, 1, 1, 2]); assert_eq!(problem.costs(), &[1, 0, 0, 1, 2]); - assert_eq!(problem.dimensions(), vec![3, 2, 2, 2, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 2, 2, 2, 3] + ); assert_eq!( ::NAME, "MinimumCostMaximumFlow" diff --git a/src/unit_tests/models/graph/minimum_covering_by_cliques.rs b/src/unit_tests/models/graph/minimum_covering_by_cliques.rs index e2916b241..436bf83b9 100644 --- a/src/unit_tests/models/graph/minimum_covering_by_cliques.rs +++ b/src/unit_tests/models/graph/minimum_covering_by_cliques.rs @@ -11,9 +11,12 @@ fn test_minimum_covering_by_cliques_creation() { let problem = MinimumCoveringByCliques::new(graph); assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 3); - assert_eq!(problem.num_variables(), 3); + assert_eq!(problem.num_variables().unwrap(), 3); // Each edge can be assigned to one of 3 groups - assert_eq!(problem.dimensions(), vec![3; 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 3] + ); } #[test] 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..632c0e8f0 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,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_edge_weights() { @@ -16,7 +15,7 @@ fn create_spec_defaults_edge_weights() { use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; -use crate::types::{Min, SolutionAggregate}; +use crate::types::Min; /// Build the example instance from issue #228: /// 8 vertices, 12 edges, s=0, t=7, B=5 @@ -50,7 +49,10 @@ fn test_minimumcutintoboundedsets_basic() { assert_eq!(problem.source(), 0); assert_eq!(problem.sink(), 7); assert_eq!(problem.size_bound(), 5); - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); } #[test] @@ -192,9 +194,3 @@ fn test_minimumcutintoboundedsets_variant() { assert!(variant.iter().any(|(k, _)| *k == "graph")); assert!(variant.iter().any(|(k, _)| *k == "weight")); } - -#[test] -fn test_minimumcutintoboundedsets_selects_optimal_solutions() { - type Value = as Problem>::Value; - assert!(Value::contributes_to_solution(&Min(Some(3)), &Min(Some(3)))); -} diff --git a/src/unit_tests/models/graph/minimum_dominating_set.rs b/src/unit_tests/models/graph/minimum_dominating_set.rs index 5edd12d7b..2f0b3b621 100644 --- a/src/unit_tests/models/graph/minimum_dominating_set.rs +++ b/src/unit_tests/models/graph/minimum_dominating_set.rs @@ -13,10 +13,10 @@ fn create_spec_rejects_weight_count_mismatch() { }); assert!(result.is_err()); } +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; -include!("../../jl_helpers.rs"); #[test] fn test_dominating_set_creation() { @@ -26,8 +26,11 @@ fn test_dominating_set_creation() { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); } #[test] @@ -201,3 +204,18 @@ fn test_mds_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap().unwrap(), 2); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(1, vec![]).unwrap(); + assert!(MinimumDominatingSet::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "weights": []}); + assert!( + serde_json::from_value::>(json.clone()).is_err() + ); + let variant = std::collections::BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]); + assert!(crate::registry::load_dyn("MinimumDominatingSet", &variant, json).is_err()); +} diff --git a/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs b/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs index fed0eb787..88af45b6d 100644 --- a/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs +++ b/src/unit_tests/models/graph/minimum_dummy_activities_pert.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_cycle() { @@ -43,7 +42,10 @@ fn test_minimum_dummy_activities_pert_creation() { let problem = issue_problem(); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 5); - assert_eq!(problem.dimensions(), vec![2; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); } #[test] diff --git a/src/unit_tests/models/graph/minimum_edge_cost_flow.rs b/src/unit_tests/models/graph/minimum_edge_cost_flow.rs index 6253a1c80..55910eae8 100644 --- a/src/unit_tests/models/graph/minimum_edge_cost_flow.rs +++ b/src/unit_tests/models/graph/minimum_edge_cost_flow.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; @@ -44,7 +43,10 @@ fn test_minimum_edge_cost_flow_creation() { assert_eq!(problem.max_capacity(), 2); assert_eq!(problem.prices(), &[3, 1, 2, 0, 0, 0]); assert_eq!(problem.capacities(), &[2, 2, 2, 2, 2, 2]); - assert_eq!(problem.dimensions(), vec![3, 3, 3, 3, 3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3, 3, 3, 3, 3] + ); assert_eq!( ::NAME, "MinimumEdgeCostFlow" @@ -150,3 +152,30 @@ fn test_minimum_edge_cost_flow_all_witnesses_optimal() { assert_eq!(problem.evaluate(sol).unwrap(), Min(Some(3))); } } + +#[test] +fn deserialize_rejects_invalid_flow_network_data() { + use serde_json::json; + let valid = serde_json::to_value(MinimumEdgeCostFlow::new( + DirectedGraph::new(2, vec![(0, 1)]), + vec![1], + vec![2], + 0, + 1, + 1, + )) + .unwrap(); + for (field, value, message) in [ + ("prices", json!([]), "prices length"), + ("capacities", json!([]), "capacities length"), + ("source", json!(2), "source"), + ("sink", json!(2), "sink"), + ("sink", json!(0), "distinct"), + ("capacities", json!([-1]), "negative"), + ] { + let mut input = valid.clone(); + input[field] = value; + let error = serde_json::from_value::(input).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} 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..a446a1eea 100644 --- a/src/unit_tests/models/graph/minimum_feedback_arc_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_arc_set.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_arc_weights() { @@ -34,8 +33,16 @@ fn test_minimum_feedback_arc_set_creation() { let problem = MinimumFeedbackArcSet::new(graph, vec![1i64; 9]); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 9); - assert_eq!(problem.dimensions().len(), 9); - assert!(problem.dimensions().iter().all(|&d| d == 2)); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 9 + ); + assert!(crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .iter() + .all(|&d| d == 2)); } #[test] @@ -207,3 +214,11 @@ fn test_minimum_feedback_arc_set_accessors() { problem.set_weights(vec![2, 3, 4]); assert_eq!(problem.weights(), &[2, 3, 4]); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = DirectedGraph::try_new(2, vec![(0, 1)]).unwrap(); + assert!(MinimumFeedbackArcSet::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "weights": []}); + assert!(serde_json::from_value::>(json).is_err()); +} 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..274a0e050 100644 --- a/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs +++ b/src/unit_tests/models/graph/minimum_feedback_vertex_set.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_vertex_weights() { @@ -52,7 +51,10 @@ fn test_minimum_feedback_vertex_set_basic() { let problem = MinimumFeedbackVertexSet::new(graph, vec![1i64; 9]); // dims should be [2; 9] - assert_eq!(problem.dimensions(), vec![2usize; 9]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2usize; 9] + ); // Valid FVS: {0, 3, 8} → config = [1,0,0,1,0,0,0,0,1] let config_valid = vec![true, false, false, true, false, false, false, false, true]; @@ -242,3 +244,11 @@ fn test_minimum_feedback_vertex_set_unit_create_and_roundtrip() { .is_err() ); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = DirectedGraph::try_new(2, vec![(0, 1)]).unwrap(); + assert!(MinimumFeedbackVertexSet::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "weights": []}); + assert!(serde_json::from_value::>(json).is_err()); +} diff --git a/src/unit_tests/models/graph/minimum_geometric_connected_dominating_set.rs b/src/unit_tests/models/graph/minimum_geometric_connected_dominating_set.rs index a5a1c6535..6236476ae 100644 --- a/src/unit_tests/models/graph/minimum_geometric_connected_dominating_set.rs +++ b/src/unit_tests/models/graph/minimum_geometric_connected_dominating_set.rs @@ -10,8 +10,11 @@ fn test_creation_and_getters() { assert_eq!(problem.num_points(), 3); assert!((problem.radius() - 1.5).abs() < f64::EPSILON); assert_eq!(problem.points().len(), 3); - assert_eq!(problem.num_variables(), 3); - assert_eq!(problem.dimensions(), vec![2; 3]); + assert_eq!(problem.num_variables().unwrap(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 3] + ); } #[test] diff --git a/src/unit_tests/models/graph/minimum_graph_bandwidth.rs b/src/unit_tests/models/graph/minimum_graph_bandwidth.rs index bdfa58ec7..6b3f5e841 100644 --- a/src/unit_tests/models/graph/minimum_graph_bandwidth.rs +++ b/src/unit_tests/models/graph/minimum_graph_bandwidth.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -22,7 +21,10 @@ fn test_minimumgraphbandwidth_creation() { let problem = star_example(); assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 3); - assert_eq!(problem.dimensions(), vec![4, 4, 4, 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 4, 4, 4] + ); } #[test] @@ -106,7 +108,10 @@ fn test_minimumgraphbandwidth_serialization() { fn test_minimumgraphbandwidth_single_vertex() { let graph = SimpleGraph::new(1, vec![]); let problem = MinimumGraphBandwidth::new(graph); - assert_eq!(problem.dimensions(), vec![1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1] + ); assert_eq!(problem.evaluate(&vec![0]).unwrap(), Min(Some(0))); assert_eq!(problem.bandwidth(&[0]).unwrap(), Some(0)); } diff --git a/src/unit_tests/models/graph/minimum_intersection_graph_basis.rs b/src/unit_tests/models/graph/minimum_intersection_graph_basis.rs index 17fd4e160..0bab5b0b2 100644 --- a/src/unit_tests/models/graph/minimum_intersection_graph_basis.rs +++ b/src/unit_tests/models/graph/minimum_intersection_graph_basis.rs @@ -12,8 +12,11 @@ fn test_minimum_intersection_graph_basis_creation() { assert_eq!(problem.num_vertices(), 3); assert_eq!(problem.num_edges(), 2); // 3 vertices * 2 edges = 6 binary variables - assert_eq!(problem.num_variables(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!(problem.num_variables().unwrap(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); } #[test] @@ -66,7 +69,10 @@ fn test_minimum_intersection_graph_basis_triangle() { let problem = MinimumIntersectionGraphBasis::new(graph); // 3 vertices * 3 edges = 9 binary variables - assert_eq!(problem.dimensions(), vec![2; 9]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 9] + ); // Valid: S[0]={0}, S[1]={0}, S[2]={0} // config: v0: [1,0,0], v1: [1,0,0], v2: [1,0,0] diff --git a/src/unit_tests/models/graph/minimum_maximal_matching.rs b/src/unit_tests/models/graph/minimum_maximal_matching.rs index 4a15078d7..b557c2fd5 100644 --- a/src/unit_tests/models/graph/minimum_maximal_matching.rs +++ b/src/unit_tests/models/graph/minimum_maximal_matching.rs @@ -11,7 +11,7 @@ fn test_minimum_maximal_matching_creation() { let problem = MinimumMaximalMatching::new(graph); assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 3); - assert_eq!(problem.num_variables(), 3); + assert_eq!(problem.num_variables().unwrap(), 3); } #[test] diff --git a/src/unit_tests/models/graph/minimum_metric_dimension.rs b/src/unit_tests/models/graph/minimum_metric_dimension.rs index 6e4764d1f..2fbe2ce97 100644 --- a/src/unit_tests/models/graph/minimum_metric_dimension.rs +++ b/src/unit_tests/models/graph/minimum_metric_dimension.rs @@ -11,8 +11,11 @@ fn test_minimum_metric_dimension_creation() { let problem = MinimumMetricDimension::new(graph); assert_eq!(problem.graph().num_vertices(), 5); assert_eq!(problem.graph().num_edges(), 6); - assert_eq!(problem.num_variables(), 5); - assert_eq!(problem.dimensions(), vec![2; 5]); + assert_eq!(problem.num_variables().unwrap(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); } #[test] diff --git a/src/unit_tests/models/graph/minimum_multiway_cut.rs b/src/unit_tests/models/graph/minimum_multiway_cut.rs index c9831cc6e..4c5c0b099 100644 --- a/src/unit_tests/models/graph/minimum_multiway_cut.rs +++ b/src/unit_tests/models/graph/minimum_multiway_cut.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_invalid_terminals() { @@ -20,7 +19,12 @@ use crate::types::Min; fn test_minimummultiwaycut_creation() { let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (0, 4), (1, 3)]); let problem = MinimumMultiwayCut::new(graph, vec![0, 2, 4], vec![2, 3, 1, 2, 4, 5]); - assert_eq!(problem.dimensions().len(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 6 + ); assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_edges(), 6); assert_eq!(problem.num_terminals(), 3); diff --git a/src/unit_tests/models/graph/minimum_sum_multicenter.rs b/src/unit_tests/models/graph/minimum_sum_multicenter.rs index e636dee31..855082d05 100644 --- a/src/unit_tests/models/graph/minimum_sum_multicenter.rs +++ b/src/unit_tests/models/graph/minimum_sum_multicenter.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -271,7 +270,10 @@ fn test_min_sum_multicenter_paper_example() { fn test_min_sum_multicenter_dims() { let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); let problem = MinimumSumMulticenter::new(graph, vec![1i64; 5], vec![1i64; 4], 2); - assert_eq!(problem.dimensions(), vec![2; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); } #[test] diff --git a/src/unit_tests/models/graph/minimum_vertex_cover.rs b/src/unit_tests/models/graph/minimum_vertex_cover.rs index 809475eef..e9cfe95e1 100644 --- a/src/unit_tests/models/graph/minimum_vertex_cover.rs +++ b/src/unit_tests/models/graph/minimum_vertex_cover.rs @@ -13,10 +13,10 @@ fn create_spec_rejects_weight_count_mismatch() { }); assert!(result.is_err()); } +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; -include!("../../jl_helpers.rs"); #[test] fn test_vertex_cover_creation() { @@ -26,7 +26,7 @@ fn test_vertex_cover_creation() { ); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.num_variables(), 4); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] @@ -202,3 +202,16 @@ fn test_mvc_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap().unwrap(), 3); } + +#[test] +fn construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(1, vec![]).unwrap(); + assert!(MinimumVertexCover::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "weights": []}); + assert!(serde_json::from_value::>(json.clone()).is_err()); + let variant = std::collections::BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]); + assert!(crate::registry::load_dyn("MinimumVertexCover", &variant, json).is_err()); +} diff --git a/src/unit_tests/models/graph/mixed_chinese_postman.rs b/src/unit_tests/models/graph/mixed_chinese_postman.rs index 5c8b3cbc8..fa082bc93 100644 --- a/src/unit_tests/models/graph/mixed_chinese_postman.rs +++ b/src/unit_tests/models/graph/mixed_chinese_postman.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_infers_graph_and_default_weights() { @@ -51,7 +50,10 @@ fn test_mixed_chinese_postman_creation_and_accessors() { assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_arcs(), 4); assert_eq!(problem.num_edges(), 4); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); assert_eq!(problem.arc_weights(), &[2, 3, 1, 4]); assert_eq!(problem.edge_weights(), &[2, 3, 1, 2]); } @@ -179,3 +181,41 @@ fn test_mixed_chinese_postman_ignores_isolated_vertices() { Min(Some(69)) ); } + +#[test] +fn test_mixed_chinese_postman_deserialization_rejects_invalid_weights() { + let valid = serde_json::to_value(sample_instance()).unwrap(); + let restored: MixedChinesePostman = serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), valid); + + let cases = [ + ( + "arc_weights", + serde_json::json!([2, 3, 1]), + "arc_weights length must match num_arcs", + ), + ( + "edge_weights", + serde_json::json!([2, 3, 1, 2, 7]), + "edge_weights length must match num_edges", + ), + ( + "arc_weights", + serde_json::json!([2, 3, -1, 4]), + "arc weight at index 2 must be nonnegative", + ), + ( + "edge_weights", + serde_json::json!([2, -3, 1, 2]), + "edge weight at index 1 must be nonnegative", + ), + ]; + for (field, value, expected) in cases { + let mut json = valid.clone(); + json[field] = value; + let error = serde_json::from_value::>(json) + .unwrap_err() + .to_string(); + assert_eq!(error, format!("problem construction failed: {expected}")); + } +} diff --git a/src/unit_tests/models/graph/monochromatic_triangle.rs b/src/unit_tests/models/graph/monochromatic_triangle.rs index 54f7a56f8..cd74129d3 100644 --- a/src/unit_tests/models/graph/monochromatic_triangle.rs +++ b/src/unit_tests/models/graph/monochromatic_triangle.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -20,7 +19,10 @@ fn test_monochromatic_triangle_creation() { // K4 has 4 triangles assert_eq!(problem.triangles().len(), 4); // One binary variable per edge - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!(problem.graph().num_vertices(), 4); } @@ -123,3 +125,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/multiple_choice_branching.rs b/src/unit_tests/models/graph/multiple_choice_branching.rs index c4054593d..1ee93e1f9 100644 --- a/src/unit_tests/models/graph/multiple_choice_branching.rs +++ b/src/unit_tests/models/graph/multiple_choice_branching.rs @@ -58,7 +58,10 @@ fn test_multiple_choice_branching_creation_and_accessors() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 8); assert_eq!(problem.num_partition_groups(), 4); - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); assert_eq!(problem.graph().arcs().len(), 8); assert_eq!(problem.weights(), &[3, 2, 4, 1, 2, 3, 1, 3]); assert_eq!( @@ -277,5 +280,5 @@ fn test_multiple_choice_branching_set_weights_rejects_wrong_length() { #[test] fn test_multiple_choice_branching_num_variables() { let problem = yes_instance(); - assert_eq!(problem.num_variables(), 8); + assert_eq!(problem.num_variables().unwrap(), 8); } diff --git a/src/unit_tests/models/graph/multiple_copy_file_allocation.rs b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs index f8b910cf7..9f8fe991d 100644 --- a/src/unit_tests/models/graph/multiple_copy_file_allocation.rs +++ b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_preserves_isolated_vertices() { @@ -31,7 +30,10 @@ fn test_multiple_copy_file_allocation_creation() { assert_eq!(problem.num_edges(), 6); assert_eq!(problem.usage(), &[10; 6]); assert_eq!(problem.storage(), &[1; 6]); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert!(MultipleCopyFileAllocation::variant().is_empty()); } diff --git a/src/unit_tests/models/graph/optimal_linear_arrangement.rs b/src/unit_tests/models/graph/optimal_linear_arrangement.rs index cd433e63d..82dbd9054 100644 --- a/src/unit_tests/models/graph/optimal_linear_arrangement.rs +++ b/src/unit_tests/models/graph/optimal_linear_arrangement.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -25,7 +24,10 @@ fn test_optimallineararrangement_basic() { let problem = issue_example(); // Check dims: 6 variables, each with domain size 6 - assert_eq!(problem.dimensions(), vec![6, 6, 6, 6, 6, 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6, 6, 6, 6, 6, 6] + ); // Identity arrangement: f(i) = i // Cost: |0-1| + |1-2| + |2-3| + |3-4| + |4-5| + |0-3| + |2-5| = 1+1+1+1+1+3+3 = 11 @@ -143,7 +145,10 @@ fn test_optimallineararrangement_single_vertex() { let graph = SimpleGraph::new(1, vec![]); let problem = OptimalLinearArrangement::new(graph); - assert_eq!(problem.dimensions(), vec![1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1] + ); assert_eq!(problem.evaluate(&vec![0]).unwrap(), Min(Some(0))); assert_eq!(problem.total_edge_length(&[0]).unwrap(), Some(0)); } diff --git a/src/unit_tests/models/graph/partial_feedback_edge_set.rs b/src/unit_tests/models/graph/partial_feedback_edge_set.rs index c39b3895a..bd56b4278 100644 --- a/src/unit_tests/models/graph/partial_feedback_edge_set.rs +++ b/src/unit_tests/models/graph/partial_feedback_edge_set.rs @@ -66,8 +66,11 @@ fn test_partial_feedback_edge_set_creation() { assert_eq!(problem.max_cycle_length(), 4); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 9); - assert_eq!(problem.num_variables(), 9); - assert_eq!(problem.dimensions(), vec![2; 9]); + assert_eq!(problem.num_variables().unwrap(), 9); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 9] + ); } #[test] diff --git a/src/unit_tests/models/graph/partition_into_cliques.rs b/src/unit_tests/models/graph/partition_into_cliques.rs index 2ee0b8fe3..b1d602f57 100644 --- a/src/unit_tests/models/graph/partition_into_cliques.rs +++ b/src/unit_tests/models/graph/partition_into_cliques.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -31,7 +30,10 @@ fn test_partition_into_cliques_creation() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 9); assert_eq!(problem.num_cliques(), 3); - assert_eq!(problem.dimensions(), vec![3; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 6] + ); assert_eq!(problem.graph().num_vertices(), 6); } diff --git a/src/unit_tests/models/graph/partition_into_forests.rs b/src/unit_tests/models/graph/partition_into_forests.rs index de56c11ba..c174713fa 100644 --- a/src/unit_tests/models/graph/partition_into_forests.rs +++ b/src/unit_tests/models/graph/partition_into_forests.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -22,7 +21,10 @@ fn test_partition_into_forests_creation() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 7); assert_eq!(problem.num_forests(), 2); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!(problem.graph().num_vertices(), 6); } 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..46f0984af 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,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -27,7 +26,10 @@ fn test_partition_into_paths_basic() { assert_eq!(problem.num_vertices(), 9); assert_eq!(problem.num_edges(), 10); assert_eq!(problem.num_groups(), 3); - assert_eq!(problem.dimensions(), vec![3; 9]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 9] + ); // Valid partition: {0,1,2}, {3,4,5}, {6,7,8} // Config: vertex i -> group i/3 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..492d4c2c5 100644 --- a/src/unit_tests/models/graph/partition_into_perfect_matchings.rs +++ b/src/unit_tests/models/graph/partition_into_perfect_matchings.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -15,7 +14,10 @@ fn test_partition_into_perfect_matchings_creation() { assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 4); assert_eq!(problem.num_matchings(), 2); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!(problem.graph().num_vertices(), 4); } diff --git a/src/unit_tests/models/graph/partition_into_triangles.rs b/src/unit_tests/models/graph/partition_into_triangles.rs index 42fb8c9c3..7a38781a8 100644 --- a/src/unit_tests/models/graph/partition_into_triangles.rs +++ b/src/unit_tests/models/graph/partition_into_triangles.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; #[test] @@ -25,7 +24,10 @@ fn test_partitionintotriangles_basic() { assert_eq!(problem.num_vertices(), 9); assert_eq!(problem.num_edges(), 9); - assert_eq!(problem.dimensions(), vec![3; 9]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 9] + ); // Valid partition: vertices 0,1,2 in group 0; 3,4,5 in group 1; 6,7,8 in group 2 assert!(problem.evaluate(&vec![0, 0, 0, 1, 1, 1, 2, 2, 2]).unwrap()); @@ -44,7 +46,10 @@ fn test_partitionintotriangles_no_solution() { let problem = PartitionIntoTriangles::new(graph); assert_eq!(problem.num_vertices(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); // No valid partition exists since there are no triangles let solver = BruteForce::new(); 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 af1cf3617..b045b99a5 100644 --- a/src/unit_tests/models/graph/path_constrained_network_flow.rs +++ b/src/unit_tests/models/graph/path_constrained_network_flow.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_capacities_and_validates_paths() { @@ -76,7 +75,10 @@ fn test_path_constrained_network_flow_creation() { #[test] fn test_path_constrained_network_flow_dims_use_path_bottlenecks() { let problem = yes_instance(); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2, 2] + ); } #[test] @@ -173,3 +175,54 @@ fn test_path_constrained_network_flow_paper_example() { assert_eq!(all.len(), 2); assert!(all.contains(&config)); } + +#[test] +fn test_path_constrained_network_flow_deserialization_rejects_invalid_instances() { + let valid = serde_json::to_value(yes_instance()).unwrap(); + let restored: PathConstrainedNetworkFlow = serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(serde_json::to_value(&restored).unwrap(), valid); + + let cases = [ + ( + "paths", + serde_json::json!([[99]]), + "arc index 99 out of bounds", + ), + ("paths", serde_json::json!([[]]), "must be non-empty"), + ("paths", serde_json::json!([[0, 5]]), "not contiguous"), + ( + "paths", + serde_json::json!([[0, 2, 5, 8], [0, 2]]), + "path 1: ", + ), + ( + "paths", + serde_json::json!([[0, 2]]), + "must end at sink 7, ended at 3", + ), + ( + "capacities", + serde_json::json!([1, 1]), + "capacities length must match graph num_arcs", + ), + ( + "source", + serde_json::json!(8), + "source (8) >= num_vertices (8)", + ), + ("sink", serde_json::json!(8), "sink (8) >= num_vertices (8)"), + ( + "sink", + serde_json::json!(0), + "source and sink must be distinct", + ), + ]; + for (field, value, expected) in cases { + let mut json = valid.clone(); + json[field] = value; + let error = serde_json::from_value::(json) + .unwrap_err() + .to_string(); + assert!(error.contains(expected), "{field}: {error}"); + } +} 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..3181533ee 100644 --- a/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs +++ b/src/unit_tests/models/graph/prize_collecting_steiner_forest.rs @@ -29,8 +29,11 @@ fn test_prize_collecting_steiner_forest_creation() { assert_eq!(*problem.beta(), 1); assert_eq!(*problem.omega(), 2); // n + m = 3 + 2 = 5 binary variables. - assert_eq!(problem.dimensions(), vec![2; 5]); - assert_eq!(problem.num_variables(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); + assert_eq!(problem.num_variables().unwrap(), 5); assert!(problem.graph().has_edge(0, 1)); } @@ -246,3 +249,25 @@ fn create_specs_default_prizes_and_costs_to_one() { assert!(!PrizeCollectingSteinerForestI64CreateSpec::inputs()[2].required); assert!(!PrizeCollectingSteinerForestI64CreateSpec::inputs()[3].required); } + +#[test] +fn nonnegative_domain_is_shared_by_construction_and_serde() { + for (prizes, costs, beta, omega) in [ + (vec![-1, 0], vec![0], 1, 1), + (vec![0, 0], vec![-1], 1, 1), + (vec![0, 0], vec![0], -1, 1), + (vec![0, 0], vec![0], 1, -1), + ] { + let graph = SimpleGraph::new(2, vec![(0, 1)]); + let data = serde_json::json!({"graph": graph, "vertex_prizes": prizes, + "edge_costs": costs, "beta": beta, "omega": omega}); + assert!( + serde_json::from_value::>(data.clone()) + .is_err() + ); + assert!( + serde_json::from_value::>(data).is_err() + ); + assert!(PrizeCollectingSteinerForest::new(graph, prizes, costs, beta, omega).is_err()); + } +} diff --git a/src/unit_tests/models/graph/rooted_tree_arrangement.rs b/src/unit_tests/models/graph/rooted_tree_arrangement.rs index a34c88abf..c8404e01f 100644 --- a/src/unit_tests/models/graph/rooted_tree_arrangement.rs +++ b/src/unit_tests/models/graph/rooted_tree_arrangement.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -21,7 +20,10 @@ fn test_rootedtreearrangement_basic_yes_example() { assert_eq!(problem.num_vertices(), 5); assert_eq!(problem.num_edges(), 5); assert_eq!(problem.bound(), 7); - assert_eq!(problem.dimensions(), vec![5; 10]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 10] + ); assert!(problem.evaluate(&config).unwrap()); assert_eq!(problem.total_edge_stretch(&config).unwrap(), Some(6)); } diff --git a/src/unit_tests/models/graph/rural_postman.rs b/src/unit_tests/models/graph/rural_postman.rs index aec2d3a5d..1a5ab5aaf 100644 --- a/src/unit_tests/models/graph/rural_postman.rs +++ b/src/unit_tests/models/graph/rural_postman.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -42,8 +41,16 @@ fn test_rural_postman_creation() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 8); assert_eq!(problem.num_required_edges(), 3); - assert_eq!(problem.dimensions().len(), 8); - assert!(problem.dimensions().iter().all(|&d| d == 3)); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 8 + ); + assert!(crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .iter() + .all(|&d| d == 3)); } #[test] 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..70de8b939 100644 --- a/src/unit_tests/models/graph/shortest_weight_constrained_path.rs +++ b/src/unit_tests/models/graph/shortest_weight_constrained_path.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_nonpositive_edge_values() { @@ -53,7 +52,10 @@ fn test_shortest_weight_constrained_path_creation() { assert_eq!(problem.source_vertex(), 0); assert_eq!(problem.target_vertex(), 5); assert_eq!(*problem.weight_bound(), 8); - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); assert!(problem.is_weighted()); } diff --git a/src/unit_tests/models/graph/spin_glass.rs b/src/unit_tests/models/graph/spin_glass.rs index 27cc19d84..3ccb73366 100644 --- a/src/unit_tests/models/graph/spin_glass.rs +++ b/src/unit_tests/models/graph/spin_glass.rs @@ -13,9 +13,9 @@ fn create_spec_defaults_couplings_and_fields() { assert_eq!(problem.couplings(), &[1]); assert_eq!(problem.fields(), &[0, 0, 0]); } +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::traits::Problem; -include!("../../jl_helpers.rs"); #[test] fn test_spin_glass_creation() { @@ -94,7 +94,7 @@ fn test_compute_energy_rejects_invalid_spin_configuration() { #[test] fn test_num_variables() { let problem = SpinGlass::::without_fields(5, vec![]).unwrap(); - assert_eq!(problem.num_variables(), 5); + assert_eq!(problem.num_variables().unwrap(), 5); } #[test] diff --git a/src/unit_tests/models/graph/steiner_tree.rs b/src/unit_tests/models/graph/steiner_tree.rs index 92d4d7a29..2eb9cc12c 100644 --- a/src/unit_tests/models/graph/steiner_tree.rs +++ b/src/unit_tests/models/graph/steiner_tree.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_duplicate_terminals() { @@ -31,7 +30,12 @@ fn test_steiner_tree_creation() { assert_eq!(problem.graph().num_vertices(), 5); assert_eq!(problem.graph().num_edges(), 7); assert_eq!(problem.terminals(), &[0, 2, 4]); - assert_eq!(problem.dimensions().len(), 7); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 7 + ); } #[test] @@ -176,10 +180,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_empty_terminals() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let _ = SteinerTree::new(graph, vec![1, 1], vec![0]); + let _ = SteinerTree::new(graph, vec![1, 1], vec![]); } #[test] @@ -198,12 +202,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]]}, @@ -212,3 +216,24 @@ fn test_steiner_tree_deserialization_rejects_invalid_invariants() { }); assert!(serde_json::from_value::>(wrong_weights).is_err()); } + +#[test] +fn test_steiner_tree_single_terminal_semantics() { + let problem = SteinerTree::try_from(SteinerTreeCreateSpec { + graph: SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), + edge_weights: vec![-5, 1, 1, -10], + terminals: vec![0], + }) + .unwrap(); + let restored: SteinerTree = + serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); + for (edges, value) in [ + (vec![false, false, false, false], Min(Some(0))), + (vec![true, false, false, false], Min(Some(-5))), + (vec![false, false, false, true], Min(None)), + (vec![true, false, false, true], Min(None)), + (vec![true, true, true, false], Min(None)), + ] { + assert_eq!(restored.evaluate(&edges).unwrap(), value); + } +} diff --git a/src/unit_tests/models/graph/steiner_tree_in_graphs.rs b/src/unit_tests/models/graph/steiner_tree_in_graphs.rs deleted file mode 100644 index fd845b214..000000000 --- a/src/unit_tests/models/graph/steiner_tree_in_graphs.rs +++ /dev/null @@ -1,232 +0,0 @@ -use super::*; -use crate::solvers::BruteForceProblem as _; - -#[test] -fn create_spec_defaults_edge_weights() { - let p = SteinerTreeInGraphs::try_from(SteinerTreeInGraphsCreateSpec:: { - graph: SimpleGraph::new(2, vec![(0, 1)]), - terminals: vec![0, 1], - edge_weights: None, - }) - .unwrap(); - assert_eq!(p.weights(), &[1]); -} -use crate::solvers::BruteForce; -use crate::topology::SimpleGraph; -use crate::traits::Problem; - -#[test] -fn test_steiner_tree_creation() { - // Path graph: 0-1-2-3 - let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 3], vec![1i64, 2, 3]); - assert_eq!(problem.graph().num_vertices(), 4); - assert_eq!(problem.graph().num_edges(), 3); - assert_eq!(problem.terminals(), &[0, 3]); - assert_eq!(problem.dimensions().len(), 3); - assert_eq!(problem.num_vertices(), 4); - assert_eq!(problem.num_edges(), 3); - assert_eq!(problem.num_terminals(), 2); -} - -#[test] -fn test_steiner_tree_evaluation() { - // Triangle graph: 0-1, 1-2, 0-2, with terminal {0, 2} - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![3i64, 4, 1]); - - // Select edge 0-2 (weight 1): valid, connects terminals directly - let config_direct = vec![false, false, true]; - let result = problem.evaluate(&config_direct).unwrap(); - assert!(result.is_valid()); - assert_eq!(result.unwrap(), 1); - - // Select edges 0-1 and 1-2 (weights 3+4=7): valid, connects via vertex 1 - let config_via = vec![true, true, false]; - let result = problem.evaluate(&config_via).unwrap(); - assert!(result.is_valid()); - assert_eq!(result.unwrap(), 7); - - // Select only edge 0-1: invalid (terminal 2 not reached) - let config_invalid = vec![true, false, false]; - let result = problem.evaluate(&config_invalid).unwrap(); - assert!(!result.is_valid()); - - // Select no edges: invalid - let config_empty = vec![false, false, false]; - let result = problem.evaluate(&config_empty).unwrap(); - assert!(!result.is_valid()); -} - -#[test] -fn test_steiner_tree_solver() { - // Diamond graph: - // 1 - // / \ - // 0 3 - // \ / - // 2 - // Edges: 0-1(w=2), 0-2(w=1), 1-3(w=2), 2-3(w=1) - // Terminals: {0, 3} - // Optimal path: 0-2-3 with weight 1+1=2 - let graph = SimpleGraph::new(4, vec![(0, 1), (0, 2), (1, 3), (2, 3)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 3], vec![2, 1, 2, 1]); - - let solver = BruteForce::new(); - let solution = solver.solve(&problem).unwrap().unwrap(); - let value = problem.evaluate(&solution).unwrap(); - assert!(value.is_valid()); - assert_eq!(value.unwrap(), 2); - // Should select edges 0-2 and 2-3 - assert_eq!(solution, vec![false, true, false, true]); -} - -#[test] -fn test_steiner_tree_with_steiner_vertices() { - // Star graph: center vertex 1 connected to 0, 2, 3 - // Edges: 0-1(w=1), 1-2(w=1), 1-3(w=1) - // Terminals: {0, 2, 3} - // Optimal: use vertex 1 as Steiner vertex, select all 3 edges, weight = 3 - let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (1, 3)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 2, 3], vec![1i64; 3]); - - let solver = BruteForce::new(); - let solution = solver.solve(&problem).unwrap().unwrap(); - let value = problem.evaluate(&solution).unwrap(); - assert!(value.is_valid()); - assert_eq!(value.unwrap(), 3); - assert_eq!(solution, vec![true, true, true]); -} - -#[test] -fn test_steiner_tree_is_valid_solution() { - // Path graph: 0-1-2 - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![1i64; 2]); - - // Valid: both edges selected - assert!(problem.is_valid_solution(&[1, 1])); - // Invalid: only first edge - assert!(!problem.is_valid_solution(&[1, 0])); - // Invalid: only second edge - assert!(!problem.is_valid_solution(&[0, 1])); - // Invalid: no edges - assert!(!problem.is_valid_solution(&[0, 0])); -} - -#[test] -fn test_steiner_tree_parameter_getters() { - let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 2, 4], vec![1i64; 4]); - assert_eq!(problem.num_vertices(), 5); - assert_eq!(problem.num_edges(), 4); - assert_eq!(problem.num_terminals(), 3); -} - -#[test] -fn test_steiner_tree_problem_name() { - assert_eq!( - as Problem>::NAME, - "SteinerTreeInGraphs" - ); -} - -#[test] -fn test_steiner_tree_serialization() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![1i64; 2]); - let json = serde_json::to_string(&problem).unwrap(); - let deserialized: SteinerTreeInGraphs = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.graph().num_vertices(), 3); - assert_eq!(deserialized.terminals(), &[0, 2]); - assert_eq!(deserialized.num_edges(), 2); -} - -#[test] -fn test_steiner_tree_single_terminal() { - // Single terminal: any config (including empty) is valid - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = SteinerTreeInGraphs::new(graph, vec![1], vec![1i64; 2]); - - // No edges needed for a single terminal - let result = problem.evaluate(&vec![false, false]).unwrap(); - assert!(result.is_valid()); - assert_eq!(result.unwrap(), 0); -} - -#[test] -fn test_steiner_tree_all_vertices_terminal() { - // When all vertices are terminals, it degenerates to spanning tree - // Path: 0-1-2 - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 1, 2], vec![1i64; 2]); - - let solver = BruteForce::new(); - let solution = solver.solve(&problem).unwrap().unwrap(); - let value = problem.evaluate(&solution).unwrap(); - assert!(value.is_valid()); - assert_eq!(value.unwrap(), 2); -} - -#[test] -fn test_steiner_tree_edges_accessor() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![5i64, 10]); - let edges = problem.edges(); - assert_eq!(edges.len(), 2); - assert_eq!(edges[0].2, 5); - assert_eq!(edges[1].2, 10); -} - -#[test] -fn test_steiner_tree_weights_management() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let mut problem = SteinerTreeInGraphs::new(graph, vec![0, 2], vec![1i64; 2]); - assert!(problem.is_weighted()); - assert_eq!(problem.weights(), vec![1, 1]); - - problem.set_weights(vec![5, 10]); - assert_eq!(problem.weights(), vec![5, 10]); -} - -#[test] -fn test_steiner_tree_example_from_issue() { - // Example from issue #255: - // Graph with 8 vertices {0,1,2,3,4,5,6,7} and 12 edges - // Terminals R = {0, 3, 5, 7} - let graph = SimpleGraph::new( - 8, - vec![ - (0, 1), // w=2, idx=0 - (0, 2), // w=3, idx=1 - (1, 2), // w=1, idx=2 - (1, 3), // w=4, idx=3 - (2, 4), // w=2, idx=4 - (3, 4), // w=3, idx=5 - (3, 5), // w=5, idx=6 - (4, 5), // w=1, idx=7 - (4, 6), // w=2, idx=8 - (5, 6), // w=3, idx=9 - (5, 7), // w=4, idx=10 - (6, 7), // w=1, idx=11 - ], - ); - let weights = vec![2, 3, 1, 4, 2, 3, 5, 1, 2, 3, 4, 1]; - let problem = SteinerTreeInGraphs::new(graph, vec![0, 3, 5, 7], weights); - - // Brute-force verification: independently confirm optimal weight is 12 - let solver = BruteForce::new(); - let solution = solver.solve(&problem).unwrap().unwrap(); - let value = problem.evaluate(&solution).unwrap(); - assert!(value.is_valid()); - assert_eq!(value.unwrap(), 12); - - // Verify the claimed optimal solution from the issue: - // Edges: {0,1}(2) + {1,2}(1) + {2,4}(2) + {3,4}(3) + {4,5}(1) + {4,6}(2) + {6,7}(1) = 12 - let config = vec![ - true, false, true, false, true, true, false, true, true, false, false, true, - ]; - let result = problem.evaluate(&config).unwrap(); - assert!(result.is_valid()); - assert_eq!(result.unwrap(), 12); -} diff --git a/src/unit_tests/models/graph/strong_connectivity_augmentation.rs b/src/unit_tests/models/graph/strong_connectivity_augmentation.rs index 107ca5e19..2134075ed 100644 --- a/src/unit_tests/models/graph/strong_connectivity_augmentation.rs +++ b/src/unit_tests/models/graph/strong_connectivity_augmentation.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -71,7 +70,10 @@ fn test_strong_connectivity_augmentation_creation() { assert_eq!(problem.num_potential_arcs(), 18); assert_eq!(problem.candidate_arcs().len(), 18); assert_eq!(problem.bound(), &1); - assert_eq!(problem.dimensions(), vec![2; 18]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 18] + ); assert!(problem.is_weighted()); } @@ -103,7 +105,10 @@ fn test_strong_connectivity_augmentation_wrong_length() { #[test] fn test_strong_connectivity_augmentation_already_strongly_connected() { let problem = issue_example_already_strongly_connected(); - assert_eq!(problem.dimensions(), vec![2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2] + ); assert!(problem.evaluate(&vec![false]).unwrap()); assert!(!problem.evaluate(&vec![true]).unwrap()); } diff --git a/src/unit_tests/models/graph/subgraph_isomorphism.rs b/src/unit_tests/models/graph/subgraph_isomorphism.rs index bde097812..a53850034 100644 --- a/src/unit_tests/models/graph/subgraph_isomorphism.rs +++ b/src/unit_tests/models/graph/subgraph_isomorphism.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -14,7 +13,10 @@ fn test_subgraph_isomorphism_creation() { assert_eq!(problem.num_pattern_vertices(), 2); assert_eq!(problem.num_pattern_edges(), 1); // dims: 2 pattern vertices, each can map to 4 host vertices - assert_eq!(problem.dimensions(), vec![4, 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 4] + ); } #[test] diff --git a/src/unit_tests/models/graph/traveling_salesman.rs b/src/unit_tests/models/graph/traveling_salesman.rs index ef89561a5..9bb65f81d 100644 --- a/src/unit_tests/models/graph/traveling_salesman.rs +++ b/src/unit_tests/models/graph/traveling_salesman.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -18,7 +17,12 @@ fn test_traveling_salesman_creation() { let problem = k4_tsp(); assert_eq!(problem.graph().num_vertices(), 4); assert_eq!(problem.graph().num_edges(), 6); - assert_eq!(problem.dimensions().len(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 6 + ); } #[test] @@ -292,3 +296,11 @@ 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 construction_and_json_reject_mismatched_weights() { + let graph = SimpleGraph::try_new(2, vec![(0, 1)]).unwrap(); + assert!(TravelingSalesman::try_new(graph.clone(), Vec::::new()).is_err()); + let json = serde_json::json!({"graph": graph, "edge_weights": []}); + assert!(serde_json::from_value::>(json).is_err()); +} diff --git a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs index c67868b1c..80e0eaca1 100644 --- a/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs +++ b/src/unit_tests/models/graph/undirected_flow_lower_bounds.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_lower_bound_above_capacity() { @@ -64,7 +63,10 @@ fn test_undirected_flow_lower_bounds_creation() { assert_eq!(problem.requirement(), 3); assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_edges(), 7); - assert_eq!(problem.dimensions(), vec![2; 7]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 7] + ); } #[test] @@ -126,3 +128,53 @@ fn test_undirected_flow_lower_bounds_paper_example() { let all = BruteForce::new().find_all_witnesses(&problem).unwrap(); assert!(all.contains(&config)); } + +#[test] +fn test_undirected_flow_lower_bounds_rejects_nonpositive_requirement_on_every_path() { + let expected = "problem construction failed: requirement must be at least 1"; + for requirement in [0, -5, i64::MIN] { + let graph = || SimpleGraph::new(2, vec![(0, 1)]); + + let error = + UndirectedFlowLowerBounds::try_new(graph(), vec![1], vec![0], 0, 1, requirement) + .unwrap_err(); + assert_eq!(error.to_string(), expected); + + let error = UndirectedFlowLowerBounds::try_from(UndirectedFlowLowerBoundsCreateSpec { + graph: graph(), + capacities: vec![1], + lower_bounds: vec![0], + source: 0, + sink: 1, + requirement, + }) + .unwrap_err(); + assert_eq!(error.to_string(), expected); + + let mut json = serde_json::to_value(UndirectedFlowLowerBounds::new( + graph(), + vec![1], + vec![0], + 0, + 1, + 1, + )) + .unwrap(); + json["requirement"] = serde_json::json!(requirement); + let error = serde_json::from_value::(json).unwrap_err(); + assert_eq!(error.to_string(), expected); + } +} + +#[test] +#[should_panic(expected = "requirement must be at least 1")] +fn test_undirected_flow_lower_bounds_new_panics_on_negative_requirement() { + UndirectedFlowLowerBounds::new( + SimpleGraph::new(2, vec![(0, 1)]), + vec![1], + vec![0], + 0, + 1, + -5, + ); +} 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 d09689d25..3381ea720 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 @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_validates_capacity_shape() { @@ -72,7 +71,7 @@ fn test_undirected_two_commodity_integral_flow_creation() { assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 3); assert_eq!( - problem.dimensions(), + crate::solvers::cartesian_dimensions(&problem).unwrap(), vec![2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3] ); } diff --git a/src/unit_tests/models/misc/additional_key.rs b/src/unit_tests/models/misc/additional_key.rs index 8df337615..0a853e4e0 100644 --- a/src/unit_tests/models/misc/additional_key.rs +++ b/src/unit_tests/models/misc/additional_key.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; /// Instance 1: 6 attributes, cyclic FDs, 3 known keys. @@ -31,7 +30,10 @@ fn test_additional_key_creation() { assert_eq!(problem.num_dependencies(), 5); assert_eq!(problem.num_relation_attrs(), 6); assert_eq!(problem.num_known_keys(), 3); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2, 2, 2] + ); assert_eq!(::NAME, "AdditionalKey"); assert_eq!(::variant(), vec![]); // Data getters diff --git a/src/unit_tests/models/misc/betweenness.rs b/src/unit_tests/models/misc/betweenness.rs index ffd895343..cff1d9ba8 100644 --- a/src/unit_tests/models/misc/betweenness.rs +++ b/src/unit_tests/models/misc/betweenness.rs @@ -17,8 +17,11 @@ 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.num_variables(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 5] + ); + assert_eq!(problem.num_variables().unwrap(), 5); assert_eq!(::NAME, "Betweenness"); assert_eq!(::variant(), vec![]); } diff --git a/src/unit_tests/models/misc/bin_packing.rs b/src/unit_tests/models/misc/bin_packing.rs index b0a8e77eb..02d10d8c4 100644 --- a/src/unit_tests/models/misc/bin_packing.rs +++ b/src/unit_tests/models/misc/bin_packing.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -9,9 +8,17 @@ fn test_bin_packing_creation() { assert_eq!(problem.num_items(), 6); assert_eq!(problem.sizes(), &[6, 6, 5, 5, 4, 4]); assert_eq!(*problem.capacity(), 10); - assert_eq!(problem.dimensions().len(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 6 + ); // Each variable has domain {0, ..., 5} - assert!(problem.dimensions().iter().all(|&d| d == 6)); + assert!(crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .iter() + .all(|&d| d == 6)); } #[test] @@ -92,7 +99,10 @@ fn test_bin_packing_brute_force_small() { fn test_bin_packing_empty_items() { let problem = BinPacking::new(Vec::::new(), 10).unwrap(); assert_eq!(problem.num_items(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); let result = problem.evaluate(&vec![]).unwrap(); assert!(result.is_valid()); assert_eq!(result.unwrap(), 0); diff --git a/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs b/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs index a15ea8bd2..42bc0ceb3 100644 --- a/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs +++ b/src/unit_tests/models/misc/boyce_codd_normal_form_violation.rs @@ -38,8 +38,11 @@ fn test_bcnf_creation() { assert_eq!(problem.num_attributes(), 6); assert_eq!(problem.num_functional_deps(), 3); assert_eq!(problem.num_target_attributes(), 6); - assert_eq!(problem.num_variables(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!(problem.num_variables().unwrap(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!(problem.target_subset(), &[0, 1, 2, 3, 4, 5]); assert_eq!(problem.functional_deps().len(), 3); } diff --git a/src/unit_tests/models/misc/capacity_assignment.rs b/src/unit_tests/models/misc/capacity_assignment.rs index 09894567c..01f79f1fa 100644 --- a/src/unit_tests/models/misc/capacity_assignment.rs +++ b/src/unit_tests/models/misc/capacity_assignment.rs @@ -1,6 +1,5 @@ use super::CapacityAssignmentCreateSpec; use crate::models::misc::CapacityAssignment; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_validates_monotonicity() { @@ -32,7 +31,10 @@ fn test_capacity_assignment_basic_properties() { assert_eq!(problem.num_capacities(), 3); assert_eq!(problem.capacities(), &[1, 2, 3]); assert_eq!(problem.delay_budget(), 12); - assert_eq!(problem.dimensions(), vec![3, 3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3, 3] + ); assert_eq!(::NAME, "CapacityAssignment"); assert_eq!(::variant(), Vec::new()); } diff --git a/src/unit_tests/models/misc/closest_string.rs b/src/unit_tests/models/misc/closest_string.rs index fba543793..d0ef506b4 100644 --- a/src/unit_tests/models/misc/closest_string.rs +++ b/src/unit_tests/models/misc/closest_string.rs @@ -18,8 +18,11 @@ fn test_closest_string_creation() { assert_eq!(problem.num_strings(), 4); assert_eq!(problem.string_length(), 3); assert_eq!(problem.total_length(), 12); - assert_eq!(problem.dimensions(), vec![2, 2, 2]); - assert_eq!(problem.num_variables(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2] + ); + assert_eq!(problem.num_variables().unwrap(), 3); assert_eq!(::NAME, "ClosestString"); assert_eq!(::variant(), vec![]); } @@ -101,7 +104,10 @@ fn test_closest_string_larger_alphabet_smoke() { // must have radius at least 2; e.g., c = 00 achieves d(00,01)=1, // d(00,12)=2, d(00,20)=1, giving a max of 2. let problem = ClosestString::new(3, vec![vec![0, 1], vec![1, 2], vec![2, 0]]); - assert_eq!(problem.dimensions(), vec![3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3] + ); assert_eq!(problem.num_strings(), 3); assert_eq!(problem.string_length(), 2); let solver = BruteForce::new(); @@ -120,7 +126,10 @@ fn test_closest_string_serialization() { let restored: ClosestString = serde_json::from_value(json).unwrap(); assert_eq!(restored.alphabet_size(), problem.alphabet_size()); assert_eq!(restored.strings(), problem.strings()); - assert_eq!(restored.dimensions(), problem.dimensions()); + assert_eq!( + crate::solvers::cartesian_dimensions(&restored).unwrap(), + crate::solvers::cartesian_dimensions(&problem).unwrap() + ); assert_eq!( restored.evaluate(&vec![0, 0, 0]).unwrap(), problem.evaluate(&vec![0, 0, 0]).unwrap() diff --git a/src/unit_tests/models/misc/closest_substring.rs b/src/unit_tests/models/misc/closest_substring.rs index 1a98cfef5..47ff0ee22 100644 --- a/src/unit_tests/models/misc/closest_substring.rs +++ b/src/unit_tests/models/misc/closest_substring.rs @@ -4,8 +4,8 @@ use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; -fn issue_instance() -> ClosestSubstring { - // The #1033 canonical example: q = 2, ell = 3, three length-5 binary strings. +fn canonical_instance() -> ClosestSubstring { + // Canonical example: q = 2, ell = 3, three length-5 binary strings. ClosestSubstring::new( 2, vec![ @@ -20,24 +20,26 @@ fn issue_instance() -> ClosestSubstring { #[test] fn test_closest_substring_creation() { - let problem = issue_instance(); + let problem = canonical_instance(); assert_eq!(problem.alphabet_size(), 2); assert_eq!(problem.num_strings(), 3); 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]); - assert_eq!(problem.num_variables(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 3, 3, 3] + ); + assert_eq!(problem.num_variables().unwrap(), 6); assert_eq!(::NAME, "ClosestSubstring"); assert_eq!(::variant(), vec![]); } #[test] fn test_closest_substring_evaluate_at_optimum() { - let problem = issue_instance(); + let problem = canonical_instance(); // Center [0,1,0] with window picks (0, 1, 0): // s_1[0..3] = [0,0,0], d_H([0,1,0], [0,0,0]) = 1 // s_2[1..4] = [0,1,0], d_H = 0 @@ -51,7 +53,7 @@ fn test_closest_substring_evaluate_at_optimum() { #[test] fn test_closest_substring_evaluate_all_zero_windows() { - let problem = issue_instance(); + let problem = canonical_instance(); // c = [0,0,0], windows (0, 0, 0): // s_1[0..3] = [0,0,0] d = 0 // s_2[0..3] = [1,0,1] d = 2 @@ -65,7 +67,7 @@ fn test_closest_substring_evaluate_all_zero_windows() { #[test] fn test_closest_substring_evaluate_at_111_center() { - let problem = issue_instance(); + let problem = canonical_instance(); // Any center [1,1,1] has Hamming distance >= 1 to every length-3 binary // string that contains at least one 0. All windows of s_1, s_2, s_3 // contain at least one zero, so the radius is at least 1. @@ -79,7 +81,7 @@ fn test_closest_substring_evaluate_at_111_center() { #[test] fn test_closest_substring_evaluate_invalid_length() { - let problem = issue_instance(); + let problem = canonical_instance(); assert!(matches!( problem.evaluate(&vec![0, 0, 0]), Err(crate::traits::EvaluationError::InvalidConfiguration(_)) @@ -92,7 +94,7 @@ fn test_closest_substring_evaluate_invalid_length() { #[test] fn test_closest_substring_bruteforce_finds_optimum() { - let problem = issue_instance(); + let problem = canonical_instance(); let solver = BruteForce::new(); // 8 centers * 27 window combinations = 216 configurations; optimum is 1. assert_eq!( @@ -112,7 +114,7 @@ fn test_closest_substring_bruteforce_finds_optimum() { fn test_closest_substring_specializes_to_closest_string() { // When substring_length == string_length, each input string has exactly // one window (W_i = 1) and the problem reduces to ClosestString on the - // same instance. Use the #1032 canonical (4 binary strings of length 3), + // same instance. Use four binary strings of length 3, // whose optimum radius is 2. let problem = ClosestSubstring::new( 2, @@ -120,8 +122,10 @@ 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]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 1, 1, 1, 1] + ); let solver = BruteForce::new(); assert_eq!( problem @@ -161,13 +165,16 @@ fn test_closest_substring_rejects_out_of_alphabet_symbol() { #[test] fn test_closest_substring_serialization() { - let problem = issue_instance(); + let problem = canonical_instance(); let json = serde_json::to_value(&problem).unwrap(); let restored: ClosestSubstring = serde_json::from_value(json).unwrap(); assert_eq!(restored.alphabet_size(), problem.alphabet_size()); assert_eq!(restored.strings(), problem.strings()); assert_eq!(restored.substring_length(), problem.substring_length()); - assert_eq!(restored.dimensions(), problem.dimensions()); + assert_eq!( + crate::solvers::cartesian_dimensions(&restored).unwrap(), + crate::solvers::cartesian_dimensions(&problem).unwrap() + ); assert_eq!( restored.evaluate(&vec![0, 1, 0, 0, 1, 0]).unwrap(), problem.evaluate(&vec![0, 1, 0, 0, 1, 0]).unwrap() diff --git a/src/unit_tests/models/misc/clustering.rs b/src/unit_tests/models/misc/clustering.rs index 6fcaccfec..7991863aa 100644 --- a/src/unit_tests/models/misc/clustering.rs +++ b/src/unit_tests/models/misc/clustering.rs @@ -1,6 +1,5 @@ use crate::models::misc::Clustering; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; /// Helper: build the 6-element two-group instance from the issue. @@ -23,7 +22,10 @@ fn test_clustering_creation() { assert_eq!(problem.num_clusters(), 2); assert_eq!(problem.diameter_bound(), 1); assert_eq!(problem.distances().len(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); } #[test] diff --git a/src/unit_tests/models/misc/conjunctive_boolean_query.rs b/src/unit_tests/models/misc/conjunctive_boolean_query.rs index 165eb95a0..b8c670036 100644 --- a/src/unit_tests/models/misc/conjunctive_boolean_query.rs +++ b/src/unit_tests/models/misc/conjunctive_boolean_query.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; /// Helper to build the issue example instance. @@ -37,7 +36,10 @@ fn test_conjunctivebooleanquery_basic() { assert_eq!(problem.num_relations(), 2); assert_eq!(problem.num_variables(), 2); assert_eq!(problem.num_conjuncts(), 3); - assert_eq!(problem.dimensions(), vec![6, 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6, 6] + ); assert_eq!( ::NAME, "ConjunctiveBooleanQuery" @@ -179,3 +181,87 @@ fn test_conjunctivebooleanquery_create_spec_rejects_invalid_relation_index() { assert!(result.is_err()); } + +#[test] +#[should_panic(expected = "expected arity")] +fn new_rejects_invalid_tuple_arity() { + ConjunctiveBooleanQuery::new( + 2, + vec![Relation { + arity: 1, + tuples: vec![vec![0, 1]], + }], + 1, + vec![], + ); +} + +#[test] +#[should_panic(expected = "must be < 2")] +fn new_rejects_invalid_tuple_domain() { + ConjunctiveBooleanQuery::new( + 2, + vec![Relation { + arity: 1, + tuples: vec![vec![2]], + }], + 1, + vec![], + ); +} + +#[test] +#[should_panic(expected = "relation index")] +fn new_rejects_invalid_relation_index() { + ConjunctiveBooleanQuery::new( + 2, + vec![Relation { + arity: 1, + tuples: vec![vec![0]], + }], + 1, + vec![(1, vec![QueryArg::Variable(0)])], + ); +} + +#[test] +#[should_panic(expected = "expected arity")] +fn new_rejects_invalid_argument_arity() { + ConjunctiveBooleanQuery::new( + 2, + vec![Relation { + arity: 1, + tuples: vec![vec![0]], + }], + 1, + vec![(0, vec![])], + ); +} + +#[test] +#[should_panic(expected = "num_variables")] +fn new_rejects_invalid_variable_index() { + ConjunctiveBooleanQuery::new( + 2, + vec![Relation { + arity: 1, + tuples: vec![vec![0]], + }], + 1, + vec![(0, vec![QueryArg::Variable(1)])], + ); +} + +#[test] +#[should_panic(expected = "domain_size")] +fn new_rejects_invalid_constant_domain() { + ConjunctiveBooleanQuery::new( + 2, + vec![Relation { + arity: 1, + tuples: vec![vec![0]], + }], + 1, + vec![(0, vec![QueryArg::Constant(2)])], + ); +} diff --git a/src/unit_tests/models/misc/conjunctive_query_foldability.rs b/src/unit_tests/models/misc/conjunctive_query_foldability.rs index 29fc69f39..ad91e74a6 100644 --- a/src/unit_tests/models/misc/conjunctive_query_foldability.rs +++ b/src/unit_tests/models/misc/conjunctive_query_foldability.rs @@ -59,8 +59,11 @@ fn test_conjunctive_query_foldability_creation() { let problem = yes_instance(); // dims = [domain_size + num_distinguished + num_undistinguished; num_undistinguished] // = [0 + 1 + 3; 3] = [4, 4, 4] - assert_eq!(problem.dimensions(), vec![4, 4, 4]); - assert_eq!(problem.num_variables(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 4, 4] + ); + assert_eq!(problem.num_variables().unwrap(), 3); assert_eq!( ::NAME, "ConjunctiveQueryFoldability" @@ -117,7 +120,10 @@ fn test_conjunctive_query_foldability_serialization() { let problem = yes_instance(); let json = serde_json::to_value(&problem).unwrap(); let restored: ConjunctiveQueryFoldability = serde_json::from_value(json).unwrap(); - assert_eq!(restored.dimensions(), problem.dimensions()); + assert_eq!( + crate::solvers::cartesian_dimensions(&restored).unwrap(), + crate::solvers::cartesian_dimensions(&problem).unwrap() + ); assert_eq!(restored.domain_size(), problem.domain_size()); assert_eq!(restored.num_distinguished(), problem.num_distinguished()); assert_eq!( @@ -174,7 +180,10 @@ fn test_conjunctive_query_foldability_with_constants() { ], ); // dims = [1+1+1; 1] = [3] - assert_eq!(problem.dimensions(), vec![3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3] + ); // σ(u→x): index for X(0) = domain_size + 0 = 1 assert!(problem.evaluate(&vec![1]).unwrap()); // σ(u→c0): index for C(0) = 0 → R(c0, c0) ∧ R(c0, x) ≠ Q2 @@ -286,7 +295,10 @@ fn test_conjunctive_query_foldability_no_undistinguished() { vec![(0, vec![X(0), X(0)])], vec![(0, vec![X(0), X(0)])], ); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } 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 1aadc7b88..b6af910da 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,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_known_values() { @@ -60,7 +59,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); @@ -79,7 +78,7 @@ fn test_cdft_creation_and_getters() { fn test_cdft_dims_repeat_attribute_domains_for_each_object() { let problem = issue_yes_instance(); assert_eq!( - problem.dimensions(), + crate::solvers::cartesian_dimensions(&problem).unwrap(), vec![2, 3, 2, 2, 3, 2, 2, 3, 2, 2, 3, 2, 2, 3, 2, 2, 3, 2] ); } diff --git a/src/unit_tests/models/misc/cosine_product_integration.rs b/src/unit_tests/models/misc/cosine_product_integration.rs index 5787b6f66..ecd36f60f 100644 --- a/src/unit_tests/models/misc/cosine_product_integration.rs +++ b/src/unit_tests/models/misc/cosine_product_integration.rs @@ -1,6 +1,5 @@ use crate::models::misc::CosineProductIntegration; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -13,7 +12,10 @@ fn test_cosine_product_integration_creation() { #[test] fn test_cosine_product_integration_dims() { let p = CosineProductIntegration::new(vec![1, 2, 3]); - assert_eq!(p.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); } #[test] diff --git a/src/unit_tests/models/misc/cyclic_ordering.rs b/src/unit_tests/models/misc/cyclic_ordering.rs index 5f8bb0b76..20eaaaee4 100644 --- a/src/unit_tests/models/misc/cyclic_ordering.rs +++ b/src/unit_tests/models/misc/cyclic_ordering.rs @@ -14,8 +14,11 @@ 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.num_variables(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 5] + ); + assert_eq!(problem.num_variables().unwrap(), 5); assert_eq!(::NAME, "CyclicOrdering"); assert_eq!(::variant(), vec![]); } diff --git a/src/unit_tests/models/misc/dynamic_storage_allocation.rs b/src/unit_tests/models/misc/dynamic_storage_allocation.rs index 42fd55c8c..79f463dc7 100644 --- a/src/unit_tests/models/misc/dynamic_storage_allocation.rs +++ b/src/unit_tests/models/misc/dynamic_storage_allocation.rs @@ -20,8 +20,11 @@ fn test_dynamic_storage_allocation_basic() { assert_eq!(problem.items().len(), 5); // dims: D - s(a) + 1 for each item // sizes are 2, 3, 1, 3, 2 => dims are 5, 4, 6, 4, 5 - assert_eq!(problem.dimensions(), vec![5, 4, 6, 4, 5]); - assert_eq!(problem.num_variables(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 4, 6, 4, 5] + ); + assert_eq!(problem.num_variables().unwrap(), 5); assert_eq!( ::NAME, "DynamicStorageAllocation" diff --git a/src/unit_tests/models/misc/ensemble_computation.rs b/src/unit_tests/models/misc/ensemble_computation.rs index 890207764..89e53816c 100644 --- a/src/unit_tests/models/misc/ensemble_computation.rs +++ b/src/unit_tests/models/misc/ensemble_computation.rs @@ -15,8 +15,11 @@ fn test_ensemble_computation_creation() { assert_eq!(problem.universe_size(), 4); assert_eq!(problem.num_subsets(), 2); assert_eq!(problem.budget(), 4); - assert_eq!(problem.num_variables(), 8); - assert_eq!(problem.dimensions(), vec![8; 8]); + assert_eq!(problem.num_variables().unwrap(), 8); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![8; 8] + ); assert_eq!( ::NAME, "EnsembleComputation" diff --git a/src/unit_tests/models/misc/expected_retrieval_cost.rs b/src/unit_tests/models/misc/expected_retrieval_cost.rs index 9cd84b62c..f2172769a 100644 --- a/src/unit_tests/models/misc/expected_retrieval_cost.rs +++ b/src/unit_tests/models/misc/expected_retrieval_cost.rs @@ -16,8 +16,11 @@ fn test_expected_retrieval_cost_basic_accessors() { assert_eq!(problem.num_records(), 6); assert_eq!(problem.num_sectors(), 3); assert_eq!(problem.probabilities(), &[0.2, 0.15, 0.15, 0.2, 0.1, 0.2]); - assert_eq!(problem.dimensions(), vec![3; 6]); - assert_eq!(problem.num_variables(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 6] + ); + assert_eq!(problem.num_variables().unwrap(), 6); } #[test] diff --git a/src/unit_tests/models/misc/factoring.rs b/src/unit_tests/models/misc/factoring.rs index 61bf2f201..3091fa794 100644 --- a/src/unit_tests/models/misc/factoring.rs +++ b/src/unit_tests/models/misc/factoring.rs @@ -1,9 +1,9 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use num_bigint::BigUint; -include!("../../jl_helpers.rs"); #[test] fn test_factoring_creation() { @@ -11,7 +11,7 @@ fn test_factoring_creation() { assert_eq!(problem.m(), 3); assert_eq!(problem.n(), 3); assert_eq!(problem.target(), &BigUint::from(15u32)); - assert_eq!(problem.num_variables(), 6); + assert_eq!(problem.num_variables().unwrap(), 6); } #[test] @@ -136,7 +136,7 @@ fn test_parameter_getters() { fn test_factoring_paper_example() { // Paper: N=15, m=2 bits, n=3 bits, p=3, q=5 let problem = Factoring::with_factor_bits(15, 2, 3); - assert_eq!(problem.num_variables(), 5); + assert_eq!(problem.num_variables().unwrap(), 5); // p=3 -> bits [1,1], q=5 -> bits [1,0,1] let config = (BigUint::from(3u32), BigUint::from(5u32)); diff --git a/src/unit_tests/models/misc/feasible_register_assignment.rs b/src/unit_tests/models/misc/feasible_register_assignment.rs index 08b10831f..d21b23ef5 100644 --- a/src/unit_tests/models/misc/feasible_register_assignment.rs +++ b/src/unit_tests/models/misc/feasible_register_assignment.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -13,7 +12,10 @@ fn test_feasible_register_assignment_basic() { assert_eq!(problem.num_same_register_pairs(), 3); assert_eq!(problem.arcs(), &[(0, 1), (0, 2), (1, 3)]); assert_eq!(problem.assignment(), &[0, 1, 0, 0]); - assert_eq!(problem.dimensions(), vec![4; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 4] + ); assert_eq!( ::NAME, "FeasibleRegisterAssignment" @@ -143,7 +145,10 @@ fn test_feasible_register_assignment_serialization() { fn test_feasible_register_assignment_empty() { let problem = FeasibleRegisterAssignment::new(0, vec![], 0, vec![]); assert_eq!(problem.num_vertices(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } @@ -170,3 +175,15 @@ fn test_feasible_register_assignment_same_register_pair_count() { let problem = FeasibleRegisterAssignment::new(5, vec![], 3, vec![0, 1, 0, 2, 0]); assert_eq!(problem.num_same_register_pairs(), 3); } + +#[test] +fn deserialize_rejects_invalid_indices_before_building_adjacency() { + for (arcs, assignment) in [(vec![(0, 2)], vec![0, 0]), (vec![(0, 1)], vec![0, 1])] { + assert!( + serde_json::from_value::(serde_json::json!({ + "num_vertices": 2, "arcs": arcs, "num_registers": 1, "assignment": assignment + })) + .is_err() + ); + } +} diff --git a/src/unit_tests/models/misc/flow_shop_scheduling.rs b/src/unit_tests/models/misc/flow_shop_scheduling.rs index a3b327622..87d2f0788 100644 --- a/src/unit_tests/models/misc/flow_shop_scheduling.rs +++ b/src/unit_tests/models/misc/flow_shop_scheduling.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -19,9 +18,17 @@ fn test_flow_shop_scheduling_creation() { assert_eq!(problem.num_jobs(), 5); assert_eq!(problem.num_processors(), 3); assert_eq!(problem.deadline(), 25); - assert_eq!(problem.dimensions().len(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 5 + ); // Lehmer code encoding: dims = [5, 4, 3, 2, 1] - assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 4, 3, 2, 1] + ); } #[test] @@ -149,7 +156,10 @@ fn test_flow_shop_scheduling_brute_force_unsatisfiable() { fn test_flow_shop_scheduling_empty() { let problem = FlowShopScheduling::new(3, vec![], 0); assert_eq!(problem.num_jobs(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); // Empty config should be satisfying (no jobs to schedule) assert!(problem.evaluate(&vec![]).unwrap()); } diff --git a/src/unit_tests/models/misc/grouping_by_swapping.rs b/src/unit_tests/models/misc/grouping_by_swapping.rs index 7b56a31d8..91c70ffb5 100644 --- a/src/unit_tests/models/misc/grouping_by_swapping.rs +++ b/src/unit_tests/models/misc/grouping_by_swapping.rs @@ -22,8 +22,11 @@ fn test_grouping_by_swapping_basic() { assert_eq!(problem.string(), &[0, 1, 2, 0, 1, 2]); assert_eq!(problem.budget(), 5); assert_eq!(problem.string_len(), 6); - assert_eq!(problem.num_variables(), 5); - assert_eq!(problem.dimensions(), vec![6; 5]); + assert_eq!(problem.num_variables().unwrap(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6; 5] + ); assert_eq!(::NAME, "GroupingBySwapping"); assert_eq!(::variant(), vec![]); diff --git a/src/unit_tests/models/misc/integer_expression_membership.rs b/src/unit_tests/models/misc/integer_expression_membership.rs index 781cdab77..ab54ac0e0 100644 --- a/src/unit_tests/models/misc/integer_expression_membership.rs +++ b/src/unit_tests/models/misc/integer_expression_membership.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; /// Helper: build expression (1 ∪ 4) + (3 ∪ 6) + (2 ∪ 5) @@ -32,7 +31,10 @@ fn test_integer_expression_membership_creation() { assert_eq!(problem.num_atoms(), 6); assert_eq!(problem.expression_size(), 11); // 6 atoms + 3 unions + 2 sums assert_eq!(problem.expression_depth(), 3); - assert_eq!(problem.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2] + ); assert_eq!( ::NAME, "IntegerExpressionMembership" @@ -116,7 +118,10 @@ fn test_integer_expression_membership_single_atom() { let expr = IntExpr::Atom(42); let problem = IntegerExpressionMembership::new(expr, 42); assert_eq!(problem.num_union_nodes(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); // empty config, atom == target } @@ -133,7 +138,10 @@ fn test_integer_expression_membership_simple_union() { let expr = IntExpr::Union(Box::new(IntExpr::Atom(3)), Box::new(IntExpr::Atom(7))); let problem = IntegerExpressionMembership::new(expr, 7); assert_eq!(problem.num_union_nodes(), 1); - assert_eq!(problem.dimensions(), vec![2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2] + ); assert!(!problem.evaluate(&vec![false]).unwrap()); // 3 ≠ 7 assert!(problem.evaluate(&vec![true]).unwrap()); // 7 == 7 } diff --git a/src/unit_tests/models/misc/job_shop_scheduling.rs b/src/unit_tests/models/misc/job_shop_scheduling.rs index e190677ca..77be4e447 100644 --- a/src/unit_tests/models/misc/job_shop_scheduling.rs +++ b/src/unit_tests/models/misc/job_shop_scheduling.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -28,7 +27,7 @@ fn test_job_shop_scheduling_creation_and_dims() { assert_eq!(problem.num_jobs(), 5); assert_eq!(problem.num_tasks(), 12); assert_eq!( - problem.dimensions(), + crate::solvers::cartesian_dimensions(&problem).unwrap(), vec![6, 5, 4, 3, 2, 1, 6, 5, 4, 3, 2, 1] ); } diff --git a/src/unit_tests/models/misc/knapsack.rs b/src/unit_tests/models/misc/knapsack.rs index b58568b2f..898cf519a 100644 --- a/src/unit_tests/models/misc/knapsack.rs +++ b/src/unit_tests/models/misc/knapsack.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_item_weights() { @@ -21,7 +20,10 @@ fn test_knapsack_basic() { assert_eq!(problem.weights(), &[2, 3, 4, 5]); assert_eq!(problem.values(), &[3, 4, 5, 7]); assert_eq!(problem.capacity(), 7); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!(::NAME, "Knapsack"); assert_eq!(::variant(), vec![]); } @@ -97,7 +99,10 @@ fn test_knapsack_evaluate_invalid_variable_value() { fn test_knapsack_empty_instance() { let problem = Knapsack::new(vec![], vec![], 10); assert_eq!(problem.num_items(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Max(Some(0))); } 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..5e2906eb4 100644 --- a/src/unit_tests/models/misc/kth_largest_m_tuple.rs +++ b/src/unit_tests/models/misc/kth_largest_m_tuple.rs @@ -31,9 +31,12 @@ fn test_kth_largest_m_tuple_creation() { assert_eq!(p.k(), 14); assert_eq!(p.bound(), 12); assert_eq!(p.num_sets(), 3); - assert_eq!(p.total_tuples(), 18); - assert_eq!(p.dimensions(), Vec::::new()); - assert_eq!(p.num_variables(), 0); + assert_eq!(p.num_elements(), 8); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + Vec::::new() + ); + assert_eq!(p.num_variables().unwrap(), 0); assert_eq!(::NAME, "KthLargestMTuple"); assert_eq!(::variant(), vec![]); } @@ -136,7 +139,7 @@ fn test_kth_largest_m_tuple_all_qualify() { p.evaluate(&solver.solve(&p).unwrap().unwrap()).unwrap(), Or(true) ); - assert_eq!(p.total_tuples(), 1); + assert_eq!(p.num_elements(), 2); } #[test] @@ -169,8 +172,11 @@ 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(); +fn tuple_product_does_not_restrict_parameters_or_evaluation() { + let p = KthLargestMTuple::new(vec![vec![1, 2]; 64], 1, 1); + assert_eq!(p.num_elements(), 128); + assert_eq!(p.evaluate(&()).unwrap(), Or(true)); + let restored: KthLargestMTuple = + serde_json::from_value(serde_json::to_value(&p).unwrap()).unwrap(); + assert_eq!(p.parameters(), restored.parameters()); } diff --git a/src/unit_tests/models/misc/longest_common_subsequence.rs b/src/unit_tests/models/misc/longest_common_subsequence.rs index 913a300db..76c5be2f7 100644 --- a/src/unit_tests/models/misc/longest_common_subsequence.rs +++ b/src/unit_tests/models/misc/longest_common_subsequence.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Max; @@ -35,7 +34,10 @@ fn test_lcs_basic() { assert_eq!(problem.sum_squared_lengths(), 216); assert_eq!(problem.sum_triangular_lengths(), 126); assert_eq!(problem.num_transitions(), 5); - assert_eq!(problem.dimensions(), vec![3; 6]); // alphabet_size + 1 = 3, max_length = 6 + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 6] + ); // alphabet_size + 1 = 3, max_length = 6 assert_eq!( ::NAME, "LongestCommonSubsequence" @@ -156,8 +158,11 @@ fn test_lcs_empty_string_max_length_zero() { // When all strings are empty or any string is empty, max_length = 0 let problem = LongestCommonSubsequence::new(2, vec![vec![], vec![0, 1]]); assert_eq!(problem.max_length(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); // empty config space - // Empty config is the only valid config; LCS length is 0 + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); // empty config space + // Empty config is the only valid config; LCS length is 0 assert_eq!(problem.evaluate(&vec![]).unwrap(), Max(Some(0))); } diff --git a/src/unit_tests/models/misc/maximum_likelihood_ranking.rs b/src/unit_tests/models/misc/maximum_likelihood_ranking.rs index 8d06afd26..8e0693a20 100644 --- a/src/unit_tests/models/misc/maximum_likelihood_ranking.rs +++ b/src/unit_tests/models/misc/maximum_likelihood_ranking.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -15,7 +14,10 @@ fn test_maximum_likelihood_ranking_creation() { assert_eq!(problem.num_items(), 4); assert_eq!(problem.matrix(), &matrix); assert_eq!(problem.comparison_count(), 5); - assert_eq!(problem.dimensions(), vec![4; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 4] + ); assert_eq!( ::NAME, "MaximumLikelihoodRanking" @@ -148,7 +150,10 @@ fn test_maximum_likelihood_ranking_single_item() { let problem = MaximumLikelihoodRanking::new(vec![vec![0]]); assert_eq!(problem.num_items(), 1); assert_eq!(problem.comparison_count(), 0); - assert_eq!(problem.dimensions(), vec![1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1] + ); assert_eq!(problem.evaluate(&vec![0]).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/models/misc/minimum_axiom_set.rs b/src/unit_tests/models/misc/minimum_axiom_set.rs index f3f746808..b05b74527 100644 --- a/src/unit_tests/models/misc/minimum_axiom_set.rs +++ b/src/unit_tests/models/misc/minimum_axiom_set.rs @@ -28,8 +28,11 @@ fn test_minimum_axiom_set_creation() { assert_eq!(problem.num_true_sentences(), 8); assert_eq!(problem.num_implications(), 8); assert_eq!(problem.true_sentences(), &[0, 1, 2, 3, 4, 5, 6, 7]); - assert_eq!(problem.dimensions(), vec![2; 8]); - assert_eq!(problem.num_variables(), 8); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); + assert_eq!(problem.num_variables().unwrap(), 8); } #[test] @@ -125,7 +128,10 @@ fn test_minimum_axiom_set_partial_true_sentences() { let problem = MinimumAxiomSet::new(5, vec![0, 1, 2], vec![(vec![0], 1), (vec![1], 2)]); assert_eq!(problem.num_sentences(), 5); assert_eq!(problem.num_true_sentences(), 3); - assert_eq!(problem.dimensions(), vec![2; 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 3] + ); // Select sentence 0 only let result = problem.evaluate(&vec![true, false, false]).unwrap(); @@ -173,3 +179,20 @@ fn test_minimum_axiom_set_paper_example() { assert!(metric.is_valid()); assert_eq!(metric.unwrap(), 2); } + +#[test] +fn deserialize_rejects_invalid_sentence_references() { + use serde_json::json; + let valid = json!({"num_sentences": 2, "true_sentences": [0, 1], "implications": [[[0], 1]]}); + for (field, value, message) in [ + ("true_sentences", json!([2]), "True sentence index"), + ("true_sentences", json!([0, 0]), "Duplicate true sentence"), + ("implications", json!([[[2], 1]]), "antecedent"), + ("implications", json!([[[0], 2]]), "consequent"), + ] { + let mut input = valid.clone(); + input[field] = value; + let error = serde_json::from_value::(input).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} diff --git a/src/unit_tests/models/misc/minimum_code_generation_one_register.rs b/src/unit_tests/models/misc/minimum_code_generation_one_register.rs index 78337e23a..43889e36e 100644 --- a/src/unit_tests/models/misc/minimum_code_generation_one_register.rs +++ b/src/unit_tests/models/misc/minimum_code_generation_one_register.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -24,7 +23,10 @@ fn test_minimum_code_generation_one_register_creation() { assert_eq!(problem.num_edges(), 8); assert_eq!(problem.num_leaves(), 3); assert_eq!(problem.num_internal(), 4); - assert_eq!(problem.dimensions(), vec![4; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 4] + ); assert_eq!( ::NAME, "MinimumCodeGenerationOneRegister" @@ -260,3 +262,21 @@ fn test_minimum_code_generation_one_register_lost_value() { let result = problem.simulate(&config).unwrap(); assert!(result.is_some()); } + +#[test] +fn deserialize_rejects_invalid_expression_graph() { + use serde_json::json; + let valid = json!({"num_vertices": 4, "edges": [[0, 1], [0, 2]], "num_leaves": 3}); + for (field, value, message) in [ + ("num_leaves", json!(5), "exceeds num_vertices"), + ("num_leaves", json!(2), "actual leaf count"), + ("edges", json!([[0, 4]]), "out of bounds"), + ("edges", json!([[0, 0]]), "Self-loop"), + ("edges", json!([[0, 1], [0, 2], [0, 3]]), "out-degree"), + ] { + let mut input = valid.clone(); + input[field] = value; + let error = serde_json::from_value::(input).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} diff --git a/src/unit_tests/models/misc/minimum_code_generation_parallel_assignments.rs b/src/unit_tests/models/misc/minimum_code_generation_parallel_assignments.rs index 541ed2e59..e6f60edf0 100644 --- a/src/unit_tests/models/misc/minimum_code_generation_parallel_assignments.rs +++ b/src/unit_tests/models/misc/minimum_code_generation_parallel_assignments.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -10,7 +9,10 @@ fn test_minimum_code_generation_parallel_assignments_creation() { assert_eq!(problem.num_variables(), 4); assert_eq!(problem.num_assignments(), 4); assert_eq!(problem.assignments(), &assignments); - assert_eq!(problem.dimensions(), vec![4; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 4] + ); assert_eq!( ::NAME, "MinimumCodeGenerationParallelAssignments" 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 c852009b8..6025d498a 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 @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -16,7 +15,10 @@ 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!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 3] + ); assert_eq!( ::NAME, "MinimumCodeGenerationUnlimitedRegisters" @@ -208,3 +210,24 @@ fn test_minimum_code_generation_unlimited_registers_paper_example() { let witness = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.simulate(&witness).unwrap(), Some(4)); } + +#[test] +fn deserialize_rejects_invalid_operand_arcs() { + use serde_json::json; + let valid = json!({"num_vertices": 4, "left_arcs": [[0, 1]], "right_arcs": [[0, 2]]}); + for (field, value, message) in [ + ("left_arcs", json!([[0, 4]]), "Left arc"), + ("right_arcs", json!([[4, 0]]), "Right arc"), + ("left_arcs", json!([[0, 0]]), "Self-loop"), + ("right_arcs", json!([[0, 0]]), "Self-loop"), + ("left_arcs", json!([[0, 1], [0, 3]]), "out-degree"), + ("left_arcs", json!([]), "Unary vertex"), + ("right_arcs", json!([[1, 2], [1, 3]]), "Binary vertex"), + ] { + let mut input = valid.clone(); + input[field] = value; + let error = + serde_json::from_value::(input).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} diff --git a/src/unit_tests/models/misc/minimum_decision_tree.rs b/src/unit_tests/models/misc/minimum_decision_tree.rs index 561066d05..31f2e22b2 100644 --- a/src/unit_tests/models/misc/minimum_decision_tree.rs +++ b/src/unit_tests/models/misc/minimum_decision_tree.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_indistinguishable_objects() { @@ -33,8 +32,16 @@ fn test_minimum_decision_tree_creation() { let problem = issue_instance(); assert_eq!(problem.num_objects(), 4); assert_eq!(problem.num_tests(), 3); - assert_eq!(problem.dimensions().len(), 7); // 2^(4-1) - 1 = 7 - assert_eq!(problem.dimensions(), vec![4; 7]); // 3 tests + 1 sentinel = 4 choices + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 7 + ); // 2^(4-1) - 1 = 7 + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 7] + ); // 3 tests + 1 sentinel = 4 choices } #[test] @@ -111,8 +118,13 @@ fn test_minimum_decision_tree_two_objects() { 2, 1, ); - assert_eq!(problem.dimensions().len(), 1); // 2^(2-1) - 1 = 1 slot - // Test at root, both objects go to leaves at depth 1 + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 1 + ); // 2^(2-1) - 1 = 1 slot + // Test at root, both objects go to leaves at depth 1 assert_eq!(problem.evaluate(&vec![0]).unwrap(), Min(Some(2))); // depth 1 + depth 1 assert_eq!(problem.evaluate(&vec![1]).unwrap(), Min(None)); // sentinel=1 is leaf at root, both objects at same leaf } 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..b583a0665 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 @@ -25,8 +25,11 @@ fn test_minimum_discrete_planar_inverse_kinematics_creation() { assert_eq!(problem.target_point(), (2.0, 1.0)); assert_eq!(problem.orientation_samples().len(), 2); assert_eq!(problem.allowed_pairs().len(), 1); - assert_eq!(problem.dimensions(), vec![2, 2]); - assert_eq!(problem.num_variables(), 2); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2] + ); + assert_eq!(problem.num_variables().unwrap(), 2); assert_eq!(problem.num_orientation_samples(), 4); } @@ -116,7 +119,10 @@ fn test_minimum_discrete_planar_inverse_kinematics_serialization() { problem.orientation_samples() ); assert_eq!(restored.allowed_pairs(), problem.allowed_pairs()); - assert_eq!(restored.dimensions(), problem.dimensions()); + assert_eq!( + crate::solvers::cartesian_dimensions(&restored).unwrap(), + crate::solvers::cartesian_dimensions(&problem).unwrap() + ); } #[test] diff --git a/src/unit_tests/models/misc/minimum_disjunctive_normal_form.rs b/src/unit_tests/models/misc/minimum_disjunctive_normal_form.rs index 75c7388ee..6992cfbd8 100644 --- a/src/unit_tests/models/misc/minimum_disjunctive_normal_form.rs +++ b/src/unit_tests/models/misc/minimum_disjunctive_normal_form.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -15,7 +14,10 @@ fn test_minimum_dnf_creation() { assert_eq!(problem.num_variables(), 3); assert_eq!(problem.minterms().len(), 6); assert_eq!(problem.num_prime_implicants(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); } #[test] @@ -144,3 +146,14 @@ fn test_minimum_dnf_wrong_config_length() { fn test_minimum_dnf_all_false() { MinimumDisjunctiveNormalForm::new(2, vec![false, false, false, false]); } + +#[test] +fn deserialize_rebuilds_prime_implicants() { + let model: MinimumDisjunctiveNormalForm = serde_json::from_value(serde_json::json!({ + "num_variables": 2, "truth_table": [false, true, true, false], + "prime_implicants": [], "minterms": [99] + })) + .unwrap(); + assert_eq!(model.minterms(), &[1, 2]); + assert_eq!(model.num_prime_implicants(), 2); +} diff --git a/src/unit_tests/models/misc/minimum_external_macro_data_compression.rs b/src/unit_tests/models/misc/minimum_external_macro_data_compression.rs index 13b50275b..dbb6a86b5 100644 --- a/src/unit_tests/models/misc/minimum_external_macro_data_compression.rs +++ b/src/unit_tests/models/misc/minimum_external_macro_data_compression.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -20,7 +19,7 @@ fn test_minimum_external_macro_data_compression_creation() { vec![] ); // dims: 6 D-slots (domain 4) + 6 C-slots (domain 4 + 6*7/2 = 25) - let dims = problem.dimensions(); + let dims = crate::solvers::cartesian_dimensions(&problem).unwrap(); assert_eq!(dims.len(), 12); assert_eq!(dims[0], 4); // alphabet_size + 1 assert_eq!(dims[6], 25); // alphabet_size + 1 + 6*7/2 @@ -96,7 +95,10 @@ fn test_minimum_external_macro_data_compression_evaluate_pointer_out_of_range() #[test] fn test_minimum_external_macro_data_compression_empty_string() { let problem = MinimumExternalMacroDataCompression::new(2, vec![], 2); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/models/misc/minimum_fault_detection_test_set.rs b/src/unit_tests/models/misc/minimum_fault_detection_test_set.rs index 50b725e11..27d755868 100644 --- a/src/unit_tests/models/misc/minimum_fault_detection_test_set.rs +++ b/src/unit_tests/models/misc/minimum_fault_detection_test_set.rs @@ -35,8 +35,11 @@ fn test_minimum_fault_detection_test_set_creation() { assert_eq!(problem.num_inputs(), 2); assert_eq!(problem.num_outputs(), 2); // 2 inputs * 2 outputs = 4 pairs - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!( ::NAME, "MinimumFaultDetectionTestSet" @@ -193,3 +196,15 @@ fn test_minimum_fault_detection_test_set_paper_example() { vec![vec![true, false], vec![false, true]] ); } + +#[test] +fn deserialize_rejects_invalid_vertices_before_building_coverage() { + for (arcs, inputs) in [(vec![(0, 2)], vec![0]), (vec![(0, 1)], vec![2])] { + assert!( + serde_json::from_value::(serde_json::json!({ + "num_vertices": 2, "arcs": arcs, "inputs": inputs, "outputs": [1] + })) + .is_err() + ); + } +} diff --git a/src/unit_tests/models/misc/minimum_internal_macro_data_compression.rs b/src/unit_tests/models/misc/minimum_internal_macro_data_compression.rs index 82be1e6e6..25c03c2c5 100644 --- a/src/unit_tests/models/misc/minimum_internal_macro_data_compression.rs +++ b/src/unit_tests/models/misc/minimum_internal_macro_data_compression.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -20,7 +19,7 @@ fn test_minimum_internal_macro_data_compression_creation() { vec![] ); // dims: 9 slots, domain = 3 + 9 + 1 = 13 - let dims = problem.dimensions(); + let dims = crate::solvers::cartesian_dimensions(&problem).unwrap(); assert_eq!(dims.len(), 9); assert!(dims.iter().all(|&d| d == 13)); } @@ -88,7 +87,10 @@ fn test_minimum_internal_macro_data_compression_evaluate_pointer_forward_ref() { #[test] fn test_minimum_internal_macro_data_compression_empty_string() { let problem = MinimumInternalMacroDataCompression::new(2, vec![], 2); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/models/misc/minimum_register_sufficiency_for_loops.rs b/src/unit_tests/models/misc/minimum_register_sufficiency_for_loops.rs index 1c07a636d..051e79db3 100644 --- a/src/unit_tests/models/misc/minimum_register_sufficiency_for_loops.rs +++ b/src/unit_tests/models/misc/minimum_register_sufficiency_for_loops.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -10,7 +9,10 @@ fn test_creation() { assert_eq!(problem.loop_length(), 6); assert_eq!(problem.num_variables(), 3); assert_eq!(problem.variables(), &[(0, 3), (2, 3), (4, 3)]); - assert_eq!(problem.dimensions(), vec![3, 3, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3, 3, 3] + ); } #[test] diff --git a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs index 635e66453..c9cab81d3 100644 --- a/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs +++ b/src/unit_tests/models/misc/minimum_tardiness_sequencing.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::One; @@ -17,7 +16,10 @@ fn test_minimum_tardiness_sequencing_basic() { assert_eq!(problem.deadlines(), &[5, 5, 5, 3, 3]); assert_eq!(problem.precedences(), &[(0, 3), (1, 3), (1, 4), (2, 4)]); assert_eq!(problem.num_precedences(), 4); - assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 4, 3, 2, 1] + ); assert_eq!( as Problem>::NAME, "MinimumTardinessSequencing" @@ -126,14 +128,20 @@ fn test_minimum_tardiness_sequencing_serialization() { fn test_minimum_tardiness_sequencing_empty() { let problem = MinimumTardinessSequencing::::new(0, vec![], vec![]); assert_eq!(problem.num_tasks(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Min(Some(0))); } #[test] fn test_minimum_tardiness_sequencing_single_task() { let problem = MinimumTardinessSequencing::::new(1, vec![1], vec![]); - assert_eq!(problem.dimensions(), vec![1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1] + ); assert_eq!(problem.evaluate(&vec![0]).unwrap(), Min(Some(0))); let problem_tardy = MinimumTardinessSequencing::::new(1, vec![0], vec![]); diff --git a/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs b/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs index 5cbe85f53..35d1e7850 100644 --- a/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs +++ b/src/unit_tests/models/misc/minimum_weight_and_or_graph.rs @@ -38,8 +38,11 @@ fn test_minimum_weight_and_or_graph_creation() { assert_eq!(problem.source(), 0); assert_eq!(problem.gate_types().len(), 7); assert_eq!(problem.arc_weights().len(), 6); - assert_eq!(problem.num_variables(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!(problem.num_variables().unwrap(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!( ::NAME, "MinimumWeightAndOrGraph" @@ -204,3 +207,16 @@ fn test_minimum_weight_and_or_graph_paper_example() { vec![true, true, false, true, false, true] ); } + +#[test] +fn deserialize_rejects_invalid_graph_before_building_outgoing_arcs() { + for (arcs, source) in [(vec![(2, 1)], 0), (vec![(0, 1)], 2)] { + assert!( + serde_json::from_value::(serde_json::json!({ + "num_vertices": 2, "arcs": arcs, "source": source, + "gate_types": [true, null], "arc_weights": [1] + })) + .is_err() + ); + } +} diff --git a/src/unit_tests/models/misc/multiprocessor_scheduling.rs b/src/unit_tests/models/misc/multiprocessor_scheduling.rs index a8581588c..de7d12807 100644 --- a/src/unit_tests/models/misc/multiprocessor_scheduling.rs +++ b/src/unit_tests/models/misc/multiprocessor_scheduling.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_zero_processors() { @@ -28,7 +27,10 @@ fn test_multiprocessor_scheduling_basic() { assert_eq!(problem.num_processors(), 2); assert_eq!(problem.deadline(), 10); assert_eq!(problem.total_length(), 20); - assert_eq!(problem.dimensions(), vec![2; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); assert_eq!( ::NAME, "MultiprocessorScheduling" @@ -84,7 +86,10 @@ fn test_multiprocessor_scheduling_invalid_processor_index() { fn test_multiprocessor_scheduling_empty_instance() { let problem = MultiprocessorScheduling::new(vec![], 2, 10); assert_eq!(problem.num_tasks(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); // Empty assignment is always feasible assert!(problem.evaluate(&vec![]).unwrap()); } @@ -106,7 +111,10 @@ fn test_multiprocessor_scheduling_single_task_exceeds_deadline() { #[test] fn test_multiprocessor_scheduling_three_processors() { let problem = MultiprocessorScheduling::new(vec![3, 3, 3], 3, 3); - assert_eq!(problem.dimensions(), vec![3; 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 3] + ); // One task per processor assert!(problem.evaluate(&vec![0, 1, 2]).unwrap()); // Two tasks on one processor exceeds deadline @@ -176,7 +184,7 @@ fn test_multiprocessor_scheduling_deserialization_rejects_zero_processors() { })) .unwrap_err(); assert!( - err.to_string().contains("expected positive integer, got 0"), + err.to_string().contains("num_processors must be positive"), "unexpected error: {err}" ); } diff --git a/src/unit_tests/models/misc/non_liveness_free_petri_net.rs b/src/unit_tests/models/misc/non_liveness_free_petri_net.rs index 987c583b8..66ba4a44b 100644 --- a/src/unit_tests/models/misc/non_liveness_free_petri_net.rs +++ b/src/unit_tests/models/misc/non_liveness_free_petri_net.rs @@ -29,8 +29,11 @@ fn test_non_liveness_free_petri_net_basic() { assert_eq!(problem.num_transitions(), 3); assert_eq!(problem.num_arcs(), 6); assert_eq!(problem.initial_token_sum(), 1); - assert_eq!(problem.dimensions(), vec![2; 3]); - assert_eq!(problem.num_variables(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 3] + ); + assert_eq!(problem.num_variables().unwrap(), 3); assert_eq!( ::NAME, "NonLivenessFreePetriNet" diff --git a/src/unit_tests/models/misc/numerical_3_dimensional_matching.rs b/src/unit_tests/models/misc/numerical_3_dimensional_matching.rs index aa0f97684..cfbe42bb8 100644 --- a/src/unit_tests/models/misc/numerical_3_dimensional_matching.rs +++ b/src/unit_tests/models/misc/numerical_3_dimensional_matching.rs @@ -18,8 +18,11 @@ fn test_numerical_3dm_creation() { assert_eq!(problem.sizes_y(), &[5, 7]); assert_eq!(problem.bound(), 15); assert_eq!(problem.num_groups(), 2); - assert_eq!(problem.dimensions(), vec![2; 4]); - assert_eq!(problem.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); + assert_eq!(problem.num_variables().unwrap(), 4); assert_eq!( ::NAME, "Numerical3DimensionalMatching" diff --git a/src/unit_tests/models/misc/numerical_matching_with_target_sums.rs b/src/unit_tests/models/misc/numerical_matching_with_target_sums.rs index c2eb0221e..417969a15 100644 --- a/src/unit_tests/models/misc/numerical_matching_with_target_sums.rs +++ b/src/unit_tests/models/misc/numerical_matching_with_target_sums.rs @@ -17,8 +17,11 @@ fn test_nmts_creation() { assert_eq!(problem.sizes_y(), &[2, 5, 3]); assert_eq!(problem.targets(), &[3, 7, 12]); assert_eq!(problem.num_pairs(), 3); - assert_eq!(problem.dimensions(), vec![3; 3]); - assert_eq!(problem.num_variables(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 3] + ); + assert_eq!(problem.num_variables().unwrap(), 3); assert_eq!( ::NAME, "NumericalMatchingWithTargetSums" diff --git a/src/unit_tests/models/misc/open_shop_scheduling.rs b/src/unit_tests/models/misc/open_shop_scheduling.rs index a80a35455..6abacfbe6 100644 --- a/src/unit_tests/models/misc/open_shop_scheduling.rs +++ b/src/unit_tests/models/misc/open_shop_scheduling.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -60,10 +59,16 @@ fn test_open_shop_scheduling_creation() { #[test] fn test_open_shop_scheduling_dims() { let p = issue_example(); - assert_eq!(p.dimensions(), vec![24usize; 12]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![24usize; 12] + ); let p2 = two_by_two(); - assert_eq!(p2.dimensions(), vec![7usize; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p2).unwrap(), + vec![7usize; 4] + ); } // ─── evaluate ──────────────────────────────────────────────────────────────── @@ -119,7 +124,10 @@ fn test_open_shop_scheduling_evaluate_wrong_length() { #[test] fn test_open_shop_scheduling_evaluate_empty() { let p = OpenShopScheduling::new(3, vec![]); - assert_eq!(p.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + Vec::::new() + ); assert_eq!(p.evaluate(&vec![]).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs b/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs index ef3555fd7..7523aea2a 100644 --- a/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs +++ b/src/unit_tests/models/misc/optimum_communication_spanning_tree.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_edge_weights() { @@ -36,7 +35,10 @@ fn test_ocst_creation() { let problem = k4_problem(); assert_eq!(problem.num_vertices(), 4); assert_eq!(problem.num_edges(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!( ::NAME, "OptimumCommunicationSpanningTree" @@ -219,3 +221,47 @@ fn test_ocst_canonical_example() { ); assert_eq!(spec.optimal_value, serde_json::json!(20)); } + +#[test] +fn deserialize_rejects_invalid_communication_matrices() { + use serde_json::json; + let valid = json!({"edge_weights": [[0, 1], [1, 0]], "requirements": [[0, 1], [1, 0]]}); + for (field, value, message) in [ + ("requirements", json!([[0]]), "same size"), + ( + "edge_weights", + json!([[0], [1, 0]]), + "edge_weights must be square", + ), + ( + "requirements", + json!([[0], [1, 0]]), + "requirements must be square", + ), + ( + "edge_weights", + json!([[1, 1], [1, 0]]), + "diagonal of edge_weights", + ), + ( + "requirements", + json!([[1, 1], [1, 0]]), + "diagonal of requirements", + ), + ( + "edge_weights", + json!([[0, -1], [-1, 0]]), + "edge_weights must be non-negative", + ), + ( + "requirements", + json!([[0, -1], [-1, 0]]), + "requirements must be non-negative", + ), + ] { + let mut input = valid.clone(); + input[field] = value; + let error = serde_json::from_value::(input).unwrap_err(); + assert!(error.to_string().contains(message), "{field}: {error}"); + } +} diff --git a/src/unit_tests/models/misc/paintshop.rs b/src/unit_tests/models/misc/paintshop.rs index 93fce4912..a5f08fa7b 100644 --- a/src/unit_tests/models/misc/paintshop.rs +++ b/src/unit_tests/models/misc/paintshop.rs @@ -1,15 +1,15 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; -include!("../../jl_helpers.rs"); #[test] fn test_paintshop_creation() { let problem = PaintShop::new(vec!["a", "b", "a", "b"]); assert_eq!(problem.num_cars(), 2); assert_eq!(problem.sequence_len(), 4); - assert_eq!(problem.num_variables(), 2); + assert_eq!(problem.num_variables().unwrap(), 2); } #[test] @@ -154,3 +154,26 @@ fn test_paintshop_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap().unwrap(), 2); } + +#[test] +fn deserialize_rebuilds_occurrence_metadata() { + let expected = PaintShop::try_new(vec!["a", "b", "a", "b"]).unwrap(); + let mut json = serde_json::to_value(&expected).unwrap(); + json["is_first"] = serde_json::json!([false]); + json["num_cars"] = serde_json::json!(99); + let restored: PaintShop = serde_json::from_value(json).unwrap(); + assert_eq!( + serde_json::to_value(restored).unwrap(), + serde_json::to_value(expected).unwrap() + ); +} + +#[test] +fn deserialize_rejects_invalid_car_indices_and_counts() { + for indices in [vec![0, 1], vec![0]] { + assert!(serde_json::from_value::(serde_json::json!({ + "sequence_indices": indices, "car_labels": ["a"] + })) + .is_err()); + } +} diff --git a/src/unit_tests/models/misc/partially_ordered_knapsack.rs b/src/unit_tests/models/misc/partially_ordered_knapsack.rs index 58794aeaf..26311ed1a 100644 --- a/src/unit_tests/models/misc/partially_ordered_knapsack.rs +++ b/src/unit_tests/models/misc/partially_ordered_knapsack.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; /// Helper: create the example instance from the issue. @@ -29,7 +28,10 @@ fn test_partially_ordered_knapsack_basic() { &[(0, 2), (0, 3), (1, 4), (3, 5), (4, 5)] ); assert_eq!(problem.capacity(), 11); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!( ::NAME, "PartiallyOrderedKnapsack" @@ -169,7 +171,10 @@ fn test_partially_ordered_knapsack_empty_instance() { let problem = PartiallyOrderedKnapsack::new(vec![], vec![], vec![], 10); assert_eq!(problem.num_items(), 0); assert_eq!(problem.num_precedences(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Max(Some(0))); } diff --git a/src/unit_tests/models/misc/partition.rs b/src/unit_tests/models/misc/partition.rs index f6a969235..94a032b38 100644 --- a/src/unit_tests/models/misc/partition.rs +++ b/src/unit_tests/models/misc/partition.rs @@ -1,6 +1,5 @@ use crate::models::misc::Partition; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -9,7 +8,10 @@ fn test_partition_basic() { assert_eq!(problem.num_elements(), 6); assert_eq!(problem.sizes(), &[3, 1, 1, 2, 2, 1]); assert_eq!(problem.total_sum(), 10); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); } #[test] diff --git a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs index 47cf34313..445331039 100644 --- a/src/unit_tests/models/misc/precedence_constrained_scheduling.rs +++ b/src/unit_tests/models/misc/precedence_constrained_scheduling.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -10,7 +9,10 @@ fn test_precedence_constrained_scheduling_basic() { assert_eq!(problem.num_processors(), 2); assert_eq!(problem.deadline(), 3); assert_eq!(problem.precedences(), &[(0, 2), (1, 3)]); - assert_eq!(problem.dimensions(), vec![3; 4]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 4] + ); assert_eq!( ::NAME, "PrecedenceConstrainedScheduling" @@ -129,7 +131,10 @@ fn test_precedence_constrained_scheduling_serialization() { fn test_precedence_constrained_scheduling_empty() { let problem = PrecedenceConstrainedScheduling::new(0, 1, 1, vec![]); assert_eq!(problem.num_tasks(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } diff --git a/src/unit_tests/models/misc/preemptive_scheduling.rs b/src/unit_tests/models/misc/preemptive_scheduling.rs index 299bb177c..c300f06fe 100644 --- a/src/unit_tests/models/misc/preemptive_scheduling.rs +++ b/src/unit_tests/models/misc/preemptive_scheduling.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -29,7 +28,10 @@ fn test_preemptive_scheduling_creation() { assert_eq!(p.lengths(), &[2, 1, 3]); assert_eq!(p.precedences(), &[(0, 2)]); assert_eq!(p.d_max(), 6); - assert_eq!(p.dimensions(), vec![2; 3 * 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2; 3 * 6] + ); assert_eq!( ::NAME, "PreemptiveScheduling" @@ -42,7 +44,10 @@ fn test_preemptive_scheduling_empty_tasks() { let p = PreemptiveScheduling::new(vec![], 1, vec![]).unwrap(); assert_eq!(p.num_tasks(), 0); assert_eq!(p.d_max(), 0); - assert_eq!(p.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + Vec::::new() + ); assert_eq!(p.evaluate(&vec![]).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/models/misc/production_planning.rs b/src/unit_tests/models/misc/production_planning.rs index 1141a3d07..6615a4fb7 100644 --- a/src/unit_tests/models/misc/production_planning.rs +++ b/src/unit_tests/models/misc/production_planning.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_period_vector_mismatch() { @@ -54,7 +53,10 @@ fn test_production_planning_creation() { assert_eq!(problem.inventory_costs(), &[1, 1, 1, 1, 1, 1]); assert_eq!(problem.cost_bound(), 80); assert_eq!(problem.max_capacity(), 12); - assert_eq!(problem.dimensions(), vec![13; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![13; 6] + ); assert_eq!(::NAME, "ProductionPlanning"); assert_eq!(::variant(), vec![]); } diff --git a/src/unit_tests/models/misc/rectilinear_picture_compression.rs b/src/unit_tests/models/misc/rectilinear_picture_compression.rs index 00acdec63..cdb5ecd28 100644 --- a/src/unit_tests/models/misc/rectilinear_picture_compression.rs +++ b/src/unit_tests/models/misc/rectilinear_picture_compression.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; fn two_block_matrix() -> Vec> { @@ -52,7 +51,10 @@ fn test_rectilinear_picture_compression_maximal_rectangles_two_blocks() { fn test_rectilinear_picture_compression_dims() { let problem = RectilinearPictureCompression::new(two_block_matrix(), 2); // 2 maximal rectangles -> 2 binary variables - assert_eq!(problem.dimensions(), vec![2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2] + ); } #[test] @@ -173,7 +175,10 @@ fn test_rectilinear_picture_compression_single_cell() { let problem = RectilinearPictureCompression::new(matrix, 1); let rects = problem.maximal_rectangles(); assert_eq!(rects, vec![(0, 0, 0, 0)]); - assert_eq!(problem.dimensions(), vec![2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2] + ); assert!(problem.evaluate(&vec![true]).unwrap()); assert!(!problem.evaluate(&vec![false]).unwrap()); } @@ -185,7 +190,10 @@ fn test_rectilinear_picture_compression_all_zeros() { let problem = RectilinearPictureCompression::new(matrix, 0); let rects = problem.maximal_rectangles(); assert!(rects.is_empty()); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); // Empty config satisfies (no 1-entries to cover) assert!(problem.evaluate(&vec![]).unwrap()); } @@ -239,3 +247,15 @@ fn test_rectilinear_picture_compression_empty_row_panics() { fn test_rectilinear_picture_compression_inconsistent_rows_panics() { RectilinearPictureCompression::new(vec![vec![true, false], vec![true]], 1); } + +#[test] +fn deserialize_rejects_invalid_matrix_before_building_rectangles() { + for matrix in [vec![], vec![vec![]], vec![vec![true], vec![]]] { + assert!( + serde_json::from_value::(serde_json::json!({ + "matrix": matrix, "bound": 1 + })) + .is_err() + ); + } +} diff --git a/src/unit_tests/models/misc/register_sufficiency.rs b/src/unit_tests/models/misc/register_sufficiency.rs index a112f8bc8..1de17b70f 100644 --- a/src/unit_tests/models/misc/register_sufficiency.rs +++ b/src/unit_tests/models/misc/register_sufficiency.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -35,7 +34,10 @@ fn test_register_sufficiency_basic() { (6, 5) ] ); - assert_eq!(problem.dimensions(), vec![7; 7]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![7; 7] + ); assert_eq!( ::NAME, "RegisterSufficiency" @@ -183,7 +185,10 @@ fn test_register_sufficiency_serialization() { fn test_register_sufficiency_empty() { let problem = RegisterSufficiency::new(0, vec![], 0); assert_eq!(problem.num_vertices(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } diff --git a/src/unit_tests/models/misc/resource_constrained_scheduling.rs b/src/unit_tests/models/misc/resource_constrained_scheduling.rs index 70415a3b0..45c71ee8f 100644 --- a/src/unit_tests/models/misc/resource_constrained_scheduling.rs +++ b/src/unit_tests/models/misc/resource_constrained_scheduling.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -17,9 +16,17 @@ fn test_resource_constrained_scheduling_creation() { assert_eq!(problem.resource_bounds(), &[20]); assert_eq!(problem.deadline(), 2); assert_eq!(problem.num_resources(), 1); - assert_eq!(problem.dimensions().len(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .len(), + 6 + ); // Each variable has domain {0, 1} (deadline = 2) - assert!(problem.dimensions().iter().all(|&d| d == 2)); + assert!(crate::solvers::cartesian_dimensions(&problem) + .unwrap() + .iter() + .all(|&d| d == 2)); } #[test] @@ -115,7 +122,10 @@ fn test_resource_constrained_scheduling_empty_tasks() { let problem = ResourceConstrainedScheduling::new(2, vec![10], Vec::>::new(), 3).unwrap(); assert_eq!(problem.num_tasks(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } diff --git a/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs b/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs index 54fd389aa..9ad8c1347 100644 --- a/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs +++ b/src/unit_tests/models/misc/scheduling_to_minimize_weighted_completion_time.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_task_weights() { @@ -28,7 +27,10 @@ fn test_scheduling_min_wct_creation() { assert_eq!(problem.num_processors(), 2); assert_eq!(problem.lengths(), &[1, 2, 3, 4, 5]); assert_eq!(problem.weights(), &[6, 4, 3, 2, 1]); - assert_eq!(problem.dimensions(), vec![2; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 5] + ); assert_eq!( ::NAME, "SchedulingToMinimizeWeightedCompletionTime" @@ -195,7 +197,10 @@ fn test_scheduling_min_wct_single_processor() { #[test] fn test_scheduling_min_wct_three_processors() { let problem = SchedulingToMinimizeWeightedCompletionTime::new(vec![3, 3, 3], vec![1, 1, 1], 3); - assert_eq!(problem.dimensions(), vec![3; 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 3] + ); // One task per processor: each completes at 3, WCT = 3*1 + 3*1 + 3*1 = 9 assert_eq!(problem.evaluate(&vec![0, 1, 2]).unwrap(), Min(Some(9))); // All on one processor: C(t0)=3, C(t1)=6, C(t2)=9, WCT = 3+6+9 = 18 diff --git a/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs b/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs index 3a017d7a4..8268ad0ae 100644 --- a/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs +++ b/src/unit_tests/models/misc/scheduling_with_individual_deadlines.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_deadline_count_mismatch() { @@ -42,7 +41,10 @@ fn test_scheduling_with_individual_deadlines_basic() { ); assert_eq!(problem.num_precedences(), 5); assert_eq!(problem.max_deadline(), 3); - assert_eq!(problem.dimensions(), vec![2, 1, 2, 2, 3, 3, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 1, 2, 2, 3, 3, 2] + ); assert_eq!( ::NAME, "SchedulingWithIndividualDeadlines" diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs index de3ae8efe..49cb187fc 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_maximum_cumulative_cost.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_precedences() { @@ -32,7 +31,10 @@ fn test_sequencing_to_minimize_maximum_cumulative_cost_creation() { ); assert_eq!(problem.num_tasks(), 6); assert_eq!(problem.num_precedences(), 6); - assert_eq!(problem.dimensions(), vec![6, 5, 4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6, 5, 4, 3, 2, 1] + ); assert_eq!( ::NAME, "SequencingToMinimizeMaximumCumulativeCost" @@ -128,7 +130,10 @@ fn test_sequencing_to_minimize_maximum_cumulative_cost_solver_aggregate() { fn test_sequencing_to_minimize_maximum_cumulative_cost_empty_instance() { let problem = SequencingToMinimizeMaximumCumulativeCost::new(vec![], vec![]); assert_eq!(problem.num_tasks(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); // Empty schedule: no tasks, max cumulative cost is 0. let val = problem.evaluate(&vec![]).unwrap(); assert_eq!(val, Min(Some(0))); diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs index feae2b123..739b1530c 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_task_weights() { @@ -29,7 +28,10 @@ fn test_sequencing_to_minimize_tardy_task_weight_basic() { assert_eq!(problem.lengths(), &[3, 2, 4, 1, 2]); assert_eq!(problem.weights(), &[5, 3, 7, 2, 4]); assert_eq!(problem.deadlines(), &[6, 4, 10, 2, 8]); - assert_eq!(problem.dimensions(), vec![5, 5, 5, 5, 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 5, 5, 5, 5] + ); assert_eq!( ::NAME, "SequencingToMinimizeTardyTaskWeight" @@ -187,7 +189,10 @@ fn test_sequencing_to_minimize_tardy_task_weight_deserialization_rejects_zero_we #[test] fn test_sequencing_to_minimize_tardy_task_weight_single_task() { let problem = SequencingToMinimizeTardyTaskWeight::new(vec![3], vec![2], vec![5]); - assert_eq!(problem.dimensions(), vec![1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1] + ); // completes at 3, deadline 5, on time assert_eq!(problem.evaluate(&vec![0]).unwrap(), Min(Some(0))); } @@ -203,7 +208,10 @@ fn test_sequencing_to_minimize_tardy_task_weight_single_task_tardy() { fn test_sequencing_to_minimize_tardy_task_weight_empty() { let problem = SequencingToMinimizeTardyTaskWeight::new(vec![], vec![], vec![]); assert_eq!(problem.num_tasks(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs index 97c31b24d..688705c4c 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_completion_time.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -17,7 +16,10 @@ fn test_sequencing_to_minimize_weighted_completion_time_basic() { assert_eq!(problem.weights(), &[3, 5, 1, 4, 2]); assert_eq!(problem.precedences(), &[(0, 2), (1, 4)]); assert_eq!(problem.num_precedences(), 2); - assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 4, 3, 2, 1] + ); assert_eq!( ::NAME, "SequencingToMinimizeWeightedCompletionTime" @@ -129,7 +131,10 @@ fn test_sequencing_to_minimize_weighted_completion_time_empty() { let problem = SequencingToMinimizeWeightedCompletionTime::new(vec![], vec![], vec![]); assert_eq!(problem.num_tasks(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Min(Some(0))); } @@ -137,7 +142,10 @@ fn test_sequencing_to_minimize_weighted_completion_time_empty() { fn test_sequencing_to_minimize_weighted_completion_time_single_task() { let problem = SequencingToMinimizeWeightedCompletionTime::new(vec![3], vec![2], vec![]); - assert_eq!(problem.dimensions(), vec![1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1] + ); assert_eq!(problem.evaluate(&vec![0]).unwrap(), Min(Some(6))); } diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs index 3214d9c90..c3b28a9c2 100644 --- a/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs +++ b/src/unit_tests/models/misc/sequencing_to_minimize_weighted_tardiness.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_vector_length_mismatch() { @@ -47,7 +46,10 @@ fn test_sequencing_to_minimize_weighted_tardiness_basic() { assert_eq!(problem.deadlines(), &[5, 8, 4, 15, 10]); assert_eq!(problem.bound(), 13); assert_eq!(problem.num_tasks(), 5); - assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 4, 3, 2, 1] + ); assert_eq!( ::NAME, "SequencingToMinimizeWeightedTardiness" diff --git a/src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs b/src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs index 26f9f9f0a..7d406a2e1 100644 --- a/src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs +++ b/src/unit_tests/models/misc/sequencing_with_deadlines_and_set_up_times.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Or; @@ -19,7 +18,10 @@ fn test_sequencing_with_deadlines_and_set_up_times_creation() { assert_eq!(problem.deadlines(), &[4, 11, 3, 16, 7]); assert_eq!(problem.compilers(), &[0, 1, 0, 1, 0]); assert_eq!(problem.setup_times(), &[1, 2]); - assert_eq!(problem.dimensions(), vec![5, 5, 5, 5, 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 5, 5, 5, 5] + ); assert_eq!( ::NAME, "SequencingWithDeadlinesAndSetUpTimes" diff --git a/src/unit_tests/models/misc/sequencing_with_release_times_and_deadlines.rs b/src/unit_tests/models/misc/sequencing_with_release_times_and_deadlines.rs index 1c86ed2d8..64fbb012b 100644 --- a/src/unit_tests/models/misc/sequencing_with_release_times_and_deadlines.rs +++ b/src/unit_tests/models/misc/sequencing_with_release_times_and_deadlines.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -16,7 +15,10 @@ fn test_sequencing_rtd_basic() { assert_eq!(problem.deadlines(), &[5, 6, 10, 3, 12]); assert_eq!(problem.time_horizon(), 12); // Lehmer code dims: [5, 4, 3, 2, 1] - assert_eq!(problem.dimensions(), vec![5, 4, 3, 2, 1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5, 4, 3, 2, 1] + ); assert_eq!( ::NAME, "SequencingWithReleaseTimesAndDeadlines" @@ -73,14 +75,20 @@ fn test_sequencing_rtd_empty_instance() { let problem = SequencingWithReleaseTimesAndDeadlines::new(vec![], vec![], vec![]); assert_eq!(problem.num_tasks(), 0); assert_eq!(problem.time_horizon(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } #[test] fn test_sequencing_rtd_single_task() { let problem = SequencingWithReleaseTimesAndDeadlines::new(vec![2], vec![1], vec![5]); - assert_eq!(problem.dimensions(), vec![1]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1] + ); // Only one permutation: task 0 starts at max(1,0)=1, finish=3 <= 5 assert!(problem.evaluate(&vec![0]).unwrap()); } diff --git a/src/unit_tests/models/misc/sequencing_within_intervals.rs b/src/unit_tests/models/misc/sequencing_within_intervals.rs index d040834e4..2bd472838 100644 --- a/src/unit_tests/models/misc/sequencing_within_intervals.rs +++ b/src/unit_tests/models/misc/sequencing_within_intervals.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_accepts_empty_window() { @@ -37,7 +36,10 @@ fn test_sequencing_within_intervals_creation() { // Task 2: 9 - 3 - 2 + 1 = 5 // Task 3: 12 - 6 - 3 + 1 = 4 // Task 4: 12 - 0 - 2 + 1 = 11 - assert_eq!(problem.dimensions(), vec![4, 6, 5, 4, 11]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4, 6, 5, 4, 11] + ); } #[test] @@ -152,7 +154,10 @@ fn test_sequencing_within_intervals_empty() { let problem = SequencingWithinIntervals::new(vec![], vec![], vec![]).unwrap(); assert_eq!(problem.num_tasks(), 0); assert_eq!(problem.num_start_slots(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } @@ -174,7 +179,10 @@ fn test_sequencing_within_intervals_variant() { fn test_sequencing_within_intervals_single_task() { let problem = SequencingWithinIntervals::new(vec![0], vec![5], vec![3]).unwrap(); // dims = 5 - 0 - 3 + 1 = 3 - assert_eq!(problem.dimensions(), vec![3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3] + ); // Any valid config should be feasible (only one task, no overlaps possible) assert!(problem.evaluate(&vec![0]).unwrap()); assert!(problem.evaluate(&vec![1]).unwrap()); @@ -217,7 +225,10 @@ fn test_sequencing_within_intervals_empty_start_domain() { .unwrap(); let restored: SequencingWithinIntervals = serde_json::from_value(serde_json::to_value(&problem).unwrap()).unwrap(); - assert_eq!(restored.dimensions(), vec![2, 0]); + assert_eq!( + crate::solvers::cartesian_dimensions(&restored).unwrap(), + vec![2, 0] + ); assert_eq!(restored.num_start_slots(), 2); let (value, witnesses) = BruteForce::new().solve_with_witnesses(&restored).unwrap(); assert_eq!(value, crate::types::Or(false)); diff --git a/src/unit_tests/models/misc/shortest_common_supersequence.rs b/src/unit_tests/models/misc/shortest_common_supersequence.rs index 65dfccfcb..1ba7fbdbf 100644 --- a/src/unit_tests/models/misc/shortest_common_supersequence.rs +++ b/src/unit_tests/models/misc/shortest_common_supersequence.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -70,7 +69,10 @@ fn test_shortestcommonsupersequence_basic() { assert_eq!(problem.num_strings(), 3); assert_eq!(problem.max_length(), 12); // 4+4+4 assert_eq!(problem.total_length(), 12); - assert_eq!(problem.dimensions(), vec![4; 12]); // alphabet_size+1 = 4, max_length = 12 + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 12] + ); // alphabet_size+1 = 4, max_length = 12 assert_eq!( ::NAME, "ShortestCommonSupersequence" diff --git a/src/unit_tests/models/misc/shortest_common_superstring.rs b/src/unit_tests/models/misc/shortest_common_superstring.rs index c2462e6c3..a32e6918d 100644 --- a/src/unit_tests/models/misc/shortest_common_superstring.rs +++ b/src/unit_tests/models/misc/shortest_common_superstring.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -26,7 +25,10 @@ fn test_shortestcommonsuperstring_basic() { assert_eq!(problem.num_strings(), 3); assert_eq!(problem.max_length(), 9); // 3+3+3 assert_eq!(problem.total_length(), 9); - assert_eq!(problem.dimensions(), vec![4; 9]); // alphabet_size + 1 = 4 across max_length = 9 positions + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 9] + ); // alphabet_size + 1 = 4 across max_length = 9 positions assert_eq!( ::NAME, "ShortestCommonSuperstring" diff --git a/src/unit_tests/models/misc/square_tiling.rs b/src/unit_tests/models/misc/square_tiling.rs index 720a2128a..b26990e1d 100644 --- a/src/unit_tests/models/misc/square_tiling.rs +++ b/src/unit_tests/models/misc/square_tiling.rs @@ -21,8 +21,11 @@ fn test_square_tiling_basic() { assert_eq!(problem.num_tiles(), 4); assert_eq!(problem.grid_size(), 2); assert_eq!(problem.tiles().len(), 4); - assert_eq!(problem.dimensions(), vec![4; 4]); - assert_eq!(problem.num_variables(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![4; 4] + ); + assert_eq!(problem.num_variables().unwrap(), 4); assert_eq!(::NAME, "SquareTiling"); assert_eq!(::variant(), vec![]); } diff --git a/src/unit_tests/models/misc/stacker_crane.rs b/src/unit_tests/models/misc/stacker_crane.rs index 071b9df8b..2522293d5 100644 --- a/src/unit_tests/models/misc/stacker_crane.rs +++ b/src/unit_tests/models/misc/stacker_crane.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_defaults_lengths_and_checks_inferred_vertex_counts() { @@ -40,7 +39,10 @@ fn test_stacker_crane_creation_and_metadata() { assert_eq!(problem.num_vertices(), 6); assert_eq!(problem.num_arcs(), 5); assert_eq!(problem.num_edges(), 7); - assert_eq!(problem.dimensions(), vec![5; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 5] + ); assert_eq!(::NAME, "StackerCrane"); assert!(::variant().is_empty()); } diff --git a/src/unit_tests/models/misc/staff_scheduling.rs b/src/unit_tests/models/misc/staff_scheduling.rs index ac58c7aba..25c823aad 100644 --- a/src/unit_tests/models/misc/staff_scheduling.rs +++ b/src/unit_tests/models/misc/staff_scheduling.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -39,7 +38,10 @@ fn test_staff_scheduling_creation() { assert_eq!(problem.num_schedules(), 5); assert_eq!(problem.requirements(), &[2, 2, 2, 3, 3, 2, 1]); assert_eq!(problem.num_workers(), 4); - assert_eq!(problem.dimensions(), vec![5; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 5] + ); } #[test] diff --git a/src/unit_tests/models/misc/string_to_string_correction.rs b/src/unit_tests/models/misc/string_to_string_correction.rs index 4530eab36..f09e06f6b 100644 --- a/src/unit_tests/models/misc/string_to_string_correction.rs +++ b/src/unit_tests/models/misc/string_to_string_correction.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; #[test] @@ -13,7 +12,10 @@ fn test_string_to_string_correction_creation() { assert_eq!(problem.source_length(), 6); assert_eq!(problem.target_length(), 5); // domain = 2*6+1 = 13, bound = 2 - assert_eq!(problem.dimensions(), vec![13; 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![13; 2] + ); assert_eq!( ::NAME, "StringToStringCorrection" @@ -111,7 +113,10 @@ fn test_string_to_string_correction_paper_example() { fn test_string_to_string_correction_unsatisfiable() { // bound=0, source != target → impossible let problem = StringToStringCorrection::new(2, vec![0, 1], vec![1, 0], 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(!problem.evaluate(&vec![]).unwrap()); let solver = BruteForce::new(); diff --git a/src/unit_tests/models/misc/subset_product.rs b/src/unit_tests/models/misc/subset_product.rs index 25c767a06..f0344829f 100644 --- a/src/unit_tests/models/misc/subset_product.rs +++ b/src/unit_tests/models/misc/subset_product.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use num_bigint::BigUint; @@ -18,7 +17,10 @@ fn test_subsetproduct_basic() { assert_eq!(problem.num_elements(), 6); assert_eq!(problem.sizes(), buv(&[2, 3, 5, 7, 6, 10]).as_slice()); assert_eq!(problem.target(), &bu(210)); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!(::NAME, "SubsetProduct"); assert_eq!(::variant(), vec![]); } @@ -80,7 +82,10 @@ fn test_subsetproduct_empty_instance() { // Empty set, target 1: empty subset product = 1 satisfies let problem = SubsetProduct::new_unchecked(vec![], bu(1)); assert_eq!(problem.num_elements(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } diff --git a/src/unit_tests/models/misc/subset_sum.rs b/src/unit_tests/models/misc/subset_sum.rs index 80807cefe..bb9102811 100644 --- a/src/unit_tests/models/misc/subset_sum.rs +++ b/src/unit_tests/models/misc/subset_sum.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use num_bigint::BigUint; @@ -18,7 +17,10 @@ fn test_subsetsum_basic() { assert_eq!(problem.num_elements(), 6); assert_eq!(problem.sizes(), buv(&[3, 7, 1, 8, 2, 4]).as_slice()); assert_eq!(problem.target(), &bu(11)); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); assert_eq!(::NAME, "SubsetSum"); assert_eq!(::variant(), vec![]); } @@ -80,7 +82,10 @@ fn test_subsetsum_empty_instance() { // Empty set, target 0: empty subset satisfies let problem = SubsetSum::new_unchecked(vec![], bu(0)); assert_eq!(problem.num_elements(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } diff --git a/src/unit_tests/models/misc/sum_of_squares_partition.rs b/src/unit_tests/models/misc/sum_of_squares_partition.rs index 5afb1a20e..4201e33d3 100644 --- a/src/unit_tests/models/misc/sum_of_squares_partition.rs +++ b/src/unit_tests/models/misc/sum_of_squares_partition.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; @@ -10,7 +9,10 @@ fn test_sum_of_squares_partition_basic() { assert_eq!(problem.num_elements(), 6); assert_eq!(problem.num_groups(), 3); assert_eq!(problem.sizes(), &[5, 3, 8, 2, 7, 1]); - assert_eq!(problem.dimensions(), vec![3; 6]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3; 6] + ); assert_eq!( ::NAME, "SumOfSquaresPartition" diff --git a/src/unit_tests/models/misc/three_partition.rs b/src/unit_tests/models/misc/three_partition.rs index 44c354c02..857e61430 100644 --- a/src/unit_tests/models/misc/three_partition.rs +++ b/src/unit_tests/models/misc/three_partition.rs @@ -16,8 +16,11 @@ fn test_three_partition_basic() { assert_eq!(problem.num_elements(), 6); assert_eq!(problem.num_groups(), 2); assert_eq!(problem.total_sum(), 30); - assert_eq!(problem.dimensions(), vec![2; 6]); - assert_eq!(problem.num_variables(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); + assert_eq!(problem.num_variables().unwrap(), 6); assert_eq!(::NAME, "ThreePartition"); assert_eq!(::variant(), vec![]); } diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index ee8aaad02..969548215 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -1,5 +1,4 @@ use super::*; -use crate::solvers::BruteForceProblem as _; #[test] fn create_spec_rejects_matrix_shape_mismatch() { @@ -49,7 +48,10 @@ fn test_timetable_design_creation_and_dims() { ); assert_eq!(problem.task_avail(), &[vec![true, true], vec![false, true]]); assert_eq!(problem.requirements(), &[vec![1, 0], vec![0, 1]]); - assert_eq!(problem.dimensions(), vec![2; 8]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 8] + ); } #[test] @@ -228,3 +230,41 @@ fn test_timetable_design_paper_example_is_valid() { ); assert_eq!(spec.optimal_value, serde_json::json!(true)); } + +#[test] +#[should_panic(expected = "craftsman 0 availability")] +fn new_rejects_wrong_craftsman_period_count() { + TimetableDesign::new( + 2, + 1, + 1, + vec![vec![true]], + vec![vec![true; 2]], + vec![vec![1]], + ); +} + +#[test] +#[should_panic(expected = "task_avail has 0 rows")] +fn new_rejects_wrong_task_count() { + TimetableDesign::new(2, 1, 1, vec![vec![true; 2]], vec![], vec![vec![1]]); +} + +#[test] +#[should_panic(expected = "task 0 availability")] +fn new_rejects_wrong_task_period_count() { + TimetableDesign::new( + 2, + 1, + 1, + vec![vec![true; 2]], + vec![vec![true]], + vec![vec![1]], + ); +} + +#[test] +#[should_panic(expected = "requirements has 0 rows")] +fn new_rejects_wrong_requirement_row_count() { + TimetableDesign::new(2, 1, 1, vec![vec![true; 2]], vec![vec![true; 2]], vec![]); +} diff --git a/src/unit_tests/models/set/comparative_containment.rs b/src/unit_tests/models/set/comparative_containment.rs index 8d0ecd984..2f58e7085 100644 --- a/src/unit_tests/models/set/comparative_containment.rs +++ b/src/unit_tests/models/set/comparative_containment.rs @@ -55,8 +55,11 @@ fn test_comparative_containment_creation() { assert_eq!(problem.universe_size(), 4); assert_eq!(problem.num_r_sets(), 2); assert_eq!(problem.num_s_sets(), 2); - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2] + ); } #[test] diff --git a/src/unit_tests/models/set/consecutive_sets.rs b/src/unit_tests/models/set/consecutive_sets.rs index 4c94c0afa..cef98afb0 100644 --- a/src/unit_tests/models/set/consecutive_sets.rs +++ b/src/unit_tests/models/set/consecutive_sets.rs @@ -13,8 +13,11 @@ fn test_consecutive_sets_creation() { assert_eq!(problem.alphabet_size(), 6); assert_eq!(problem.num_subsets(), 5); assert_eq!(problem.bound_k(), 6); - assert_eq!(problem.num_variables(), 6); - assert_eq!(problem.dimensions(), vec![7; 6]); // alphabet_size + 1 = 7 + assert_eq!(problem.num_variables().unwrap(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![7; 6] + ); // alphabet_size + 1 = 7 } #[test] @@ -139,3 +142,10 @@ fn test_consecutive_sets_duplicate_elements() { fn test_consecutive_sets_zero_bound() { ConsecutiveSets::new(3, vec![vec![0, 1]], 0); } + +#[test] +fn json_rejects_invalid_instance() { + let json = serde_json::json!({"alphabet_size":3,"subsets":[[0,0]],"bound_k":3}); + assert!(serde_json::from_value::(json.clone()).is_err()); + assert!(crate::registry::load_dyn("ConsecutiveSets", &Default::default(), json).is_err()); +} diff --git a/src/unit_tests/models/set/exact_cover_by_3_sets.rs b/src/unit_tests/models/set/exact_cover_by_3_sets.rs index a7cdf05cc..b955cac74 100644 --- a/src/unit_tests/models/set/exact_cover_by_3_sets.rs +++ b/src/unit_tests/models/set/exact_cover_by_3_sets.rs @@ -18,8 +18,11 @@ fn test_exact_cover_by_3_sets_creation() { assert_eq!(problem.universe_size(), 6); assert_eq!(problem.num_subsets(), 3); assert_eq!(problem.num_sets(), 3); - assert_eq!(problem.num_variables(), 3); - assert_eq!(problem.dimensions(), vec![2, 2, 2]); + assert_eq!(problem.num_variables().unwrap(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2] + ); } #[test] @@ -169,3 +172,28 @@ fn test_exact_cover_by_3_sets_element_out_of_range() { fn test_exact_cover_by_3_sets_duplicate_elements() { ExactCoverBy3Sets::new(6, vec![[0, 0, 1]]); } + +#[test] +fn construction_and_json_reject_invalid_triples() { + for (universe_size, subsets) in [ + (5, vec![[0, 1, 2]]), + (6, vec![[0, 1, 7]]), + (6, vec![[0, 0, 1]]), + ] { + assert!(ExactCoverBy3Sets::try_new(universe_size, subsets.clone()).is_err()); + let json = serde_json::json!({"universe_size": universe_size, "subsets": subsets}); + assert!(serde_json::from_value::(json.clone()).is_err()); + assert!(crate::registry::load_dyn("ExactCoverBy3Sets", &Default::default(), json).is_err()); + } +} + +#[test] +fn construction_and_json_sort_triples() { + let expected = ExactCoverBy3Sets::try_new(3, vec![[2, 0, 1]]).unwrap(); + let loaded: ExactCoverBy3Sets = serde_json::from_value(serde_json::json!({ + "universe_size": 3, "subsets": [[2, 0, 1]] + })) + .unwrap(); + assert_eq!(expected.subsets(), &[[0, 1, 2]]); + assert_eq!(loaded.subsets(), expected.subsets()); +} diff --git a/src/unit_tests/models/set/integer_knapsack.rs b/src/unit_tests/models/set/integer_knapsack.rs index b03fc1744..e767866ae 100644 --- a/src/unit_tests/models/set/integer_knapsack.rs +++ b/src/unit_tests/models/set/integer_knapsack.rs @@ -10,7 +10,10 @@ fn test_integer_knapsack_basic() { assert_eq!(problem.values(), &[4, 5, 7, 3, 9]); assert_eq!(problem.capacity(), 15); // dims: floor(15/3)+1=6, floor(15/4)+1=4, floor(15/5)+1=4, floor(15/2)+1=8, floor(15/7)+1=3 - assert_eq!(problem.dimensions(), vec![6, 4, 4, 8, 3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6, 4, 4, 8, 3] + ); assert_eq!(::NAME, "IntegerKnapsack"); assert_eq!(::variant(), vec![]); } @@ -65,20 +68,19 @@ fn test_integer_knapsack_evaluate_wrong_config_length() { } #[test] -fn test_integer_knapsack_evaluate_out_of_domain() { +fn test_integer_knapsack_evaluate_single_item_overweight() { let problem = IntegerKnapsack::new(vec![3, 4], vec![4, 5], 10).unwrap(); - // dims = [4, 3], so config [4, 0] is out of domain for item 0 - assert!(matches!( - problem.evaluate(&vec![4, 0]), - Err(crate::traits::EvaluationError::InvalidConfiguration(_)) - )); + assert_eq!(problem.evaluate(&vec![4, 0]).unwrap(), Max(None)); } #[test] fn test_integer_knapsack_empty_instance() { let problem = IntegerKnapsack::new(vec![], vec![], 10).unwrap(); assert_eq!(problem.num_items(), 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert_eq!(problem.evaluate(&vec![]).unwrap(), Max(Some(0))); } @@ -107,7 +109,10 @@ fn test_integer_knapsack_serialization() { #[test] fn test_integer_knapsack_zero_capacity() { let problem = IntegerKnapsack::new(vec![1, 2], vec![10, 20], 0).unwrap(); - assert_eq!(problem.dimensions(), vec![1, 1]); // floor(0/1)+1=1, floor(0/2)+1=1 + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1, 1] + ); // floor(0/1)+1=1, floor(0/2)+1=1 assert_eq!(problem.evaluate(&vec![0, 0]).unwrap(), Max(Some(0))); let solver = BruteForce::new(); let solution = solver.solve(&problem).unwrap().unwrap(); @@ -118,7 +123,10 @@ fn test_integer_knapsack_zero_capacity() { #[test] fn test_integer_knapsack_dimension_uses_structural_range() { let problem = IntegerKnapsack::new(vec![1], vec![1], i64::MAX).unwrap(); - assert_eq!(problem.dimensions(), vec![1_usize << 63]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![1_usize << 63] + ); } #[test] @@ -126,7 +134,10 @@ fn test_integer_knapsack_single_item() { // Single item size=3, value=5, capacity=7 // Max multiplicity: floor(7/3)=2, dims=[3] let problem = IntegerKnapsack::new(vec![3], vec![5], 7).unwrap(); - assert_eq!(problem.dimensions(), vec![3]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![3] + ); assert_eq!(problem.evaluate(&vec![0]).unwrap(), Max(Some(0))); assert_eq!(problem.evaluate(&vec![1]).unwrap(), Max(Some(5))); assert_eq!(problem.evaluate(&vec![2]).unwrap(), Max(Some(10))); @@ -231,7 +242,7 @@ fn test_integer_knapsack_deserialization_rejects_invalid_fields() { #[test] fn test_integer_knapsack_paper_example() { - // From issue #532: 5 items, sizes=[3,4,5,2,7], values=[4,5,7,3,9], B=15 + // 5 items, sizes=[3,4,5,2,7], values=[4,5,7,3,9], B=15 // Optimal=22 with c=(0,0,1,5,0) or c=(1,0,0,6,0) let problem = IntegerKnapsack::new(vec![3, 4, 5, 2, 7], vec![4, 5, 7, 3, 9], 15).unwrap(); diff --git a/src/unit_tests/models/set/maximum_set_packing.rs b/src/unit_tests/models/set/maximum_set_packing.rs index 9993ca2ef..53024f41b 100644 --- a/src/unit_tests/models/set/maximum_set_packing.rs +++ b/src/unit_tests/models/set/maximum_set_packing.rs @@ -1,9 +1,9 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Max; -include!("../../jl_helpers.rs"); #[test] fn test_maximum_set_packing_create_spec_uses_subsets_input() { @@ -24,7 +24,7 @@ fn test_maximum_set_packing_create_spec_uses_subsets_input() { fn test_set_packing_creation() { let problem = MaximumSetPacking::::new(vec![vec![0, 1], vec![1, 2], vec![3, 4]]); assert_eq!(problem.num_sets(), 3); - assert_eq!(problem.num_variables(), 3); + assert_eq!(problem.num_variables().unwrap(), 3); } #[test] diff --git a/src/unit_tests/models/set/minimum_cardinality_key.rs b/src/unit_tests/models/set/minimum_cardinality_key.rs index d73369de6..deaaa101a 100644 --- a/src/unit_tests/models/set/minimum_cardinality_key.rs +++ b/src/unit_tests/models/set/minimum_cardinality_key.rs @@ -29,8 +29,11 @@ fn test_minimum_cardinality_key_creation() { let problem = instance1(); assert_eq!(problem.num_attributes(), 6); assert_eq!(problem.num_dependencies(), 4); - assert_eq!(problem.num_variables(), 6); - assert_eq!(problem.dimensions(), vec![2; 6]); + assert_eq!(problem.num_variables().unwrap(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 6] + ); } #[test] @@ -177,3 +180,10 @@ fn test_minimum_cardinality_key_paper_example() { let witness = solver.solve(&problem).unwrap().unwrap(); assert_eq!(witness, solution); } + +#[test] +fn json_rejects_invalid_instance() { + let json = serde_json::json!({"num_attributes":3,"dependencies":[[[0,3],[1]]]}); + assert!(serde_json::from_value::(json.clone()).is_err()); + assert!(crate::registry::load_dyn("MinimumCardinalityKey", &Default::default(), json).is_err()); +} diff --git a/src/unit_tests/models/set/minimum_hitting_set.rs b/src/unit_tests/models/set/minimum_hitting_set.rs index 2b7a59b5f..af083aa4a 100644 --- a/src/unit_tests/models/set/minimum_hitting_set.rs +++ b/src/unit_tests/models/set/minimum_hitting_set.rs @@ -41,8 +41,11 @@ fn test_minimum_hitting_set_creation_accessors_and_dimensions() { assert_eq!(problem.universe_size(), 4); assert_eq!(problem.num_sets(), 2); - assert_eq!(problem.num_variables(), 4); - assert_eq!(problem.dimensions(), vec![2; 4]); + assert_eq!(problem.num_variables().unwrap(), 4); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 4] + ); assert_eq!(problem.sets(), &[vec![1, 2], vec![3]]); assert_eq!(problem.get_set(0), Some(&vec![1, 2])); assert_eq!(problem.get_set(1), Some(&vec![3])); @@ -176,3 +179,10 @@ fn test_minimum_hitting_set_canonical_example_spec() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap(), Min(Some(3))); } + +#[test] +fn json_rejects_invalid_instance() { + let json = serde_json::json!({"universe_size":3,"sets":[[0,3]]}); + assert!(serde_json::from_value::(json.clone()).is_err()); + assert!(crate::registry::load_dyn("MinimumHittingSet", &Default::default(), json).is_err()); +} diff --git a/src/unit_tests/models/set/minimum_set_covering.rs b/src/unit_tests/models/set/minimum_set_covering.rs index 299647f7c..4fa0155bf 100644 --- a/src/unit_tests/models/set/minimum_set_covering.rs +++ b/src/unit_tests/models/set/minimum_set_covering.rs @@ -1,9 +1,9 @@ use super::*; +include!("../../jl_helpers.rs"); use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; use crate::types::Min; -include!("../../jl_helpers.rs"); #[test] fn test_minimum_set_covering_create_spec_uses_subsets_input() { @@ -23,7 +23,7 @@ fn test_set_covering_creation() { let problem = MinimumSetCovering::::new(4, vec![vec![0, 1], vec![1, 2], vec![2, 3]]); assert_eq!(problem.universe_size(), 4); assert_eq!(problem.num_sets(), 3); - assert_eq!(problem.num_variables(), 3); + assert_eq!(problem.num_variables().unwrap(), 3); } #[test] @@ -138,3 +138,15 @@ fn test_setcovering_paper_example() { let best = solver.solve(&problem).unwrap().unwrap(); assert_eq!(problem.evaluate(&best).unwrap().unwrap(), 2); } + +#[test] +fn construction_and_json_reject_invalid_data() { + assert!(MinimumSetCovering::::try_new(2, vec![vec![2]]).is_err()); + assert!(MinimumSetCovering::try_with_weights(2, vec![vec![0]], Vec::::new()).is_err()); + for json in [ + serde_json::json!({"universe_size":2,"sets":[[2]],"weights":[1]}), + serde_json::json!({"universe_size":2,"sets":[[0]],"weights":[]}), + ] { + assert!(serde_json::from_value::>(json).is_err()); + } +} diff --git a/src/unit_tests/models/set/prime_attribute_name.rs b/src/unit_tests/models/set/prime_attribute_name.rs index e9550959a..a6b1b0e22 100644 --- a/src/unit_tests/models/set/prime_attribute_name.rs +++ b/src/unit_tests/models/set/prime_attribute_name.rs @@ -44,8 +44,11 @@ fn test_prime_attribute_name_creation() { assert_eq!(problem.num_attributes(), 6); assert_eq!(problem.num_dependencies(), 3); assert_eq!(problem.query_attribute(), 3); - assert_eq!(problem.num_variables(), 6); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2, 2, 2]); + assert_eq!(problem.num_variables().unwrap(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2, 2, 2] + ); assert_eq!(problem.dependencies().len(), 3); } @@ -210,3 +213,11 @@ fn test_prime_attribute_name_empty_lhs() { fn test_prime_attribute_name_dep_out_of_range() { PrimeAttributeName::new(3, vec![(vec![0], vec![5])], 0); } + +#[test] +fn json_rejects_invalid_instance() { + let json = + serde_json::json!({"num_attributes":3,"dependencies":[[[],[1]]],"query_attribute":0}); + assert!(serde_json::from_value::(json.clone()).is_err()); + assert!(crate::registry::load_dyn("PrimeAttributeName", &Default::default(), json).is_err()); +} diff --git a/src/unit_tests/models/set/rooted_tree_storage_assignment.rs b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs index 944ef4500..e87bb7c4a 100644 --- a/src/unit_tests/models/set/rooted_tree_storage_assignment.rs +++ b/src/unit_tests/models/set/rooted_tree_storage_assignment.rs @@ -1,6 +1,5 @@ use super::*; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; use crate::traits::Problem; fn yes_instance(bound: i64) -> RootedTreeStorageAssignment { @@ -21,7 +20,10 @@ fn test_rooted_tree_storage_assignment_creation() { problem.subsets(), &[vec![0, 2], vec![1, 3], vec![0, 4], vec![2, 4]] ); - assert_eq!(problem.dimensions(), vec![5; 5]); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![5; 5] + ); } #[test] diff --git a/src/unit_tests/models/set/set_basis.rs b/src/unit_tests/models/set/set_basis.rs index a1c0aabdd..8f1ae7af6 100644 --- a/src/unit_tests/models/set/set_basis.rs +++ b/src/unit_tests/models/set/set_basis.rs @@ -38,8 +38,11 @@ fn test_set_basis_creation() { assert_eq!(problem.universe_size(), 4); assert_eq!(problem.num_sets(), 4); assert_eq!(problem.basis_size(), 3); - assert_eq!(problem.num_variables(), 12); - assert_eq!(problem.dimensions(), vec![2; 12]); + assert_eq!(problem.num_variables().unwrap(), 12); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2; 12] + ); assert_eq!(problem.get_set(0), Some(&vec![0, 1])); assert_eq!(problem.get_set(4), None); } @@ -131,17 +134,11 @@ fn test_set_basis_rejects_wrong_config_length() { } #[test] -fn test_set_basis_deserialized_invalid_target_returns_false() { - let problem: SetBasis = serde_json::from_value(serde_json::json!({ - "universe_size": 4, - "collection": [[0, 4]], - "k": 1 +fn test_set_basis_deserialization_rejects_invalid_target() { + assert!(serde_json::from_value::(serde_json::json!({ + "universe_size": 4, "collection": [[0, 4]], "k": 1 })) - .unwrap(); - - assert!(!problem - .evaluate(&vec![vec![true, false, false, false]]) - .unwrap()); + .is_err()); } #[test] @@ -183,7 +180,10 @@ fn test_set_basis_is_valid_solution() { fn test_set_basis_k_zero_empty_collection() { // k = 0 with empty collection: trivially satisfiable (no targets to cover). let problem = SetBasis::new(3, vec![], 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(problem.evaluate(&vec![]).unwrap()); } @@ -191,7 +191,10 @@ fn test_set_basis_k_zero_empty_collection() { fn test_set_basis_k_zero_nonempty_collection() { // k = 0 with non-empty collection: impossible (no basis sets to cover targets). let problem = SetBasis::new(3, vec![vec![0, 1]], 0); - assert_eq!(problem.dimensions(), Vec::::new()); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + Vec::::new() + ); assert!(!problem.evaluate(&vec![]).unwrap()); } @@ -205,3 +208,10 @@ fn test_set_basis_empty_collection_with_k_positive() { assert!(problem.evaluate(&vec![vec![false; 2]; 2]).unwrap()); assert!(problem.evaluate(&vec![vec![true; 2]; 2]).unwrap()); } + +#[test] +fn json_rejects_invalid_instance() { + let json = serde_json::json!({"universe_size":3,"collection":[[0,3]],"k":1}); + assert!(serde_json::from_value::(json.clone()).is_err()); + assert!(crate::registry::load_dyn("SetBasis", &Default::default(), json).is_err()); +} diff --git a/src/unit_tests/models/set/set_splitting.rs b/src/unit_tests/models/set/set_splitting.rs index 476e97af4..ca465327b 100644 --- a/src/unit_tests/models/set/set_splitting.rs +++ b/src/unit_tests/models/set/set_splitting.rs @@ -9,7 +9,7 @@ fn test_set_splitting_creation() { let problem = SetSplitting::new(4, vec![vec![0, 1], vec![1, 2], vec![2, 3]]); assert_eq!(problem.universe_size(), 4); assert_eq!(problem.num_subsets(), 3); - assert_eq!(problem.num_variables(), 4); + assert_eq!(problem.num_variables().unwrap(), 4); } #[test] diff --git a/src/unit_tests/models/set/three_dimensional_matching.rs b/src/unit_tests/models/set/three_dimensional_matching.rs index 3ef8b0df4..9ea102aff 100644 --- a/src/unit_tests/models/set/three_dimensional_matching.rs +++ b/src/unit_tests/models/set/three_dimensional_matching.rs @@ -11,8 +11,11 @@ fn test_three_dimensional_matching_creation() { ); assert_eq!(problem.universe_size(), 3); assert_eq!(problem.num_triples(), 5); - assert_eq!(problem.num_variables(), 5); - assert_eq!(problem.dimensions(), vec![2, 2, 2, 2, 2]); + assert_eq!(problem.num_variables().unwrap(), 5); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![2, 2, 2, 2, 2] + ); } #[test] @@ -152,3 +155,12 @@ fn test_three_dimensional_matching_duplicate_coordinates() { assert!(!problem.evaluate(&vec![true, false, true]).unwrap()); // T0+T2: w={0,0} not distinct assert!(!problem.evaluate(&vec![false, true, true]).unwrap()); // T1+T2: x={1,1} not distinct } + +#[test] +fn json_rejects_invalid_instance() { + let json = serde_json::json!({"universe_size":2,"triples":[[0,2,0]]}); + assert!(serde_json::from_value::(json.clone()).is_err()); + assert!( + crate::registry::load_dyn("ThreeDimensionalMatching", &Default::default(), json).is_err() + ); +} diff --git a/src/unit_tests/models/set/two_dimensional_consecutive_sets.rs b/src/unit_tests/models/set/two_dimensional_consecutive_sets.rs index b67594e2a..57c45aee6 100644 --- a/src/unit_tests/models/set/two_dimensional_consecutive_sets.rs +++ b/src/unit_tests/models/set/two_dimensional_consecutive_sets.rs @@ -17,8 +17,11 @@ fn test_two_dimensional_consecutive_sets_creation() { ); assert_eq!(problem.alphabet_size(), 6); assert_eq!(problem.num_subsets(), 5); - assert_eq!(problem.num_variables(), 6); - assert_eq!(problem.dimensions(), vec![6, 6, 6, 6, 6, 6]); + assert_eq!(problem.num_variables().unwrap(), 6); + assert_eq!( + crate::solvers::cartesian_dimensions(&problem).unwrap(), + vec![6, 6, 6, 6, 6, 6] + ); } #[test] diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 6595c0007..76231f82e 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -6,6 +6,7 @@ use crate::models::formula::KSatisfiability; use crate::models::misc::Clustering; use crate::prelude::*; use crate::rules::{ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow}; +use crate::solvers::SolveOutcome; use crate::topology::{KingsSubgraph, SimpleGraph, UnitDiskGraph}; use crate::types::ProblemParameters; use crate::variant::{K3, KN}; @@ -56,7 +57,7 @@ fn symbolic_composition_propagates_num_colors_across_multiple_edges() { variant: ReductionGraph::variant_to_map(&KColoring::::variant()), }, ReductionStep { - name: QUBO::::NAME.to_string(), + name: Decision::>::NAME.to_string(), variant: ReductionGraph::variant_to_map(&QUBO::::variant()), }, ], @@ -151,7 +152,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] @@ -191,18 +192,20 @@ fn test_find_direct_route_by_exact_variants() { fn test_multi_step_path() { let graph = ReductionGraph::new(); - // Factoring -> CircuitSAT -> SpinGlass is a 2-step path + // Factoring -> CircuitSAT -> DecisionSpinGlass -> SpinGlass is a 3-step path let src = ReductionGraph::variant_to_map(&crate::models::misc::Factoring::variant()); let dst = ReductionGraph::variant_to_map(&SpinGlass::::variant()); 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 be a 3-step path"); assert_eq!( path.type_names(), - vec!["Factoring", "CircuitSAT", "SpinGlass"] + vec!["Factoring", "CircuitSAT", "DecisionSpinGlass", "SpinGlass"] ); } @@ -227,7 +230,7 @@ fn aggregate_mode_rejects_witness_only_real_edge() { &src, "MinimumVertexCover", &dst, - ReductionMode::Aggregate + ReductionMode::Turing ) .is_empty()); } @@ -249,13 +252,13 @@ fn variant_reduction_supports_both_modes_public_api() { ReductionMode::Witness ) .is_empty()); - assert!(!graph + assert!(graph .find_all_paths_mode( "MaximumIndependentSet", &src, "MaximumIndependentSet", &dst, - ReductionMode::Aggregate + ReductionMode::Turing ) .is_empty()); } @@ -274,7 +277,7 @@ fn value_changing_variant_cast_is_not_aggregate_capable() { &src, "MaximumSetPacking", &dst, - ReductionMode::Aggregate + ReductionMode::Turing ) .is_empty()); } @@ -326,7 +329,7 @@ fn test_subsetsum_to_integerknapsack_is_proof_only() { assert!(!graph.has_direct_reduction_by_name_mode( "SubsetSum", "IntegerKnapsack", - ReductionMode::Aggregate, + ReductionMode::Turing, )); assert!(!graph.has_direct_reduction_by_name_mode( "SubsetSum", @@ -416,7 +419,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}"); @@ -791,7 +796,7 @@ fn test_has_direct_reduction_by_name_mode() { assert!(!graph.has_direct_reduction_by_name_mode( "MaximumIndependentSet", "MinimumVertexCover", - ReductionMode::Aggregate, + ReductionMode::Turing, )); } @@ -808,7 +813,7 @@ fn test_minimumvertexcover_to_minimummaximalmatching_is_proof_only_direct_edge() assert!(!graph.has_direct_reduction_by_name_mode( "MinimumVertexCover", "MinimumMaximalMatching", - ReductionMode::Aggregate, + ReductionMode::Turing, )); assert!(!graph.has_direct_reduction_by_name_mode( "MinimumVertexCover", @@ -856,7 +861,7 @@ fn test_find_all_paths_mode_aggregate_rejects_witness_only() { &src, "MinimumVertexCover", &dst, - ReductionMode::Aggregate, + ReductionMode::Turing, ); assert!(paths.is_empty()); } @@ -868,7 +873,7 @@ fn test_decision_minimum_vertex_cover_has_both_edges() { assert!(graph.has_direct_reduction_by_name_mode( "DecisionMinimumVertexCover", "MinimumVertexCover", - ReductionMode::Aggregate, + ReductionMode::Witness, )); assert!(graph.has_direct_reduction_by_name_mode( "DecisionMinimumVertexCover", @@ -884,7 +889,7 @@ fn test_decision_minimum_dominating_set_has_both_edges() { assert!(graph.has_direct_reduction_by_name_mode( "DecisionMinimumDominatingSet", "MinimumDominatingSet", - ReductionMode::Aggregate, + ReductionMode::Witness, )); assert!(graph.has_direct_reduction_by_name_mode( "DecisionMinimumDominatingSet", @@ -900,15 +905,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, - >(ReductionMode::Aggregate)); + Decision>, + >(ReductionMode::Witness)); assert!(!graph.has_direct_reduction_mode::< Decision>, - MinMaxMulticenter, + Decision>, >(ReductionMode::Turing)); let entries = crate::rules::registry::reduction_entries(); let variant = Decision::>::variant(); @@ -916,7 +921,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 }) @@ -929,15 +934,22 @@ fn test_decision_minimum_dominating_set_to_minmax_multicenter_has_direct_witness MinimumDominatingSet::new(SimpleGraph::path(4), vec![One; 4]), bound, ); - let aggregate = (edge.reduce_aggregate_fn.unwrap())(&source).unwrap(); - assert_eq!( - *aggregate - .extract_value_from_solution_dyn(&witness) - .unwrap() - .downcast::() - .unwrap(), - Or(expected) - ); + let step = (edge.reduce_fn.unwrap())(&source).unwrap(); + let target = step + .witness + .target_problem_any() + .downcast_ref::>>() + .unwrap(); + let outcome = if expected { + SolveOutcome::optimal(target, witness).unwrap() + } else { + SolveOutcome::Infeasible + }; + let recovered = step + .witness + .recover_result_dyn(&source, crate::solvers::erase_outcome(outcome)) + .unwrap(); + assert_eq!(!matches!(recovered, SolveOutcome::Infeasible), expected); } } @@ -948,15 +960,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, - >(ReductionMode::Aggregate)); + Decision>, + >(ReductionMode::Witness)); assert!(!graph.has_direct_reduction_mode::< Decision>, - MinimumSumMulticenter, + Decision>, >(ReductionMode::Turing)); } @@ -978,7 +990,7 @@ fn test_optimization_to_decision_turing_edges() { assert!(!graph.has_direct_reduction_by_name_mode( "MinimumVertexCover", "DecisionMinimumVertexCover", - ReductionMode::Aggregate, + ReductionMode::Witness, )); // MinimumDominatingSet → DecisionMinimumDominatingSet (Turing) @@ -995,18 +1007,43 @@ fn test_ksatisfiability_k3_to_decision_minimum_vertex_cover_direct_witness_edge( 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>, - >(ReductionMode::Aggregate)); + Decision>, + >(ReductionMode::Witness)); assert!(!graph.has_direct_reduction_mode::< KSatisfiability, - Decision>, + Decision>, >(ReductionMode::Turing)); } +#[test] +fn test_ksatisfiability_k3_reaches_unit_and_integer_decision_vertex_cover_targets() { + // Variant-level NP-hardness chains: HamiltonianCircuit consumes the unit-weight + // decision cover, ComparativeContainment the integer-weight one. + let graph = ReductionGraph::new(); + let src = ReductionGraph::variant_to_map(&KSatisfiability::::variant()); + let targets = [ + ( + "HamiltonianCircuit", + HamiltonianCircuit::::variant(), + ), + ( + "ComparativeContainment", + ComparativeContainment::::variant(), + ), + ]; + + for (name, variant) in targets { + let dst = ReductionGraph::variant_to_map(&variant); + let paths = graph.find_paths_up_to("KSatisfiability", &src, name, &dst, 1); + assert_eq!(paths.len(), 1, "no witness path from 3-SAT to {name}"); + assert_eq!(paths[0].steps.last().unwrap().variant, dst); + } +} + #[test] fn test_find_paths_bounded_limits_depth() { let graph = ReductionGraph::new(); @@ -1064,16 +1101,16 @@ fn test_find_paths_bounded_returns_shortest_when_truncated() { fn edge() -> ReductionEdgeData { fn reduce( _source: &dyn std::any::Any, - ) -> std::result::Result< - Box, - crate::rules::ReductionError, - > { - Ok(Box::new(crate::rules::VariantReductionResult::< - crate::models::formula::Satisfiability, - crate::models::formula::Satisfiability, - >::new( - crate::models::formula::Satisfiability::new(0, vec![]), - ))) + ) -> std::result::Result + { + Ok(crate::rules::registry::ExecutedStep { + witness: std::rc::Rc::new(crate::rules::VariantReductionResult::< + crate::models::formula::Satisfiability, + crate::models::formula::Satisfiability, + >::new( + crate::models::formula::Satisfiability::new(0, vec![]) + )), + }) } ReductionEdgeData { @@ -1086,7 +1123,7 @@ fn test_find_paths_bounded_returns_shortest_when_truncated() { }, ), reduce_fn: Some(reduce), - reduce_aggregate_fn: None, + turing: false, } } diff --git a/src/unit_tests/registry/dispatch.rs b/src/unit_tests/registry/dispatch.rs index 2291f949d..9452c9cd6 100644 --- a/src/unit_tests/registry/dispatch.rs +++ b/src/unit_tests/registry/dispatch.rs @@ -10,6 +10,101 @@ use crate::Problem; use std::any::Any; use std::collections::BTreeMap; +#[test] +fn timetable_construction_and_loading_reject_inconsistent_dimensions() { + let input = serde_json::json!({ + "num_periods": 1, "num_craftsmen": 1, "num_tasks": 1, + "craftsman_avail": [[true]], "task_avail": [[true]], "requirements": [[1]] + }); + let variant = BTreeMap::new(); + crate::registry::construct_dyn("TimetableDesign", &variant, input.clone()).unwrap(); + load_dyn("TimetableDesign", &variant, input.clone()).unwrap(); + for field in ["craftsman_avail", "task_avail", "requirements"] { + for value in [serde_json::json!([]), serde_json::json!([[]])] { + let mut invalid = input.clone(); + invalid[field] = value; + let error = + crate::registry::construct_dyn("TimetableDesign", &variant, invalid.clone()) + .err() + .expect("inconsistent dimensions must fail"); + assert!(error.to_string().contains(field), "{error}"); + let error = load_dyn("TimetableDesign", &variant, invalid).unwrap_err(); + assert!(error.to_string().contains(field), "{error}"); + } + } +} + +#[test] +fn graph_construction_and_loading_reject_inconsistent_fields() { + use serde_json::json; + let variant = BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]); + for (name, input, mutations) in [ + ( + "MinMaxMulticenter", + json!({"graph": [[0,1]], "k": 1}), + vec![ + ("weights", "vertex_weights", json!([])), + ("edge_weights", "edge_lengths", json!([])), + ], + ), + ( + "MinimumSumMulticenter", + json!({"graph": [[0,1]], "k": 1}), + vec![ + ("weights", "vertex_weights", json!([])), + ("edge_weights", "edge_lengths", json!([])), + ], + ), + ( + "BoundedDiameterSpanningTree", + json!({"graph": [[0,1]], "weight_bound": 1, "diameter_bound": 1}), + vec![ + ("edge_weights", "edge_weights", json!([])), + ("edge_weights", "edge_weights", json!([0])), + ("weight_bound", "weight_bound", json!(0)), + ("diameter_bound", "diameter_bound", json!(0)), + ], + ), + ( + "RuralPostman", + json!({"graph": [[0,1]], "required_edges": [0]}), + vec![ + ("edge_weights", "edge_lengths", json!([])), + ("required_edges", "required_edges", json!([1])), + ], + ), + ( + "MinimumMultiwayCut", + json!({"graph": {"num_vertices": 2, "edges": [[0,1]]}, "terminals": [0,1], "edge_weights": [1]}), + vec![ + ("edge_weights", "edge_weights", json!([])), + ("terminals", "terminals", json!([0, 2])), + ], + ), + ] { + let problem = crate::registry::construct_dyn(name, &variant, input.clone()).unwrap(); + let stored = problem.serialize_json(); + load_dyn(name, &variant, stored.clone()).unwrap(); + for (create_field, stored_field, value) in mutations { + let mut invalid = input.clone(); + invalid[create_field] = value.clone(); + assert!( + crate::registry::construct_dyn(name, &variant, invalid).is_err(), + "{name}: {create_field}" + ); + let mut invalid = stored.clone(); + invalid[stored_field] = value; + assert!( + load_dyn(name, &variant, invalid).is_err(), + "{name}: {stored_field}" + ); + } + } +} + #[derive(Clone, serde::Serialize, serde::Deserialize)] struct SolutionProblem { weights: Vec, @@ -49,8 +144,12 @@ impl Problem for SolutionProblem { } impl crate::solvers::BruteForceProblem for SolutionProblem { - fn dimensions(&self) -> Vec { - vec![2; self.weights.len()] + fn num_variables(&self) -> Result { + Ok(self.weights.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -93,45 +192,26 @@ fn test_dyn_problem_blanket_impl_exposes_problem_metadata() { } #[test] -fn test_dyn_problem_formats_optimization_values_as_max_min() { +fn test_dyn_evaluation_distinguishes_infeasibility_and_malformed_input() { let problem = MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i64; 3]); let dyn_problem: &dyn DynProblem = &problem; - assert_eq!( dyn_problem .evaluate_dyn(&serde_json::json!([true, false, true])) .unwrap(), - "Max(2)" + ("Max(2)".into(), true) ); assert_eq!( dyn_problem .evaluate_dyn(&serde_json::json!([true, true, false])) .unwrap(), - "Max(None)" - ); -} - -#[test] -fn test_dyn_witness_evaluation_distinguishes_infeasibility_and_malformed_input() { - let problem = MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i64; 3]); - let dyn_problem: &dyn DynProblem = &problem; - assert_eq!( - dyn_problem - .evaluate_witness_dyn(&serde_json::json!([true, false, true])) - .unwrap(), - Some("Max(2)".into()) - ); - assert_eq!( - dyn_problem - .evaluate_witness_dyn(&serde_json::json!([true, true, false])) - .unwrap(), - None + ("Max(None)".into(), false) ); assert!(dyn_problem - .evaluate_witness_dyn(&serde_json::json!([true])) + .evaluate_dyn(&serde_json::json!([true])) .is_err()); assert!(dyn_problem - .evaluate_witness_dyn(&serde_json::json!([0, 0, 0])) + .evaluate_dyn(&serde_json::json!([0, 0, 0])) .is_err()); } @@ -370,7 +450,10 @@ fn explicit_independent_set_variants_round_trip_through_standard_api() { "Max(2.5)" }; assert_eq!(evaluation, expected, "{variant:?}"); - assert_eq!(loaded.evaluate_dyn(&solution).unwrap(), expected); + assert_eq!( + loaded.evaluate_dyn(&solution).unwrap(), + (expected.into(), true) + ); } } let mut bad = base.clone(); @@ -417,3 +500,63 @@ fn registered_weight_variants_reject_invalid_graphs_and_witnesses() { } } } + +#[derive(Clone, serde::Serialize)] +struct DirectEvaluation; + +#[derive(Clone, serde::Serialize)] +struct DirectValue(bool); + +impl crate::traits::EvaluationValue for DirectValue { + fn is_valid(&self) -> bool { + self.0 + } +} + +impl std::fmt::Display for DirectValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Problem for DirectEvaluation { + const NAME: &'static str = "DirectEvaluation"; + type Solution = bool; + type Value = DirectValue; + + fn parameter_names() -> &'static [&'static str] { + &[] + } + fn parameters(&self) -> crate::types::ProblemParameters { + Default::default() + } + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } + fn evaluate(&self, solution: &bool) -> Result { + Ok(DirectValue(*solution)) + } +} + +crate::impl_dyn_problem!(DirectEvaluation); + +#[test] +fn dynamic_evaluation_needs_neither_aggregation_nor_registration() { + let problem: &dyn DynProblem = &DirectEvaluation; + for feasible in [true, false] { + let input = serde_json::json!(feasible); + assert_eq!( + problem.evaluate_dyn(&input).unwrap(), + (feasible.to_string(), feasible) + ); + assert_eq!(problem.evaluate_json(&input).unwrap(), input); + } + assert!(problem.evaluate_dyn(&serde_json::json!([])).is_err()); + assert!(problem.evaluate_json(&serde_json::json!([])).is_err()); + assert_eq!(problem.problem_name(), "DirectEvaluation"); + assert!(problem.variant_map().is_empty()); + assert!(problem.parameter_names_dyn().is_empty()); + assert_eq!(problem.parameters_dyn(), Default::default()); + assert_eq!(problem.serialize_json(), serde_json::Value::Null); + assert!(problem.as_any().is::()); +} diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs index eb909bf25..71259a675 100644 --- a/src/unit_tests/registry/variant.rs +++ b/src/unit_tests/registry/variant.rs @@ -320,7 +320,7 @@ fn established_random_generation_models_remain_registered() { DecisionMinimumVertexCover MaximumIndependentSet MinimumVertexCover MaximumClique MinimumDominatingSet MaximalIS KClique MinimumCutIntoBoundedSets HamiltonianCircuit HamiltonianPath HamiltonianPathBetweenTwoVertices LongestCircuit MinimumMaximalMatching - RootedTreeArrangement SteinerTree SteinerTreeInGraphs LengthBoundedDisjointPaths + RootedTreeArrangement SteinerTree LengthBoundedDisjointPaths MaximumAchromaticNumber MaximumDomaticNumber MinimumCoveringByCliques MinimumIntersectionGraphBasis MaximumLeafSpanningTree GeneralizedHex BottleneckTravelingSalesman MaxCut MaximumMatching TravelingSalesman SpinGlass KColoring @@ -367,7 +367,7 @@ fn unit_variants_construct_without_unit_inputs() { "MaximumCoKPlex" => json!({"graph":graph,"k":1}), "MinimumFeedbackVertexSet" => json!({"graph":{"num_vertices":3,"arcs":[[0,1],[1,2]]}}), "MaximumSetPacking" => json!({"subsets":[[0,1],[1,2]]}), - "SteinerTree" | "SteinerTreeInGraphs" => json!({"graph":graph,"terminals":[0,2]}), + "SteinerTree" => json!({"graph":graph,"terminals":[0,2]}), "MaximumIndependentSet" => match entry.variant_map()["graph"].as_str() { "SimpleGraph" => json!({"graph":[[0,1],[1,2]]}), "KingsSubgraph" => json!({"positions":[[0,0],[1,0],[2,0]]}), @@ -377,8 +377,14 @@ 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}), + "DecisionMinimumDominatingSet" | "DecisionMinimumVertexCover" => { + json!({"graph":graph,"bound":1}) + } "MaxCut" => json!({"graph":[[0,1],[1,2]]}), + "DecisionLongestPath" => { + json!({"graph":[[0,1],[1,2]],"source_vertex":0,"target_vertex":2,"bound":2}) + } + "DecisionMinMaxMulticenter" => json!({"graph":[[0,1],[1,2]],"k":1,"bound":1}), "LongestPath" => json!({"graph":[[0,1],[1,2]],"source_vertex":0,"target_vertex":2}), "MinMaxMulticenter" => json!({"graph":[[0,1],[1,2]],"k":1}), "MixedChinesePostman" => json!({"graph":[[0,1],[1,2]],"arcs":[[2,0]]}), @@ -435,13 +441,9 @@ 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]})), - ( - "SteinerTreeInGraphs", - json!({"graph":graph,"terminals":[3]}), - ), ( "MinimumTardinessSequencing", json!({"deadlines":[1,2],"precedences":[[0,2]]}), diff --git a/src/unit_tests/rules/acyclicpartition_ilp.rs b/src/unit_tests/rules/acyclicpartition_ilp.rs index 467d43aec..50ec5acee 100644 --- a/src/unit_tests/rules/acyclicpartition_ilp.rs +++ b/src/unit_tests/rules/acyclicpartition_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::models::graph::AcyclicPartition; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -32,7 +33,14 @@ fn test_acyclicpartition_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( source.evaluate(&extracted).unwrap().0, @@ -60,7 +68,14 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).unwrap().0); } @@ -82,7 +97,10 @@ fn test_infeasible_instance() { ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_err()); + assert_eq!( + solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -107,7 +125,14 @@ fn test_acyclicpartition_to_ilp_regression_direct_topological_labels() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("the feasible source instance must yield a feasible ILP"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } diff --git a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs index f044cf0f9..9136af53e 100644 --- a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::ILP; use crate::models::graph::BalancedCompleteBipartiteSubgraph; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::topology::BipartiteGraph; use crate::traits::Problem; @@ -45,7 +46,10 @@ fn test_infeasible_instance() { ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let ilp = reduction.target_problem(); let solver = crate::solvers::ILPSolver::new(); - assert!(solver.solve(ilp).is_err()); + assert_eq!( + solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -54,7 +58,14 @@ fn test_extract_solution_identity() { let reduction: ReductionBCBSToILP = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target_sol = vec![1, 1, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&target_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, false, true, true, false]); assert!(source.evaluate(&extracted).unwrap().0); } diff --git a/src/unit_tests/rules/bicliquecover_bmf.rs b/src/unit_tests/rules/bicliquecover_bmf.rs index 739b63df8..27450f060 100644 --- a/src/unit_tests/rules/bicliquecover_bmf.rs +++ b/src/unit_tests/rules/bicliquecover_bmf.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::BMF; use crate::models::graph::BicliqueCover; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::BipartiteGraph; use crate::traits::Problem; @@ -70,7 +71,14 @@ fn test_bicliquecover_to_bmf_closed_loop_full_biclique() { .solve(target) .unwrap() .expect("target must be feasible"); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_source); } @@ -89,7 +97,14 @@ fn test_bicliquecover_to_bmf_closed_loop_identity_rank2() { .solve(target) .unwrap() .expect("target must be feasible"); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_source); } diff --git a/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs index 249dabc98..784a98e5f 100644 --- a/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs @@ -2,8 +2,11 @@ use super::*; use crate::models::algebraic::ILP; use crate::models::graph::BiconnectivityAugmentation; use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; fn small_instance() -> BiconnectivityAugmentation { @@ -30,7 +33,14 @@ fn test_biconnectivityaugmentation_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( source.evaluate(&extracted).unwrap().0, @@ -46,7 +56,14 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).unwrap().0); } @@ -59,7 +76,14 @@ fn test_trivial_single_vertex() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("trivial ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } @@ -78,7 +102,14 @@ fn test_already_biconnected() { let ilp_sol = solver .solve(ilp) .expect("already biconnected should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } @@ -112,7 +143,7 @@ fn test_biconnectivityaugmentation_to_ilp_all_two_vertex_instances() { assert!(expected); assert!( source - .evaluate(&reduction.extract_solution(&z).unwrap()) + .evaluate(&reduction.recover_result(&source, SolveOutcome::optimal(reduction.target_problem(), z.clone()).unwrap()).unwrap().into_solution().expect("qualifying target result must recover a source solution")) .unwrap() .0 ); @@ -142,7 +173,17 @@ fn test_biconnectivityaugmentation_to_ilp_empty_negative_budget() { .is_some(), budget >= 0 ); - assert_eq!(reduction.extract_solution(&vec![]).is_ok(), budget >= 0); + if budget >= 0 { + assert!(reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + .is_empty()); + } } } } @@ -156,16 +197,34 @@ fn test_biconnectivityaugmentation_to_ilp_signed_cost_and_certificate_bounds() { let z = ILPSolver::new().solve(reduction.target_problem()).unwrap(); assert!( source - .evaluate(&reduction.extract_solution(&z).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), z.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); - assert!(reduction.extract_solution(&vec![0; z.len()]).is_err()); - assert!(reduction.extract_solution(&vec![1; z.len() + 1]).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0; z.len()]) + .unwrap() + .is_valid()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![1; z.len() + 1]), + Err(InvalidConfiguration(_)) + )); for value in [-1, 2] { let mut bad = z.clone(); bad[0] = value; - assert!(reduction.extract_solution(&bad).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&bad) + .unwrap() + .is_valid()); } } } diff --git a/src/unit_tests/rules/binpacking_ilp.rs b/src/unit_tests/rules/binpacking_ilp.rs index b254153d5..4101f63b2 100644 --- a/src/unit_tests/rules/binpacking_ilp.rs +++ b/src/unit_tests/rules/binpacking_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -36,7 +37,14 @@ fn test_binpacking_to_ilp_closed_loop() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_obj, Min(Some(2))); @@ -55,7 +63,14 @@ fn test_single_item() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); @@ -71,7 +86,14 @@ fn test_same_weight_items() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(2))); @@ -87,7 +109,14 @@ fn test_exact_fill() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); @@ -109,7 +138,14 @@ fn test_solution_extraction() { ilp_solution[9] = 1; // y_0 = 1 ilp_solution[10] = 1; // y_1 = 1 - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 0]); assert!(problem.evaluate(&extracted).unwrap().is_valid()); } diff --git a/src/unit_tests/rules/bmf_bicliquecover.rs b/src/unit_tests/rules/bmf_bicliquecover.rs index 6ed1e3a2b..83905b6ba 100644 --- a/src/unit_tests/rules/bmf_bicliquecover.rs +++ b/src/unit_tests/rules/bmf_bicliquecover.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::BMF; use crate::models::graph::BicliqueCover; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; #[test] @@ -33,7 +34,14 @@ fn test_bmf_to_bicliquecover_closed_loop_all_ones() { .solve(target) .unwrap() .expect("target has feasible biclique cover"); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_source); assert!(problem.is_exact(&extracted).unwrap()); @@ -54,7 +62,14 @@ fn test_bmf_to_bicliquecover_closed_loop_identity() { .solve(target) .unwrap() .expect("target has feasible biclique cover"); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_source); assert!(problem.is_exact(&extracted).unwrap()); diff --git a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs index 5d92bc4bb..afba4fee4 100644 --- a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs @@ -1,6 +1,9 @@ use super::*; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; fn k4_btsp() -> BottleneckTravelingSalesman { @@ -34,7 +37,14 @@ fn test_bottlenecktravelingsalesman_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!( @@ -64,7 +74,14 @@ fn test_bottlenecktravelingsalesman_to_ilp_c4() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!(ilp_value.is_valid()); @@ -80,7 +97,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = problem.evaluate(&extracted).unwrap(); assert!(metric.is_valid()); } @@ -96,8 +120,9 @@ fn test_no_hamiltonian_cycle_infeasible() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); - assert!( - result.is_err(), + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), "Path graph should have no Hamiltonian cycle" ); } @@ -171,7 +196,14 @@ fn test_bottleneck_ilp_signed_full_range_and_native_cycles() { let source = BottleneckTravelingSalesman::new(SimpleGraph::new(n, edges), weights); let result = ReduceTo::>::reduce_to(&source).unwrap(); let witness = tour_witness(&source, &tour, &edge_order); - let extracted = result.extract_solution(&witness).unwrap(); + let extracted = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let expected = source.evaluate(&extracted).unwrap().unwrap(); assert_eq!( result.target_problem().evaluate(&witness).unwrap().value, @@ -181,11 +213,16 @@ fn test_bottleneck_ilp_signed_full_range_and_native_cycles() { for variable in 0..witness.len() { let mut invalid = witness.clone(); invalid[variable] = 2; - assert!(result.extract_solution(&invalid).is_err()); + assert!(!ReductionResult::target_problem(&result) + .evaluate(&invalid) + .unwrap() + .is_valid()); } - assert!(result - .extract_solution(&witness[..witness.len() - 1].to_vec()) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&result) + .evaluate(&witness[..witness.len() - 1].to_vec()), + Err(InvalidConfiguration(_)) + )); } } @@ -196,12 +233,21 @@ fn test_bottleneck_ilp_maximum_must_be_used_and_dominate() { let mut config = tour_witness(&source, &[0, 1, 2, 3], &[0, 3, 5, 2]); let selector = 4 * 4 + 2 * 6 * 4; config[selector..].fill(0); - assert!(result.extract_solution(&config).is_err()); + assert!(!ReductionResult::target_problem(&result) + .evaluate(&config) + .unwrap() + .is_valid()); config[selector] = 1; // used, but lower than the maximum edge - assert!(result.extract_solution(&config).is_err()); + assert!(!ReductionResult::target_problem(&result) + .evaluate(&config) + .unwrap() + .is_valid()); config[selector] = 0; config[selector + 1] = 1; // unused - assert!(result.extract_solution(&config).is_err()); + assert!(!ReductionResult::target_problem(&result) + .evaluate(&config) + .unwrap() + .is_valid()); } #[test] @@ -226,9 +272,10 @@ fn test_bottleneck_ilp_dimensions_and_malformed_weights() { for (n, m) in [(usize::MAX, 0), (1, usize::MAX), (0, usize::MAX)] { assert!(ReductionBTSPToILP::dimensions(n, m).is_err()); } - let source: BottleneckTravelingSalesman = serde_json::from_value(serde_json::json!({ - "graph": {"num_vertices": 0, "edges": []}, "edge_weights": [1] - })) - .unwrap(); - assert!(ReduceTo::>::reduce_to(&source).is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "graph": {"num_vertices": 0, "edges": []}, "edge_weights": [1] + })) + .is_err() + ); } diff --git a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs index 039d7f2c3..ed41a8bab 100644 --- a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::models::graph::BoundedComponentSpanningForest; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -31,7 +32,14 @@ fn test_boundedcomponentspanningforest_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( source.evaluate(&extracted).unwrap().0, @@ -47,7 +55,14 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).unwrap().0); } @@ -68,7 +83,14 @@ fn test_single_component() { let ilp_sol = solver .solve(ilp) .expect("single component should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } @@ -85,7 +107,10 @@ fn test_infeasible_instance() { ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_err()); + assert_eq!( + solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] diff --git a/src/unit_tests/rules/capacityassignment_ilp.rs b/src/unit_tests/rules/capacityassignment_ilp.rs index e0053dfb1..30ec31ee9 100644 --- a/src/unit_tests/rules/capacityassignment_ilp.rs +++ b/src/unit_tests/rules/capacityassignment_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -65,7 +66,14 @@ fn test_capacityassignment_to_ilp_closed_loop() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!( ilp_value, bf_value, @@ -85,13 +93,18 @@ fn test_solution_extraction() { let reduction: ReductionCAToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - // link 0 → cap 1, link 1 → cap 0 - // x_{0,0}=0, x_{0,1}=1, x_{0,2}=0, x_{1,0}=1, x_{1,1}=0, x_{1,2}=0 - let ilp_solution = vec![0, 1, 0, 1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); - assert_eq!(extracted, vec![1, 0]); - // Verify extraction works (evaluation may or may not be feasible) - let _ = problem.evaluate(&extracted).unwrap(); + // Both links choose capacity level 1: total delay 4 + 3 <= 10. + let ilp_solution = vec![0, 1, 0, 0, 1, 0]; + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert_eq!(extracted, vec![1, 1]); + assert!(problem.evaluate(&extracted).unwrap().is_valid()); } #[test] @@ -108,7 +121,14 @@ fn test_capacityassignment_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } diff --git a/src/unit_tests/rules/circuit_ilp.rs b/src/unit_tests/rules/circuit_ilp.rs index 044e10148..0435d30b5 100644 --- a/src/unit_tests/rules/circuit_ilp.rs +++ b/src/unit_tests/rules/circuit_ilp.rs @@ -1,7 +1,10 @@ use super::*; use crate::models::formula::{Assignment, BooleanExpr, Circuit, CircuitSAT}; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Or; @@ -103,7 +106,14 @@ fn test_circuit_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); } @@ -140,11 +150,25 @@ fn test_circuit_ilp_native_folds_all_feasible_witnesses() { .map(|i| if (mask >> i) & 1 == 0 { 0 } else { 1 }) .collect(); if target.evaluate(&solution).unwrap().value.is_some() { - let extracted = reduction.extract_solution(&solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()) + .unwrap(), + ) + .map(|result| { + result.into_solution().expect( + "qualifying target result must recover a source solution", + ) + }) + .unwrap(); assert!(source.evaluate(&extracted).unwrap().0); actual.insert(extracted); } else { - assert!(reduction.extract_solution(&solution).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&solution) + .unwrap() + .is_valid()); } } assert_eq!(actual, expected, "{expr:?}, output={output}"); @@ -160,14 +184,30 @@ fn test_circuit_ilp_rejects_invalid_target_and_supports_empty_circuit() { BooleanExpr::constant(true), )])); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - for invalid in [vec![], vec![0], vec![0, 0], vec![2, 1], vec![1, 1, 1]] { - assert!(reduction.extract_solution(&invalid).is_err()); + for invalid in [vec![], vec![0], vec![1, 1, 1]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&invalid), + Err(InvalidConfiguration(_)) + )); + } + for invalid in [vec![0, 0], vec![2, 1]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&invalid) + .unwrap() + .is_valid()); } let empty = CircuitSAT::new(Circuit::new(vec![])); let reduction = ReduceTo::>::reduce_to(&empty).unwrap(); assert_eq!(reduction.target_problem().num_vars(), 0); assert_eq!( - reduction.extract_solution(&vec![]).unwrap(), + reduction + .recover_result( + &empty, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); } diff --git a/src/unit_tests/rules/circuit_sat.rs b/src/unit_tests/rules/circuit_sat.rs index be07e6372..9f5168584 100644 --- a/src/unit_tests/rules/circuit_sat.rs +++ b/src/unit_tests/rules/circuit_sat.rs @@ -3,6 +3,7 @@ use crate::models::formula::{Assignment, BooleanExpr, Circuit, CircuitSAT, Satis use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn contradiction_source() -> CircuitSAT { @@ -28,7 +29,14 @@ fn test_circuitsat_to_satisfiability_closed_loop() { .solve(reduction.target_problem()) .unwrap() .expect("issue example should yield a SAT witness"); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), source.num_variables()); assert!(source.evaluate(&extracted).unwrap().0); } diff --git a/src/unit_tests/rules/circuit_spinglass.rs b/src/unit_tests/rules/circuit_spinglass.rs index d44aaec9d..9ad670180 100644 --- a/src/unit_tests/rules/circuit_spinglass.rs +++ b/src/unit_tests/rules/circuit_spinglass.rs @@ -1,11 +1,15 @@ 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::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; use crate::types::{NumericSize, WeightElement}; use num_traits::Num; -include!("../jl_helpers.rs"); /// Verify a gadget has the correct ground states. fn verify_gadget_truth_table(gadget: &LogicGadget, expected: &[(Vec, Vec)]) @@ -147,7 +151,7 @@ fn test_constant_true() { BooleanExpr::constant(true), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); let sg = reduction.target_problem(); @@ -156,7 +160,16 @@ fn test_constant_true() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s).unwrap()) + .map(|s| { + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), (s).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }) .collect(); // c should be 1 @@ -175,7 +188,7 @@ fn test_constant_false() { BooleanExpr::constant(false), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); let sg = reduction.target_problem(); @@ -184,7 +197,16 @@ fn test_constant_false() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s).unwrap()) + .map(|s| { + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), (s).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }) .collect(); // c should be 0 @@ -207,7 +229,7 @@ fn test_multi_input_and() { ]), )]); let problem = CircuitSAT::new(circuit); - let reduction = ReduceTo::>::reduce_to(&problem) + let reduction = ReduceTo::>>::reduce_to(&problem) .expect("reduction should succeed"); let sg = reduction.target_problem(); @@ -216,7 +238,16 @@ fn test_multi_input_and() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s).unwrap()) + .map(|s| { + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), (s).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }) .collect(); // Variables sorted: c, x, y, z @@ -241,24 +272,24 @@ 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(); - assert!(sg.num_spins() >= 2); // At least c and x + assert!(sg.inner().num_spins() >= 2); // At least c and x } #[test] 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(); // Empty circuit should result in empty SpinGlass - assert_eq!(sg.num_spins(), 0); + assert_eq!(sg.inner().num_spins(), 0); } #[test] @@ -268,7 +299,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) @@ -277,7 +308,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(); - assert!(sg.num_spins() >= 3); // At least c, x, y + assert!(sg.inner().num_spins() >= 3); // At least c, x, y } #[test] @@ -299,9 +330,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", @@ -310,7 +341,6 @@ fn test_jl_parity_circuitsat_to_spinglass() { #[test] fn test_circuit_spinglass_all_threshold_witnesses_native_domain() { - use crate::rules::AggregateReductionResult; use std::collections::BTreeSet; let x = BooleanExpr::var("x"); let y = BooleanExpr::var("y"); @@ -340,58 +370,102 @@ 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 = crate::rules::ReductionResult::target_problem(&reduction); let expected: BTreeSet<_> = BruteForce::new() .find_all_witnesses(&source) .unwrap() .into_iter() .collect(); let mut actual = BTreeSet::new(); - for mask in 0..(1usize << target.num_spins()) { - let spins = (0..target.num_spins()) + for mask in 0..(1usize << target.inner().num_spins()) { + let spins = (0..target.inner().num_spins()) .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 { - let decoded = reduction.extract_solution(&spins).unwrap(); + let energy = target.inner().evaluate(&spins).unwrap(); + assert!(energy.0.unwrap() >= *ReductionResult::target_problem(&reduction).bound()); + if crate::types::Or(OptimizationValue::meets_bound( + &(energy), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + )) + .0 + { + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), spins.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&decoded).unwrap().0); actual.insert(decoded); } else { - assert!(reduction.extract_solution(&spins).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&spins) + .unwrap() + .is_valid()); } } assert_eq!(actual, expected, "expression {expr:?}, output {output}"); - assert!(!reduction.extract_value(crate::types::Min(None)).0); + assert!( + !crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); } } } #[test] fn test_circuit_spinglass_unsat_threshold_and_invalid_spins() { - use crate::rules::AggregateReductionResult; let source = CircuitSAT::new(Circuit::new(vec![Assignment::new( 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); - for witness in BruteForce::new() + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); + assert_eq!(*ReductionResult::target_problem(&reduction).bound(), -5); + assert!( + !crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(Some(-3))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); + assert!(BruteForce::new() .find_all_witnesses(ReductionResult::target_problem(&reduction)) .unwrap() - { - assert!(reduction.extract_solution(&witness).is_err()); - } + .is_empty()); for bad in [vec![], vec![1], vec![0, 0], vec![1, 1, 1]] { - assert!(reduction.extract_solution(&bad).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&bad), + Err(InvalidConfiguration(_)) + )); } 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!( + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(Some(0))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); assert_eq!( - reduction.extract_solution(&vec![]).unwrap(), + reduction + .recover_result( + &empty, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); } @@ -420,19 +494,24 @@ 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 reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); let target = reduction.target_problem(); let expected = if width == 0 { 1 } else { width + 2 * (width - 1) }; - assert_eq!(target.num_spins(), expected); - assert!(target.num_spins() <= source.num_variables() + 3 * source.num_expression_nodes()); + assert_eq!(target.inner().num_spins(), expected); + assert!( + target.inner().num_spins() + <= source.num_variables() + 3 * source.num_expression_nodes() + ); if width == 5 { - assert_eq!(target.num_spins(), 13); + assert_eq!(target.inner().num_spins(), 13); assert!( - target.num_spins() > source.num_variables() + 2 * source.num_expression_nodes() + target.inner().num_spins() + > source.num_variables() + 2 * source.num_expression_nodes() ); } } diff --git a/src/unit_tests/rules/closeststring_ilp.rs b/src/unit_tests/rules/closeststring_ilp.rs index 5714565a7..7c79f79a1 100644 --- a/src/unit_tests/rules/closeststring_ilp.rs +++ b/src/unit_tests/rules/closeststring_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::misc::ClosestString; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -59,7 +60,14 @@ fn test_closeststring_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let extracted_value = source.evaluate(&extracted).unwrap(); // The extracted center must be syntactically valid and match the BF optimum. @@ -89,7 +97,14 @@ fn test_closeststring_to_ilp_extract_known_center() { target_solution[4] = 1; // x_{2,0} target_solution[6] = 2; // R = 2 - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 0]); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(2))); } @@ -100,12 +115,10 @@ fn test_closeststring_to_ilp_rejects_missing_one_hot_symbol() { let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target_solution = vec![0; reduction.target_problem().num_vars()]; - assert_eq!( - reduction - .extract_solution(&target_solution) - .unwrap_err() - .to_string(), - "center position 0 has no selected symbol" + assert!( + !crate::traits::Problem::evaluate(reduction.target_problem(), &target_solution) + .unwrap() + .is_valid() ); } @@ -135,7 +148,14 @@ fn test_closeststring_to_ilp_single_string_zero_radius() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![1, 0, 1, 1]); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/rules/closestsubstring_ilp.rs b/src/unit_tests/rules/closestsubstring_ilp.rs index bf3a9fed5..bf567ff91 100644 --- a/src/unit_tests/rules/closestsubstring_ilp.rs +++ b/src/unit_tests/rules/closestsubstring_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::misc::ClosestSubstring; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -77,12 +78,10 @@ fn test_closestsubstring_to_ilp_rejects_missing_one_hot_symbol() { let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target_solution = vec![0; reduction.target_problem().num_vars()]; - assert_eq!( - reduction - .extract_solution(&target_solution) - .unwrap_err() - .to_string(), - "center position 0 has no selected value" + assert!( + !crate::traits::Problem::evaluate(reduction.target_problem(), &target_solution) + .unwrap() + .is_valid() ); } @@ -97,7 +96,14 @@ fn test_closestsubstring_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Extracted config must be syntactically valid (length ell + n = 6) and // match the brute-force optimum. @@ -131,7 +137,14 @@ fn test_closestsubstring_to_ilp_zero_radius_when_common_substring_exists() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let extracted_value = source.evaluate(&extracted).unwrap(); assert!(extracted_value.is_valid()); @@ -177,7 +190,14 @@ fn test_closestsubstring_to_ilp_extract_known_solution() { target_solution[6 + 6] = 1; // y_{3, 0} target_solution[ilp.num_vars() - 1] = 1; // R = 1 - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 0, 0, 1, 0]); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(1))); } 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..a6d977552 100644 --- a/src/unit_tests/rules/closestvectorproblem_qubo.rs +++ b/src/unit_tests/rules/closestvectorproblem_qubo.rs @@ -1,8 +1,9 @@ use super::*; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; 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() } @@ -57,9 +58,16 @@ fn test_closestvectorproblem_to_qubo_twelve_dimensional_identity() { } assert_eq!(offset, 0); } - let solution = reduction.extract_solution(&bits).unwrap(); + let solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), bits.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); 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] @@ -70,10 +78,17 @@ fn test_closestvectorproblem_to_qubo_closed_loop() { .solve(reduction.target_problem()) .unwrap() .unwrap(); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); 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); } @@ -82,24 +97,42 @@ fn test_closestvectorproblem_to_qubo_coefficients() { let reduction = ReduceTo::>::reduce_to(&canonical_cvp()).unwrap(); let qubo = reduction.target_problem(); - assert_eq!(qubo.get(0, 0), Some(&-248)); - assert_eq!(qubo.get(0, 1), Some(&16)); - assert_eq!(qubo.get(0, 6), Some(&4)); - assert_eq!(qubo.get(6, 6), Some(&-241)); + assert_eq!(qubo.get(0, 0), Some(-248)); + assert_eq!(qubo.get(0, 1), Some(16)); + assert_eq!(qubo.get(0, 6), Some(4)); + assert_eq!(qubo.get(6, 6), Some(-241)); } #[test] fn test_closestvectorproblem_to_qubo_exact_range_decoding() { let reduction = ReduceTo::>::reduce_to(&canonical_cvp()).unwrap(); assert_eq!( - reduction.extract_solution(&canonical_bits()).unwrap(), + reduction + .recover_result( + &canonical_cvp(), + SolveOutcome::optimal(reduction.target_problem(), canonical_bits().clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![1, 1] ); let duplicate = vec![ true, false, false, true, false, true, true, true, true, true, false, ]; - assert_eq!(reduction.extract_solution(&duplicate).unwrap(), vec![1, 1]); + assert_eq!( + reduction + .recover_result( + &canonical_cvp(), + SolveOutcome::optimal(reduction.target_problem(), duplicate.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![1, 1] + ); assert_eq!( reduction .target_problem() @@ -118,7 +151,14 @@ fn test_closestvectorproblem_to_qubo_preserves_optimum_outside_old_box() { .unwrap() .unwrap(); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![20] ); } @@ -156,3 +196,25 @@ fn test_closestvectorproblem_to_qubo_canonical_example_spec() { serde_json::to_value(canonical_bits()).unwrap() ); } + +#[test] +fn qubo_energy_matches_squared_distance_up_to_the_dropped_constant() { + let source = ClosestVectorProblem::new(vec![vec![2]], vec![1_i64]).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + assert_eq!(target.num_vars(), 3); + // The all-zero encoding represents x=-2, with squared distance (-4-1)^2=25. + for index in 0..8 { + let bits: Vec = (0..3).map(|bit| index & (1 << bit) != 0).collect(); + let coefficient = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), bits.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + let energy = target.evaluate(&bits).unwrap().unwrap(); + assert_eq!(source.squared_distance(&coefficient).unwrap(), energy + 25); + } +} diff --git a/src/unit_tests/rules/clustering_ilp.rs b/src/unit_tests/rules/clustering_ilp.rs index 2142986ba..40fac843a 100644 --- a/src/unit_tests/rules/clustering_ilp.rs +++ b/src/unit_tests/rules/clustering_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; @@ -64,8 +65,17 @@ fn test_clustering_to_ilp_solution_extraction() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let extracted = reduction - .extract_solution(&vec![1, 0, 1, 0, 0, 1, 0, 1]) - .unwrap(); + .recover_result( + &problem, + SolveOutcome::optimal( + reduction.target_problem(), + vec![1, 0, 1, 0, 0, 1, 0, 1].clone(), + ) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 1, 1]); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -76,5 +86,8 @@ fn test_clustering_to_ilp_infeasible_instance_is_infeasible() { let reduction: ReductionClusteringToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } diff --git a/src/unit_tests/rules/coloring_ilp.rs b/src/unit_tests/rules/coloring_ilp.rs index 238d2cbaf..a54fda858 100644 --- a/src/unit_tests/rules/coloring_ilp.rs +++ b/src/unit_tests/rules/coloring_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::variant::{K1, K2, K3, K4, KN}; @@ -76,7 +77,14 @@ fn test_coloring_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Verify the extracted solution is valid for the original problem assert!( @@ -101,7 +109,14 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Verify validity assert!( @@ -126,8 +141,9 @@ fn test_ilp_infeasible_triangle_2_colors() { // ILP should be infeasible let result = ilp_solver.solve(ilp); - assert!( - result.is_err(), + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), "Triangle with 2 colors should be infeasible" ); } @@ -143,7 +159,14 @@ fn test_solution_extraction() { // vertex 2 has color 0 (x_{2,0} = 1) // Variables are indexed as: v0c0, v0c1, v0c2, v1c0, v1c1, v1c2, v2c0, v2c1, v2c2 let ilp_solution = vec![0, 1, 0, 0, 0, 1, 1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![1, 2, 0]); @@ -176,7 +199,14 @@ fn test_empty_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap()); } @@ -193,7 +223,14 @@ fn test_complete_graph_k4() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap()); @@ -216,7 +253,11 @@ fn test_complete_graph_k4_with_3_colors_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_err(), "K4 with 3 colors should be infeasible"); + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), + "K4 with 3 colors should be infeasible" + ); } #[test] @@ -230,7 +271,14 @@ fn test_bipartite_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap()); @@ -248,7 +296,14 @@ fn test_reduction_closed_loop() { let target_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("target ILP should be solvable"); - let solution = reduction.extract_solution(&target_solution).unwrap(); + let solution = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&solution).unwrap()); } @@ -265,7 +320,14 @@ fn test_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0]); } @@ -279,7 +341,14 @@ fn test_single_edge() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap()); assert_ne!(extracted[0], extracted[1]); diff --git a/src/unit_tests/rules/coloring_qubo.rs b/src/unit_tests/rules/coloring_qubo.rs index 2b4ea3793..54ce4b6e2 100644 --- a/src/unit_tests/rules/coloring_qubo.rs +++ b/src/unit_tests/rules/coloring_qubo.rs @@ -1,14 +1,20 @@ use super::*; +use crate::models::decision::Decision; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; use crate::variant::{K2, K3}; #[test] fn test_kcoloring_to_qubo_closed_loop() { // Triangle K3, 3 colors → exactly 6 valid colorings (3! permutations) let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)])); - let reduction = ReduceTo::>::reduce_to(&kc).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&kc).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); @@ -16,7 +22,14 @@ fn test_kcoloring_to_qubo_closed_loop() { // All solutions should extract to valid colorings for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &kc, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(kc.evaluate(&extracted).unwrap()); } @@ -28,14 +41,22 @@ fn test_kcoloring_to_qubo_closed_loop() { fn test_kcoloring_to_qubo_path() { // Path graph: 0-1-2, 2 colors let kc = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])); - let reduction = ReduceTo::>::reduce_to(&kc).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&kc).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &kc, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(kc.evaluate(&extracted).unwrap()); } @@ -48,14 +69,22 @@ fn test_kcoloring_to_qubo_reversed_edges() { // Edge (2, 0) triggers the idx_v < idx_u swap branch (line 104). // Path: 2-0-1 with reversed edge ordering let kc = KColoring::::new(SimpleGraph::new(3, vec![(2, 0), (0, 1)])); - let reduction = ReduceTo::>::reduce_to(&kc).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&kc).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &kc, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(kc.evaluate(&extracted).unwrap()); } @@ -66,15 +95,15 @@ 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().num_variables().unwrap(), 9); } #[test] fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { - use crate::rules::AggregateReductionResult; for n in 0..=3 { let possible: Vec<_> = (0..n) .flat_map(|u| ((u + 1)..n).map(move |v| (u, v))) @@ -87,9 +116,9 @@ 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); - assert_eq!(target.num_vars(), n * k); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); + let target = crate::rules::ReductionResult::target_problem(&reduction); + assert_eq!(target.inner().num_vars(), n * k); let mut minimum = i64::MAX; let mut any_coloring = false; for bits in 0..(1usize << (n * k)) { @@ -108,35 +137,56 @@ fn test_kcoloring_to_qubo_all_small_graphs_and_configurations() { let penalty = (n + 1) as i64; let residual = 2 * counts.iter().map(|&count| (1 - count).pow(2)).sum::() + conflicts; - let value = target.evaluate(&config).unwrap(); + let value = target.inner().evaluate(&config).unwrap(); assert_eq!(value.0, Some(penalty * residual - 2 * penalty * n as i64)); minimum = minimum.min(value.0.unwrap()); let expected = residual == 0; assert_eq!( - AggregateReductionResult::extract_value(&reduction, value).0, + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0, expected ); - match reduction.extract_solution(&config) { - Ok(coloring) => { - assert!(expected); - assert!(source.evaluate(&coloring).unwrap().0); - any_coloring = true; - } - Err(_) => assert!(!expected), + if expected { + let coloring = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()) + .unwrap(), + ) + .map(|result| { + result.into_solution().expect( + "qualifying target result must recover a source solution", + ) + }) + .unwrap(); + assert!(source.evaluate(&coloring).unwrap().0); + any_coloring = true; } } assert_eq!( - AggregateReductionResult::extract_value( - &reduction, - crate::types::Min(Some(minimum)) - ) + crate::types::Or(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 + !crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 ); - assert!(reduction.extract_solution(&vec![false; n * k + 1]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![false; n * k + 1]), + Err(InvalidConfiguration(_)) + )); } } } diff --git a/src/unit_tests/rules/consecutiveblockminimization_ilp.rs b/src/unit_tests/rules/consecutiveblockminimization_ilp.rs index f778a2f96..b8f0015e6 100644 --- a/src/unit_tests/rules/consecutiveblockminimization_ilp.rs +++ b/src/unit_tests/rules/consecutiveblockminimization_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -48,7 +49,14 @@ fn test_cbm_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs b/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs index 1a9169afe..b00fbfc7c 100644 --- a/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -33,7 +34,14 @@ fn test_coma_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); // Also verify that brute-force on the source agrees @@ -59,7 +67,14 @@ fn test_coma_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs b/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs index b6a7ecf2f..135f0c650 100644 --- a/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -43,7 +44,14 @@ fn test_cos_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); // Verify brute-force on source agrees @@ -73,7 +81,14 @@ fn test_cos_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -93,7 +108,14 @@ fn test_cos_to_ilp_allows_zero_rows_in_selected_submatrix() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("a single selected column always has C1P"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -107,6 +129,13 @@ fn test_cos_to_ilp_trivial() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs index c9e07b866..35b03d1e6 100644 --- a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::misc::{ConsistencyOfDatabaseFrequencyTables, FrequencyTable, KnownValue}; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -55,7 +56,14 @@ fn test_cdft_to_ilp_solution_encoding_round_trip() { let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = reduction.encode_source_solution(&small_yes_witness()); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, small_yes_witness()); } @@ -65,7 +73,10 @@ fn test_cdft_to_ilp_unsat_instance_is_infeasible() { let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let solver = ILPSolver::new(); - assert!(solver.solve(reduction.target_problem()).is_err()); + assert_eq!( + solver.solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -93,7 +104,14 @@ fn test_consistency_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap()); } @@ -126,7 +144,14 @@ fn test_cdft_to_ilp_issue_instance_closed_loop() { let target_solution = solver .solve(reduction.target_problem()) .expect("ILP solver should find a feasible solution for the issue instance"); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&source_solution).unwrap(), "extracted source solution must satisfy the original CDFT instance" @@ -139,6 +164,13 @@ fn test_cdft_to_ilp_issue_instance_encoding_round_trip() { let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = reduction.encode_source_solution(&issue_witness()); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, issue_witness()); } diff --git a/src/unit_tests/rules/decisionmaximumindependentset_integralflowbundles.rs b/src/unit_tests/rules/decisionmaximumindependentset_integralflowbundles.rs index 50c1b776d..1beb9e2f6 100644 --- a/src/unit_tests/rules/decisionmaximumindependentset_integralflowbundles.rs +++ b/src/unit_tests/rules/decisionmaximumindependentset_integralflowbundles.rs @@ -1,5 +1,8 @@ use super::*; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Or; @@ -23,7 +26,17 @@ fn test_decisionmaximumindependentset_to_integralflowbundles_closed_loop() { assert_eq!(witness.is_some(), bound == 2); if let Some(witness) = witness { assert_eq!( - source.evaluate(&reduction.extract_solution(&witness).unwrap()), + source.evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ), Ok(Or(true)) ); } @@ -71,7 +84,21 @@ fn test_decision_ifb_all_small_graphs_thresholds_and_binary_flows() { if target.evaluate(&flow).unwrap().0 { target_exists = true; assert_eq!( - source.evaluate(&reduction.extract_solution(&flow).unwrap()), + source.evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + flow.clone() + ) + .unwrap() + ) + .map(|result| result.into_solution().expect( + "qualifying target result must recover a source solution" + )) + .unwrap() + ), Ok(Or(true)) ); } @@ -91,18 +118,31 @@ fn test_decision_ifb_loops_parallel_edges_and_invalid_witnesses() { let reduction = ReduceTo::::reduce_to(&source).unwrap(); let valid = vec![0, 0, 1, 1, 1, 1, 1, 1]; assert_eq!( - reduction.extract_solution(&valid).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), valid.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, true, true] ); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); for flow in [ - vec![], vec![usize::MAX; 8], vec![0; 8], vec![0, 0, 1, 0, 1, 1, 1, 1], // conservation vec![1, 1, 0, 0, 1, 1, 1, 1], // self-loop vec![0, 0, 2, 2, 1, 1, 1, 1], // path capacity ] { - assert!(reduction.extract_solution(&flow).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&flow) + .unwrap() + .is_valid()); } } diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 5f39c738d..c32542944 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -2,9 +2,12 @@ use crate::models::decision::Decision; use crate::models::graph::{MinimumDominatingSet, MinimumSumMulticenter}; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; -use crate::types::{Min, One, Or}; +use crate::types::One; +use crate::types::OptimizationValue; fn decision_mds( num_vertices: usize, @@ -27,22 +30,28 @@ 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 reduction = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); assert_eq!( - crate::rules::AggregateReductionResult::target_problem(&reduction).k(), - target.k() + crate::rules::ReductionResult::target_problem(&reduction) + .inner() + .k(), + target.inner().k() ); assert_eq!( - target.graph().num_vertices(), + target.inner().graph().num_vertices(), source.inner().graph().num_vertices() + 1 ); - assert_eq!(target.graph().edges(), source.inner().graph().edges()); - assert_eq!(target.vertex_weights(), vec![1i64; 7].as_slice()); - assert_eq!(target.edge_lengths(), vec![1i64; 7].as_slice()); - assert_eq!(target.k(), 3); + assert_eq!( + target.inner().graph().edges(), + source.inner().graph().edges() + ); + assert_eq!(target.inner().vertex_weights(), vec![1i64; 7].as_slice()); + assert_eq!(target.inner().edge_lengths(), vec![1i64; 7].as_slice()); + assert_eq!(target.inner().k(), 3); } #[test] @@ -52,8 +61,9 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_yes_in &[(0, 1), (0, 2), (1, 3), (2, 3), (3, 4), (3, 5), (4, 5)], 2, ); - let reduction = ReduceTo::>::reduce_to(&source) - .expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); let target_solutions = BruteForce::new().find_all_witnesses(target).unwrap(); @@ -63,10 +73,20 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_yes_in ); for target_solution in target_solutions { - assert_eq!(target.evaluate(&target_solution).unwrap().unwrap(), 4); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + assert_eq!( + target.inner().evaluate(&target_solution).unwrap().unwrap(), + 4 + ); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, target_solution[..6]); - assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); + assert_eq!(source.evaluate(&extracted).unwrap(), crate::types::Or(true)); } } @@ -77,11 +97,15 @@ 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 reduction = + ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); + assert!(BruteForce::new().solve(target).unwrap().is_none()); - let target_solutions = BruteForce::new().find_all_witnesses(target).unwrap(); + let target_solutions = BruteForce::new() + .find_all_witnesses(target.inner()) + .unwrap(); assert!( !target_solutions.is_empty(), "target should still have optimal K-center placements" @@ -90,18 +114,17 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins let threshold = i64::try_from(source.inner().graph().num_vertices()).unwrap() - i64::try_from(source.k()).unwrap(); for target_solution in target_solutions { - let target_value = target.evaluate(&target_solution).unwrap().unwrap(); + let target_value = target.inner().evaluate(&target_solution).unwrap().unwrap(); assert_eq!(target_value, 6); assert!(target_value > threshold); assert_eq!( - crate::rules::AggregateReductionResult::extract_value( - &reduction, - Min(Some(target_value)) - ), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(Some(target_value))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(false) ); - assert!(reduction.extract_solution(&target_solution).is_err()); } } @@ -127,39 +150,53 @@ 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(); + ReduceTo::>>::reduce_to( + &source, + ) + .unwrap(); let target = reduction.target_problem(); - assert!(target.num_vertices() <= n + 2); - assert_eq!(target.num_edges(), edges.len()); + assert!(target.inner().num_vertices() <= n + 2); + assert_eq!(target.inner().num_edges(), edges.len()); let source_yes = BruteForce::new().solve(&source).unwrap().is_some(); let mut optimum = None; - for mask in 0usize..1 << target.num_vertices() { - let placement: Vec<_> = (0..target.num_vertices()) + for mask in 0usize..1 << target.inner().num_vertices() { + let placement: Vec<_> = (0..target.inner().num_vertices()) .map(|i| mask & (1 << i) != 0) .collect(); - let value = target.evaluate(&placement).unwrap(); + let value = target.inner().evaluate(&placement).unwrap(); 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; - match reduction.extract_solution(&placement) { - Ok(witness) => { - assert!(accepted); - assert_eq!(source.evaluate(&witness).unwrap(), Or(true)); - } - Err(_) => assert!(!accepted), + let accepted = crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + )) + .0; + if accepted { + let witness = reduction + .recover_result( + &source, + SolveOutcome::feasible(target, placement).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); + assert_eq!(source.evaluate(&witness).unwrap(), crate::types::Or(true)); } } assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Min(optimum)), - Or(source_yes), + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(optimum)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(source_yes), "n={n}, edges={edges:?}, K={bound}" ); - assert!(reduction - .extract_solution(&vec![false; target.num_vertices() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![false; target.inner().num_vertices() + 1]), + Err(InvalidConfiguration(_)) + )); } } } diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs index 9e15b4e34..4a212d982 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -1,6 +1,11 @@ use super::*; +use crate::models::decision::Decision; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; fn decision_mds( n: usize, @@ -16,33 +21,58 @@ 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 reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); let target = reduction.target_problem(); - assert_eq!(target.num_vertices(), 5); - assert_eq!(target.num_edges(), 2); - assert_eq!(target.k(), 3); - for witness in BruteForce::new().find_all_witnesses(target).unwrap() { + assert_eq!(target.inner().num_vertices(), 5); + assert_eq!(target.inner().num_edges(), 2); + assert_eq!(target.inner().k(), 3); + for witness in BruteForce::new() + .find_all_witnesses(target.inner()) + .unwrap() + { assert!(witness[3] && witness[4]); assert!( source - .evaluate(&reduction.extract_solution(&witness).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); } let source = decision_mds(4, &[(0, 1), (1, 2), (2, 3)], 1); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let witness = BruteForce::new() + let reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); + assert!(BruteForce::new() .solve(reduction.target_problem()) .unwrap() + .is_none()); + let witness = BruteForce::new() + .solve(reduction.target_problem().inner()) + .unwrap() + .unwrap(); + let optimum = reduction + .target_problem() + .inner() + .evaluate(&witness) .unwrap(); - let optimum = reduction.target_problem().evaluate(&witness).unwrap(); - assert_eq!(optimum, Min(Some(2))); + assert_eq!(optimum, crate::types::Min(Some(2))); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, optimum), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(optimum), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(false) ); - assert!(reduction.extract_solution(&witness).is_err()); } #[test] @@ -59,13 +89,14 @@ fn test_multicenter_all_small_graphs_bounds_and_placements() { 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(); + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = reduction.target_problem(); - assert_eq!(target.graph().edges(), edges); - assert_eq!(target.num_vertices(), n + 2); - assert_eq!(target.vertex_weights(), vec![One; n + 2]); - assert_eq!(target.edge_lengths(), vec![One; edges.len()]); - assert!((1..=n + 2).contains(&target.k())); + assert_eq!(target.inner().graph().edges(), edges); + assert_eq!(target.inner().num_vertices(), n + 2); + assert_eq!(target.inner().vertex_weights(), vec![One; n + 2]); + assert_eq!(target.inner().edge_lengths(), vec![One; edges.len()]); + assert!((1..=n + 2).contains(&target.inner().k())); let mut source_yes = false; for mask in 0..(1usize << n) { let mut witness: Vec<_> = (0..n).map(|v| mask & (1 << v) != 0).collect(); @@ -73,36 +104,47 @@ fn test_multicenter_all_small_graphs_bounds_and_placements() { source_yes = true; let mut count = witness.iter().filter(|&&b| b).count(); for bit in &mut witness { - if !*bit && count < target.k() - 2 { + if !*bit && count < target.inner().k() - 2 { *bit = true; count += 1; } } witness.extend([true, true]); - assert!(target.evaluate(&witness).unwrap().0.is_some_and(|r| r <= 1)); + assert!(target + .inner() + .evaluate(&witness) + .unwrap() + .0 + .is_some_and(|r| r <= 1)); } } let mut optimum: Option = None; for mask in 0..(1usize << (n + 2)) { let witness: Vec<_> = (0..n + 2).map(|v| mask & (1 << v) != 0).collect(); - let radius = target.evaluate(&witness).unwrap().0; + let radius = target.inner().evaluate(&witness).unwrap().0; if let Some(r) = radius { optimum = Some(optimum.map_or(r, |old| old.min(r))); } if radius.is_some_and(|r| r <= 1) { assert!( source - .evaluate(&reduction.extract_solution(&witness).unwrap()) + .evaluate(&reduction.recover_result(&source, SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap()).unwrap().into_solution().expect("qualifying target result must recover a source solution")) .unwrap() .0 ); } else { - assert!(reduction.extract_solution(&witness).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() + .is_valid()); } } assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Min(optimum)), - Or(source_yes) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(optimum)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(source_yes) ); } } @@ -112,18 +154,34 @@ 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::>>::reduce_to(&source).unwrap(); let witness = vec![true, false, true, true, true]; assert_eq!( - reduction.extract_solution(&witness).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, true] ); for bad in [vec![], vec![true; 6]] { - assert!(reduction.extract_solution(&bad).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&bad), + Err(InvalidConfiguration(_)) + )); } assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Min(None)), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(false) ); } diff --git a/src/unit_tests/rules/decisionminimumvertexcover_casts.rs b/src/unit_tests/rules/decisionminimumvertexcover_casts.rs new file mode 100644 index 000000000..db8823e65 --- /dev/null +++ b/src/unit_tests/rules/decisionminimumvertexcover_casts.rs @@ -0,0 +1,58 @@ +use super::*; +use crate::rules::traits::ReductionResult; +use crate::rules::ReduceTo; +use crate::solvers::{BruteForce, SolveOutcome}; +use crate::traits::Problem; +use crate::types::Or; + +fn unit_cycle_cover(bound: i64) -> Decision> { + Decision::new( + MinimumVertexCover::new(SimpleGraph::cycle(5), vec![One; 5]), + bound, + ) +} + +#[test] +fn test_decisionminimumvertexcover_one_to_i64_cast_closed_loop() { + // C5 has minimum vertex cover size 3. + let source = unit_cycle_cover(3); + let reduction = ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem(); + + assert_eq!(target.inner().graph(), source.inner().graph()); + assert_eq!(target.inner().weights(), &[1_i64; 5]); + assert_eq!(target.bound(), &3); + + let target_solution = BruteForce::new().solve(target).unwrap().unwrap(); + let source_solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(target, target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + + assert_eq!(source_solution, target_solution); + assert_eq!(source.evaluate(&source_solution).unwrap(), Or(true)); +} + +#[test] +fn test_decisionminimumvertexcover_one_to_i64_cast_preserves_infeasibility() { + let source = unit_cycle_cover(2); + let reduction = ReduceTo::>>::reduce_to(&source) + .expect("reduction should succeed"); + + assert_eq!(BruteForce::new().solve(&source).unwrap(), None); + assert_eq!( + BruteForce::new().solve(reduction.target_problem()).unwrap(), + None + ); + assert!(matches!( + reduction + .recover_result(&source, SolveOutcome::Infeasible) + .unwrap(), + SolveOutcome::Infeasible + )); +} diff --git a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 67ba7ee19..6ffe789e7 100644 --- a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -3,19 +3,19 @@ use crate::models::decision::Decision; use crate::models::graph::{HamiltonianCircuit, MinimumVertexCover}; use crate::rules::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; 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 +23,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 +35,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"); @@ -50,14 +50,21 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_closed_loop() { .0 ); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, cover); assert!(source.evaluate(&extracted).unwrap().0); } #[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"); @@ -70,7 +77,14 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_ignores_isolated_vertic .0 ); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert!(!extracted[2]); assert!(source.evaluate(&extracted).unwrap().0); @@ -79,7 +93,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(); @@ -91,13 +105,20 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_yes_when_k_covers .solve(target) .unwrap() .expect("triangle should have a Hamiltonian circuit"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } #[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 +128,73 @@ 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 hamiltonian_edge_registers_only_unit_weight_vertex_cover() { + let sources = inventory::iter:: + .into_iter() + .filter(|entry| { + entry.source_name == "DecisionMinimumVertexCover" + && entry.target_name == "HamiltonianCircuit" + }) + .map(|entry| (entry.source_variant_fn)()) + .collect::>(); + assert_eq!( + sources, + vec![Decision::>::variant()] + ); +} + +#[test] +fn unit_cover_bound_handles_negative_and_empty_graphs() { + for (bound, expected) in [(-1, false), (0, true), (i64::MAX, true)] { + let source = decision_mvc(2, &[], bound); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let witness = BruteForce::new().solve(reduction.target_problem()).unwrap(); + assert_eq!(witness.is_some(), expected); + if let Some(witness) = witness { + let cover = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert!(source.evaluate(&cover).unwrap().0); + } + } +} + +#[test] +fn loops_force_vertices_before_the_loopless_construction() { + for (n, edges, bound, cover) in [ + (2, vec![(0, 0), (0, 1)], 1, vec![true, false]), + ( + 4, + vec![(0, 0), (0, 1), (1, 2), (2, 3)], + 2, + vec![true, false, true, false], + ), + ] { + let source = decision_mvc(n, &edges, bound); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let witness = reduction.build_target_witness(&cover); + let recovered = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness).unwrap(), + ) + .unwrap() + .into_solution() + .unwrap(); + assert!(recovered[0]); + assert!(source.evaluate(&recovered).unwrap().0); + } + for (edges, bound) in [(vec![(0, 0), (1, 1)], 1), (vec![(0, 0), (1, 2)], 1)] { + let source = decision_mvc(3, &edges, bound); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + assert!(BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .is_none()); + } } diff --git a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs index ad9c9f1bc..23a6ef217 100644 --- a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::graph::DirectedHamiltonianPath; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -39,7 +40,14 @@ fn test_directedhamiltonianpath_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -72,7 +80,14 @@ fn test_directedhamiltonianpath_to_ilp_issue_example() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should find a path"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -89,8 +104,9 @@ fn test_directedhamiltonianpath_to_ilp_no_path() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); - assert!( - result.is_err(), + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), "Graph with no Hamiltonian path should be infeasible" ); } diff --git a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs index d438da773..2f0c37c9c 100644 --- a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -84,7 +85,14 @@ fn test_directedtwocommodityintegralflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&extracted).unwrap().0, @@ -97,8 +105,9 @@ fn test_directedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible flow instance should produce infeasible ILP" ); } @@ -110,8 +119,9 @@ fn test_directedtwocommodityintegralflow_to_ilp_disallows_using_other_commodity_ let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "commodity 1 must conserve flow at commodity 2's source in the ILP reduction" ); } @@ -130,7 +140,14 @@ fn test_directedtwocommodityintegralflow_to_ilp_extract_solution() { target_solution[8 + 3] = 1; // f2 on arc (1,3) target_solution[8 + 7] = 1; // f2 on arc (3,5) - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 16); assert!( problem.evaluate(&extracted).unwrap().0, diff --git a/src/unit_tests/rules/disjointconnectingpaths_ilp.rs b/src/unit_tests/rules/disjointconnectingpaths_ilp.rs index 740f58586..9379b8cf5 100644 --- a/src/unit_tests/rules/disjointconnectingpaths_ilp.rs +++ b/src/unit_tests/rules/disjointconnectingpaths_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::ReduceTo; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -45,7 +46,14 @@ fn test_disjointconnectingpaths_to_ilp_forbids_using_another_pairs_terminal() { .is_feasible(&colliding_flow) .unwrap()); let target_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } @@ -64,7 +72,14 @@ fn test_disjointconnectingpaths_to_ilp_discards_disconnected_circulation() { .target_problem() .is_feasible(&target_solution) .unwrap()); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, false, false, false]); assert!(source.evaluate(&extracted).unwrap().0); } diff --git a/src/unit_tests/rules/ensemblecomputation_ilp.rs b/src/unit_tests/rules/ensemblecomputation_ilp.rs index 3be3c0aaa..7d18f85c5 100644 --- a/src/unit_tests/rules/ensemblecomputation_ilp.rs +++ b/src/unit_tests/rules/ensemblecomputation_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::ILP; use crate::models::misc::EnsembleComputation; use crate::rules::ReduceTo; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -22,7 +23,14 @@ fn test_ensemblecomputation_to_ilp_closed_loop() { let source = feasible_instance(); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(3))); } @@ -30,14 +38,20 @@ fn test_ensemblecomputation_to_ilp_closed_loop() { fn test_ensemblecomputation_to_ilp_infeasible_budget() { let source = EnsembleComputation::new(3, vec![vec![0, 1, 2]], 1); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] fn test_ensemblecomputation_to_ilp_rejects_singleton_target() { let source = EnsembleComputation::new(3, vec![vec![0]], 2); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -45,6 +59,13 @@ fn test_ensemblecomputation_to_ilp_empty_family() { let source = EnsembleComputation::new(1, vec![], 2); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/rules/eulerianpath_ilp.rs b/src/unit_tests/rules/eulerianpath_ilp.rs index 7d9f5c3c4..d8111a981 100644 --- a/src/unit_tests/rules/eulerianpath_ilp.rs +++ b/src/unit_tests/rules/eulerianpath_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::EulerianPath; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Or; @@ -52,7 +53,14 @@ fn test_eulerianpath_to_ilp_empty_instance() { let solution = ILPSolver::new() .solve(ilp) .expect("Empty ILP should be feasible"); - let extracted = reduction.extract_solution(&solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 0); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); } @@ -66,7 +74,14 @@ fn test_eulerianpath_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible for a YES instance"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), source.num_arcs()); assert!( @@ -87,8 +102,9 @@ fn test_eulerianpath_to_ilp_infeasible_no_instance() { // The ILP must report infeasibility for a NO instance. let solution = ILPSolver::new().solve(reduction.target_problem()); - assert!( - solution.is_err(), + assert_eq!( + solution, + Err(crate::solvers::ILPSolveError::Infeasible), "ILP must be infeasible for a degree-unbalanced NO instance, got {:?}", solution ); @@ -104,7 +120,14 @@ fn test_eulerianpath_to_ilp_closed_circuit_with_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible for a closed Eulerian circuit"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert!( source.is_valid_solution(&extracted), diff --git a/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs index 5d1fc643f..35b51eb54 100644 --- a/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -2,6 +2,7 @@ use crate::models::algebraic::AlgebraicEquationsOverGF2; use crate::models::set::ExactCoverBy3Sets; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; #[test] fn test_exactcoverby3sets_to_algebraicequationsovergf2_closed_loop() { @@ -49,8 +50,14 @@ fn test_exactcoverby3sets_to_algebraicequationsovergf2_extract_solution_is_ident assert_eq!( reduction - .extract_solution(&vec![true, false, true]) - .unwrap(), - vec![true, false, true] + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![true, true, false].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![true, true, false] ); } diff --git a/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index ba458bb61..9967a8372 100644 --- a/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -1,7 +1,11 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::Graph; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::traits::Problem; /// q = 2, m = 2: X = {0..5} with C = [{0,1,2}, {3,4,5}]. /// Both subsets together form the unique exact cover. @@ -74,7 +78,14 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_extract_solution() { let mut target_config = vec![true; reduction.target_problem().num_edges()]; *target_config.last_mut().unwrap() = false; assert_eq!( - reduction.extract_solution(&target_config).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, true] ); @@ -82,11 +93,18 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_extract_solution() { let mut invalid = vec![false; target_config.len()]; invalid[2] = true; invalid[3] = true; - assert!(reduction.extract_solution(&invalid).is_err()); - assert!(reduction.extract_solution(&vec![]).is_err()); - assert!(reduction - .extract_solution(&vec![true; target_config.len()]) - .is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&invalid) + .unwrap() + .is_valid()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![true; target_config.len()]) + .unwrap() + .is_valid()); } #[test] @@ -103,7 +121,10 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_no_instance() { // exist here). Equivalently, the brute-force aggregate evaluates to // Or(false). assert!(BruteForce::new().solve(target).unwrap().is_none()); - assert!(reduction.extract_solution(&vec![]).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![]) + .unwrap() + .is_valid()); } #[test] @@ -118,7 +139,10 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_universe_boundaries() { .solve(reduction.target_problem()) .unwrap() .is_none()); - assert!(reduction.extract_solution(&vec![]).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![]) + .unwrap() + .is_valid()); } let source = ExactCoverBy3Sets::new(0, vec![]); let reduction = @@ -129,7 +153,14 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_universe_boundaries() { .unwrap(); assert_eq!(witness, vec![true, true]); assert_eq!( - reduction.extract_solution(&witness).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); } @@ -144,7 +175,14 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_duplicate_sets() { .unwrap(); assert!(!witnesses.is_empty()); for witness in witnesses { - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted).unwrap()); assert_eq!(extracted.iter().filter(|&&x| x).count(), 1); } diff --git a/src/unit_tests/rules/exactcoverby3sets_ilp.rs b/src/unit_tests/rules/exactcoverby3sets_ilp.rs index 06de42089..606c117c0 100644 --- a/src/unit_tests/rules/exactcoverby3sets_ilp.rs +++ b/src/unit_tests/rules/exactcoverby3sets_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -29,7 +30,14 @@ fn test_exactcoverby3sets_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -39,7 +47,14 @@ fn test_solution_extraction() { let reduction: ReductionX3CToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = vec![1, 1]; // select both triples - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true]); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs index 146036ae1..005e8a653 100644 --- a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::set::ExactCoverBy3Sets; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Max; @@ -65,7 +66,23 @@ 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(); + let extracted = reduction.map_solution(&best).unwrap(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(target, best.clone()).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(target, best.clone()).unwrap() + ), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); assert!(!source.evaluate(&extracted).unwrap()); } @@ -84,6 +101,13 @@ fn test_exactcoverby3sets_to_maximumsetpacking_optimal_value() { // Maximum packing: S0 + S1 = 2 disjoint sets = q assert_eq!(target.evaluate(&best).unwrap(), Max(Some(2))); - let extracted = reduction.extract_solution(&best).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), best.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs index 757f57c49..211c903b5 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -70,7 +71,23 @@ 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(); + let extracted = reduction.map_solution(&optimal).unwrap(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(target, optimal.clone()).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(target, optimal.clone()).unwrap() + ), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); assert!(!source.evaluate(&extracted).unwrap()); } @@ -81,9 +98,19 @@ fn test_extract_solution_reads_only_set_sentence_axioms() { ReduceTo::::reduce_to(&source).expect("reduction should succeed"); let extracted = reduction - .extract_solution(&vec![ - true, false, true, false, false, true, false, false, false, true, true, - ]) - .unwrap(); + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![ + true, false, true, false, false, true, false, false, false, true, true, + ] + .clone(), + ) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); 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..fca006e62 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -3,6 +3,7 @@ use crate::models::misc::MinimumFaultDetectionTestSet; use crate::models::set::ExactCoverBy3Sets; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -81,7 +82,23 @@ 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(); + let extracted = reduction.map_solution(&best).unwrap(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(target, best.clone()).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(target, best.clone()).unwrap() + ), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); assert!(!source.evaluate(&extracted).unwrap()); } @@ -93,8 +110,17 @@ fn test_exactcoverby3sets_to_minimumfaultdetectiontestset_extract_solution_ident assert_eq!( reduction - .extract_solution(&vec![vec![true], vec![true], vec![false]]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![vec![true], vec![true], vec![false]].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, true, false] ); assert!(source.evaluate(&vec![true, true, false]).unwrap().0); diff --git a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs index ea050fe01..770735706 100644 --- a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs @@ -1,9 +1,10 @@ -use super::*; use crate::models::misc::StaffScheduling; use crate::models::set::ExactCoverBy3Sets; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; #[test] @@ -56,7 +57,14 @@ fn test_exactcoverby3sets_to_staffscheduling_unique_cover() { let solutions = solver.find_all_witnesses(target).unwrap(); // Each satisfying target config should extract to selecting all 3 subsets for sol in &solutions { - let extracted = result.extract_solution(sol).unwrap(); + let extracted = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( source.evaluate(&extracted).unwrap().0, "Extracted solution must be valid" @@ -65,7 +73,16 @@ fn test_exactcoverby3sets_to_staffscheduling_unique_cover() { // There should be exactly one satisfying assignment (up to extraction) let extracted_solutions: Vec> = solutions .iter() - .map(|s| result.extract_solution(s).unwrap()) + .map(|s| { + result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), (s).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + }) .collect(); assert!( extracted_solutions @@ -83,16 +100,24 @@ fn test_exactcoverby3sets_to_staffscheduling_extract_solution() { // StaffScheduling config: [1, 1, 0, 0] means 1 worker on schedule 0 and 1 on schedule 1 let target_config = vec![1, 1, 0, 0]; - let extracted = result.extract_solution(&target_config).unwrap(); + let extracted = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, false, false]); // 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]); + // No workers cannot cover the required shifts. + assert!(!ReductionResult::target_problem(&result) + .evaluate(&vec![0, 0, 0, 0]) + .unwrap() + .is_valid()); } #[test] diff --git a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs index dac72241d..0a5c688b0 100644 --- a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs @@ -2,6 +2,7 @@ use crate::models::misc::SubsetProduct; use crate::models::set::ExactCoverBy3Sets; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use num_bigint::BigUint; #[test] @@ -41,9 +42,15 @@ fn test_exactcoverby3sets_to_subsetproduct_extract_solution_is_identity() { assert_eq!( reduction - .extract_solution(&vec![true, false, true]) - .unwrap(), - vec![true, false, true] + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![true, true, false].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![true, true, false] ); } diff --git a/src/unit_tests/rules/expectedretrievalcost_ilp.rs b/src/unit_tests/rules/expectedretrievalcost_ilp.rs index ae9b2af03..52b0d9d37 100644 --- a/src/unit_tests/rules/expectedretrievalcost_ilp.rs +++ b/src/unit_tests/rules/expectedretrievalcost_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -48,7 +49,14 @@ fn test_expectedretrievalcost_to_ilp_bf_vs_ilp() { let bf_cost = problem.expected_cost(&bf_witness).unwrap().unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_cost = problem.expected_cost(&extracted).unwrap().unwrap(); // ILP cost should match BF optimal cost @@ -65,20 +73,35 @@ fn test_solution_extraction() { let reduction: ReductionERCToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - // record 0 -> sector 0, record 1 -> sector 1 - // x_{0,0}=1, x_{0,1}=0, x_{1,0}=0, x_{1,1}=1 - let mut ilp_solution = vec![0_i64; 4 + 16]; // n + n^2 - // x vars - ilp_solution[0] = 1; // x_{0,0} - ilp_solution[3] = 1; // x_{1,1} - // z vars: z_{r,s,r',s'} at offset 4 + (r*2+s)*4 + (r'*2+s') - // z_{0,0,0,0} = x_{0,0}*x_{0,0} = 1: offset 4 + 0*4 + 0 = 4 - ilp_solution[4] = 1; - // z_{1,1,1,1} = x_{1,1}*x_{1,1} = 1: offset 4 + 3*4 + 3 = 4+15=19 - ilp_solution[19] = 1; - - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); - assert_eq!(extracted, vec![0, 1]); + for assignment in [vec![0, 0], vec![0, 1], vec![1, 0], vec![1, 1]] { + let mut target = vec![0; reduction.target_problem().num_vars()]; + for (r, §or) in assignment.iter().enumerate() { + target[reduction.x_var(r, sector)] = 1; + } + for (r, §or) in assignment.iter().enumerate() { + for (other, &other_sector) in assignment.iter().enumerate() { + target[reduction.z_var(r, sector, other, other_sector)] = 1; + } + } + assert_eq!( + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + assignment + ); + assert_eq!( + reduction + .target_problem() + .evaluate_objective(&target) + .unwrap(), + problem.expected_cost(&assignment).unwrap().unwrap() + ); + } } #[test] @@ -91,7 +114,14 @@ fn test_expectedretrievalcost_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!( matches!(value, Min(Some(_))), diff --git a/src/unit_tests/rules/factoring_circuit.rs b/src/unit_tests/rules/factoring_circuit.rs index 5b16f2702..34e7c1458 100644 --- a/src/unit_tests/rules/factoring_circuit.rs +++ b/src/unit_tests/rules/factoring_circuit.rs @@ -1,10 +1,13 @@ use super::*; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_optimization_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; use crate::traits::Problem; use num_bigint::BigUint; use std::collections::HashMap; -include!("../jl_helpers.rs"); #[test] fn test_read_bit() { @@ -226,7 +229,14 @@ fn test_extract_solution() { } } - let factoring_sol = reduction.extract_solution(&sol).unwrap(); + let factoring_sol = reduction + .recover_result( + &factoring, + SolveOutcome::optimal(reduction.target_problem(), sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let (p, q) = factoring_sol.clone(); assert_eq!(p, BigUint::from(2u32), "p should be 2"); assert_eq!(q, BigUint::from(3u32), "q should be 3"); @@ -239,7 +249,14 @@ fn test_extract_solution() { } } assert_eq!( - reduction.extract_solution(&sol).unwrap(), + reduction + .recover_result( + &factoring, + SolveOutcome::optimal(reduction.target_problem(), sol.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), (BigUint::from(2u32), BigUint::from(3u32)) ); } @@ -384,7 +401,14 @@ fn test_factoring_to_circuit_zero_width_closed_loop() { let witness = BruteForce::new().solve(reduction.target_problem()).unwrap(); assert_eq!(witness.is_some(), value == 0); if let Some(witness) = witness { - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_factorization(&extracted)); assert!(extracted.0.is_zero()); } @@ -396,10 +420,18 @@ fn test_factoring_to_circuit_zero_width_closed_loop() { fn test_factoring_to_circuit_rejects_invalid_certificates() { let source = Factoring::with_factor_bits(6, 2, 2); let reduction = ReduceTo::::reduce_to(&source).unwrap(); - assert!(reduction.extract_solution(&vec![]).is_err()); - assert!(reduction - .extract_solution(&vec![false; reduction.target_problem().num_variables()]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![ + false; + ReductionResult::target_problem(&reduction) + .num_variables() + ]) + .unwrap() + .is_valid()); let values = evaluate_multiplier_circuit(&reduction, 1, 1); let config = reduction .target_problem() @@ -407,7 +439,10 @@ fn test_factoring_to_circuit_rejects_invalid_certificates() { .iter() .map(|name| values[name]) .collect(); - assert!(reduction.extract_solution(&config).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } #[test] diff --git a/src/unit_tests/rules/factoring_ilp.rs b/src/unit_tests/rules/factoring_ilp.rs index 472e97016..6c0cfbfab 100644 --- a/src/unit_tests/rules/factoring_ilp.rs +++ b/src/unit_tests/rules/factoring_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use num_bigint::BigUint; @@ -54,7 +55,14 @@ fn test_factor_6() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Verify it's a valid factorization assert!(problem.is_valid_factorization(&extracted)); @@ -80,7 +88,14 @@ fn test_factor_15() { let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); // 4. Extract factoring solution - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // 5. Verify: solution is valid and p × q = 15 assert!(problem.is_valid_factorization(&extracted)); @@ -98,7 +113,14 @@ fn test_factor_35() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.is_valid_factorization(&extracted)); @@ -116,7 +138,14 @@ fn test_factor_one() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.is_valid_factorization(&extracted)); @@ -134,7 +163,14 @@ fn test_factor_prime() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.is_valid_factorization(&extracted)); @@ -152,7 +188,14 @@ fn test_factor_square() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.is_valid_factorization(&extracted)); @@ -171,7 +214,11 @@ fn test_infeasible_target_too_large() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_err(), "Should be infeasible"); + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), + "Should be infeasible" + ); } #[test] @@ -184,7 +231,14 @@ fn test_factoring_to_ilp_closed_loop() { // Get ILP solution let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let ilp_factors = reduction.extract_solution(&ilp_solution).unwrap(); + let ilp_factors = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Get brute force solutions let bf = BruteForce::new(); @@ -215,8 +269,16 @@ 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 extracted = reduction.extract_solution(&ilp_solution).unwrap(); + // Each product column already matches 0110, so every carry is zero. + let ilp_solution = vec![0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]; + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, (BigUint::from(2u32), BigUint::from(3u32))); @@ -249,7 +311,14 @@ fn test_integer_ilp_pipeline_solution() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let solution = reduction.extract_solution(&ilp_solution).unwrap(); + let solution = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.is_valid_factorization(&solution)); } @@ -264,7 +333,14 @@ fn test_asymmetric_bit_widths() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.is_valid_factorization(&extracted)); @@ -277,7 +353,10 @@ fn test_oversized_biguint_target_makes_ilp_infeasible() { let target = BigUint::from(1u32) << 70; let problem = Factoring::with_factor_bits(target, 2, 2); let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] diff --git a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs index 6b18e1e2a..7049cccac 100644 --- a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs +++ b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; @@ -27,7 +28,14 @@ fn test_feasible_register_assignment_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("feasible source instance should yield a feasible ILP"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); let mut sorted = extracted.clone(); @@ -40,8 +48,9 @@ fn test_feasible_register_assignment_to_ilp_infeasible() { let source = FeasibleRegisterAssignment::new(3, vec![(0, 1), (0, 2), (1, 2)], 1, vec![0, 0, 0]); let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "register-conflict source instance should reduce to an infeasible ILP" ); } diff --git a/src/unit_tests/rules/flowshopscheduling_ilp.rs b/src/unit_tests/rules/flowshopscheduling_ilp.rs index 34da74e87..39147243d 100644 --- a/src/unit_tests/rules/flowshopscheduling_ilp.rs +++ b/src/unit_tests/rules/flowshopscheduling_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::ILP; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -20,7 +21,14 @@ fn test_flowshopscheduling_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -33,8 +41,9 @@ fn test_flowshopscheduling_to_ilp_infeasible() { // 2 machines, 3 jobs with large processing times, very tight deadline let problem = FlowShopScheduling::new(2, vec![vec![5, 5], vec![5, 5], vec![5, 5]], 6); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible FSS should produce infeasible ILP" ); } @@ -47,7 +56,14 @@ fn test_flowshopscheduling_to_ilp_single_job() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("single-job ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -63,6 +79,13 @@ fn test_flowshopscheduling_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index df044287b..ebbcb098c 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -11,11 +11,11 @@ use crate::models::set::MaximumSetPacking; use crate::registry::ProblemCategory; use crate::rules::graph::{ReductionMode, ReductionStep}; use crate::rules::registry::{ReductionEntry, ReductionParameterDeclarations}; -use crate::rules::traits::{AggregateReductionResult, ReductionResult}; -use crate::solvers::BruteForceProblem as _; +use crate::rules::traits::ReductionResult; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; -use crate::types::{One, ProblemParameters, Sum}; +use crate::types::{Min, One, ProblemParameters}; use petgraph::graph::DiGraph; use serde_json::json; use std::any::Any; @@ -50,7 +50,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, + turing, } } @@ -68,29 +68,34 @@ fn named_path(names: &[&str]) -> ReductionPath { } #[derive(Clone)] -struct AggregateChainSource; +struct OffsetChainSource; #[derive(Clone)] -struct AggregateChainMiddle; +struct OffsetChainMiddle; #[derive(Clone)] -struct AggregateChainTarget; +struct OffsetChainTarget; #[derive(Clone)] struct NaturalVariantProblem; -impl Problem for AggregateChainSource { - const NAME: &'static str = "AggregateChainSource"; +impl Problem for OffsetChainSource { + const NAME: &'static str = "OffsetChainSource"; type Solution = Vec; - type Value = Sum; + type Value = Min; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 1usize as u64)]) + } fn evaluate( &self, config: &Self::Solution, ) -> Result { - Ok(Sum(config.iter().sum::() as u64)) + Ok(Min(Some(config.iter().sum::() as u64))) } fn variant() -> Vec<(&'static str, &'static str)> { @@ -98,24 +103,33 @@ impl Problem for AggregateChainSource { } } -impl crate::solvers::BruteForceProblem for AggregateChainSource { - fn dimensions(&self) -> Vec { - vec![1] +impl crate::solvers::BruteForceProblem for OffsetChainSource { + fn num_variables(&self) -> Result { + Ok(1usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([1][variable]) } } -impl Problem for AggregateChainMiddle { - const NAME: &'static str = "AggregateChainMiddle"; +impl Problem for OffsetChainMiddle { + const NAME: &'static str = "OffsetChainMiddle"; type Solution = Vec; - type Value = Sum; + type Value = Min; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 1usize as u64)]) + } fn evaluate( &self, config: &Self::Solution, ) -> Result { - Ok(Sum(config.iter().sum::() as u64)) + Ok(Min(Some(config.iter().sum::() as u64))) } fn variant() -> Vec<(&'static str, &'static str)> { @@ -123,24 +137,33 @@ impl Problem for AggregateChainMiddle { } } -impl crate::solvers::BruteForceProblem for AggregateChainMiddle { - fn dimensions(&self) -> Vec { - vec![1] +impl crate::solvers::BruteForceProblem for OffsetChainMiddle { + fn num_variables(&self) -> Result { + Ok(1usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([1][variable]) } } -impl Problem for AggregateChainTarget { - const NAME: &'static str = "AggregateChainTarget"; +impl Problem for OffsetChainTarget { + const NAME: &'static str = "OffsetChainTarget"; type Solution = Vec; - type Value = Sum; + type Value = Min; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 1usize as u64)]) + } fn evaluate( &self, config: &Self::Solution, ) -> Result { - Ok(Sum(config.iter().sum::() as u64)) + Ok(Min(Some(config.iter().sum::() as u64))) } fn variant() -> Vec<(&'static str, &'static str)> { @@ -148,24 +171,33 @@ impl Problem for AggregateChainTarget { } } -impl crate::solvers::BruteForceProblem for AggregateChainTarget { - fn dimensions(&self) -> Vec { - vec![1] +impl crate::solvers::BruteForceProblem for OffsetChainTarget { + fn num_variables(&self) -> Result { + Ok(1usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([1][variable]) } } impl Problem for NaturalVariantProblem { const NAME: &'static str = "NaturalVariantProblem"; type Solution = Vec; - type Value = Sum; + type Value = crate::types::Max; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 1usize as u64)]) + } fn evaluate( &self, config: &Self::Solution, ) -> Result { - Ok(Sum(config.iter().sum::() as u64)) + Ok(crate::types::Max(Some(config.iter().sum::() as u64))) } fn variant() -> Vec<(&'static str, &'static str)> { @@ -174,118 +206,178 @@ impl Problem for NaturalVariantProblem { } impl crate::solvers::BruteForceProblem for NaturalVariantProblem { - fn dimensions(&self) -> Vec { - vec![1] + fn num_variables(&self) -> Result { + Ok(1usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([1][variable]) } } -struct SourceToMiddleAggregateResult { - target: AggregateChainMiddle, +struct SourceToMiddleOffsetResult { + target: OffsetChainMiddle, } -impl AggregateReductionResult for SourceToMiddleAggregateResult { - type Source = AggregateChainSource; - type Target = AggregateChainMiddle; +impl ReductionResult for SourceToMiddleOffsetResult { + type Source = OffsetChainSource; + type Target = OffsetChainMiddle; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Sum) -> Sum { - Sum(target_value.0 + 2) + fn recover_result( + &self, + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + Ok(match target { + SolveOutcome::Optimal { mut solution, .. } => { + solution[0] += 2; + SolveOutcome::optimal(source, solution)? + } + SolveOutcome::Feasible { mut solution, .. } => { + solution[0] += 2; + SolveOutcome::feasible(source, solution)? + } + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + }) } } -struct MiddleToTargetAggregateResult { - target: AggregateChainTarget, +struct MiddleToTargetOffsetResult { + target: OffsetChainTarget, } -impl AggregateReductionResult for MiddleToTargetAggregateResult { - type Source = AggregateChainMiddle; - type Target = AggregateChainTarget; +impl ReductionResult for MiddleToTargetOffsetResult { + type Source = OffsetChainMiddle; + type Target = OffsetChainTarget; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Sum) -> Sum { - Sum(target_value.0 + 3) + fn recover_result( + &self, + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + Ok(match target { + SolveOutcome::Optimal { mut solution, .. } => { + solution[0] += 3; + SolveOutcome::optimal(source, solution)? + } + SolveOutcome::Feasible { mut solution, .. } => { + solution[0] += 3; + SolveOutcome::feasible(source, solution)? + } + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + }) } } -fn reduce_source_to_middle_aggregate( +fn reduce_source_to_middle_offset( any: &dyn Any, -) -> Result, crate::rules::ReductionError> -{ - any.downcast_ref::().ok_or( +) -> Result { + any.downcast_ref::().ok_or( crate::rules::ReductionError::SourceTypeMismatch { - source_problem: AggregateChainSource::NAME, - target_problem: AggregateChainMiddle::NAME, - expected: std::any::type_name::(), + source_problem: OffsetChainSource::NAME, + target_problem: OffsetChainMiddle::NAME, + expected: std::any::type_name::(), }, )?; - Ok(Box::new(SourceToMiddleAggregateResult { - target: AggregateChainMiddle, - })) + Ok(crate::rules::registry::ExecutedStep { + witness: std::rc::Rc::new(SourceToMiddleOffsetResult { + target: OffsetChainMiddle, + }), + }) } -fn reduce_middle_to_target_aggregate( +fn reduce_middle_to_target_offset( any: &dyn Any, -) -> Result, crate::rules::ReductionError> -{ - any.downcast_ref::().ok_or( +) -> Result { + any.downcast_ref::().ok_or( crate::rules::ReductionError::SourceTypeMismatch { - source_problem: AggregateChainMiddle::NAME, - target_problem: AggregateChainTarget::NAME, - expected: std::any::type_name::(), + source_problem: OffsetChainMiddle::NAME, + target_problem: OffsetChainTarget::NAME, + expected: std::any::type_name::(), }, )?; - Ok(Box::new(MiddleToTargetAggregateResult { - target: AggregateChainTarget, - })) + Ok(crate::rules::registry::ExecutedStep { + witness: std::rc::Rc::new(MiddleToTargetOffsetResult { + target: OffsetChainTarget, + }), + }) } struct SourceToMiddleWitnessResult { - target: AggregateChainMiddle, + target: OffsetChainMiddle, } impl ReductionResult for SourceToMiddleWitnessResult { - type Source = AggregateChainSource; - type Target = AggregateChainMiddle; + type Source = OffsetChainSource; + type Target = OffsetChainMiddle; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution( + fn recover_result( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + crate::solvers::SolveOutcome::Infeasible => { + Ok(crate::solvers::SolveOutcome::Infeasible) + } + crate::solvers::SolveOutcome::Optimal { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::optimal(source, solution)?) + } + crate::solvers::SolveOutcome::Feasible { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::feasible(source, solution)?) + } + } + } +} + +impl SourceToMiddleWitnessResult { + fn map_solution( + &self, + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } fn reduce_source_to_middle_witness( any: &dyn Any, -) -> Result, crate::rules::ReductionError> { - any.downcast_ref::().ok_or( +) -> Result { + any.downcast_ref::().ok_or( crate::rules::ReductionError::SourceTypeMismatch { - source_problem: AggregateChainSource::NAME, - target_problem: AggregateChainMiddle::NAME, - expected: std::any::type_name::(), + source_problem: OffsetChainSource::NAME, + target_problem: OffsetChainMiddle::NAME, + expected: std::any::type_name::(), }, )?; - Ok(Box::new(SourceToMiddleWitnessResult { - target: AggregateChainMiddle, - })) + Ok(crate::rules::registry::ExecutedStep { + witness: std::rc::Rc::new(SourceToMiddleWitnessResult { + target: OffsetChainMiddle, + }), + }) } fn fail_source_to_middle_witness( _any: &dyn Any, -) -> Result, crate::rules::ReductionError> { +) -> Result { Err(crate::rules::ReductionError::InvalidTarget { - source_problem: AggregateChainSource::NAME, - target_problem: AggregateChainMiddle::NAME, + source_problem: OffsetChainSource::NAME, + target_problem: OffsetChainMiddle::NAME, message: "synthetic target construction failure".to_string(), }) } @@ -294,49 +386,75 @@ static SHARED_PREFIX_EXECUTIONS: AtomicUsize = AtomicUsize::new(0); fn reduce_counted_source_to_middle_witness( any: &dyn Any, -) -> Result, crate::rules::ReductionError> { +) -> Result { SHARED_PREFIX_EXECUTIONS.fetch_add(1, Ordering::SeqCst); reduce_source_to_middle_witness(any) } struct MiddleToTargetWitnessResult { - target: AggregateChainTarget, + target: OffsetChainTarget, } impl ReductionResult for MiddleToTargetWitnessResult { - type Source = AggregateChainMiddle; - type Target = AggregateChainTarget; + type Source = OffsetChainMiddle; + type Target = OffsetChainTarget; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution( + fn recover_result( + &self, + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + crate::solvers::SolveOutcome::Infeasible => { + Ok(crate::solvers::SolveOutcome::Infeasible) + } + crate::solvers::SolveOutcome::Optimal { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::optimal(source, solution)?) + } + crate::solvers::SolveOutcome::Feasible { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::feasible(source, solution)?) + } + } + } +} + +impl MiddleToTargetWitnessResult { + fn map_solution( &self, - target_solution: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { + target_solution: &<::Target as crate::traits::Problem>::Solution, + ) -> crate::rules::ExtractionResult< + <::Source as crate::traits::Problem>::Solution, + > { Ok(target_solution.to_vec()) } } fn reduce_middle_to_target_witness( any: &dyn Any, -) -> Result, crate::rules::ReductionError> { - any.downcast_ref::().ok_or( +) -> Result { + any.downcast_ref::().ok_or( crate::rules::ReductionError::SourceTypeMismatch { - source_problem: AggregateChainMiddle::NAME, - target_problem: AggregateChainTarget::NAME, - expected: std::any::type_name::(), + source_problem: OffsetChainMiddle::NAME, + target_problem: OffsetChainTarget::NAME, + expected: std::any::type_name::(), }, )?; - Ok(Box::new(MiddleToTargetWitnessResult { - target: AggregateChainTarget, - })) + Ok(crate::rules::registry::ExecutedStep { + witness: std::rc::Rc::new(MiddleToTargetWitnessResult { + target: OffsetChainTarget, + }), + }) } fn reduce_natural_variant_witness( any: &dyn Any, -) -> Result, crate::rules::ReductionError> { +) -> Result { let source = any.downcast_ref::().ok_or( crate::rules::ReductionError::SourceTypeMismatch { source_problem: NaturalVariantProblem::NAME, @@ -344,10 +462,12 @@ fn reduce_natural_variant_witness( expected: std::any::type_name::(), }, )?; - Ok(Box::new(crate::rules::VariantReductionResult::< - NaturalVariantProblem, - NaturalVariantProblem, - >::new(source.clone()))) + Ok(crate::rules::registry::ExecutedStep { + witness: std::rc::Rc::new(crate::rules::VariantReductionResult::< + NaturalVariantProblem, + NaturalVariantProblem, + >::new(source.clone())), + }) } fn build_two_node_graph( @@ -396,42 +516,70 @@ fn execute_paths_executes_a_shared_prefix_once() { let witness_edge = |reduce_fn| ReductionEdgeData { parameter_contract: empty_parameter_contract(), reduce_fn: Some(reduce_fn), - reduce_aggregate_fn: None, + turing: false, }; let graph = ReductionGraph::from_test_edges( &[ - AggregateChainSource::NAME, - AggregateChainMiddle::NAME, - AggregateChainTarget::NAME, + OffsetChainSource::NAME, + OffsetChainMiddle::NAME, + OffsetChainTarget::NAME, ], &[ ( - AggregateChainSource::NAME, - AggregateChainMiddle::NAME, + OffsetChainSource::NAME, + OffsetChainMiddle::NAME, witness_edge(reduce_counted_source_to_middle_witness), ), ( - AggregateChainMiddle::NAME, - AggregateChainTarget::NAME, + OffsetChainMiddle::NAME, + OffsetChainTarget::NAME, witness_edge(reduce_middle_to_target_witness), ), ], ); - let paths = vec![ - named_path(&[AggregateChainSource::NAME, AggregateChainMiddle::NAME]), + let mut paths = vec![ + named_path(&[OffsetChainSource::NAME, OffsetChainMiddle::NAME]), named_path(&[ - AggregateChainSource::NAME, - AggregateChainMiddle::NAME, - AggregateChainTarget::NAME, + OffsetChainSource::NAME, + OffsetChainMiddle::NAME, + OffsetChainTarget::NAME, ]), ]; + paths.push(paths[0].clone()); + paths.push(paths[1].clone()); + let executed = graph - .execute_paths(&paths, &AggregateChainSource) + .execute_paths(&paths, &OffsetChainSource) .expect("both paths are executable"); - assert_eq!(executed.len(), 2); + assert_eq!(executed.len(), 4); + for (path, execution) in paths.iter().zip(&executed) { + assert_eq!(execution.steps.len(), path.len()); + assert_eq!( + execution + .recover_result::( + &OffsetChainSource, + SolveOutcome::Optimal { + solution: vec![1usize], + evaluation: Min(Some(1)) + } + ) + .unwrap() + .into_solution() + .unwrap(), + vec![1] + ); + } + assert!(std::rc::Rc::ptr_eq( + &executed[0].steps[0].witness, + &executed[3].steps[0].witness + )); + assert!(std::rc::Rc::ptr_eq( + &executed[1].steps[1].witness, + &executed[3].steps[1].witness + )); assert_eq!(SHARED_PREFIX_EXECUTIONS.load(Ordering::SeqCst), 1); } @@ -470,7 +618,7 @@ fn path_parameter_contract_errors_are_typed_and_isolated() { ReductionEdgeData { parameter_contract: empty_parameter_contract(), reduce_fn: Some(|_| panic!("metadata inspection must not execute reductions")), - reduce_aggregate_fn: None, + turing: false, }, ); @@ -491,7 +639,7 @@ fn path_parameter_contract_errors_are_typed_and_isolated() { ReductionEdgeData { parameter_contract: invalid_contract, reduce_fn: Some(|_| panic!("metadata inspection must not execute reductions")), - reduce_aggregate_fn: None, + turing: false, }, ); @@ -611,24 +759,24 @@ fn test_find_direct_path() { } #[test] -fn test_aggregate_reduction_chain_extracts_value_backwards() { +fn test_reduction_chain_recovers_result_backwards() { let source_variant = BTreeMap::new(); let middle_variant = BTreeMap::new(); let target_variant = BTreeMap::new(); let nodes = vec![ VariantNode { - name: AggregateChainSource::NAME, + name: OffsetChainSource::NAME, variant: source_variant.clone(), complexity: "", }, VariantNode { - name: AggregateChainMiddle::NAME, + name: OffsetChainMiddle::NAME, variant: middle_variant.clone(), complexity: "", }, VariantNode { - name: AggregateChainTarget::NAME, + name: OffsetChainTarget::NAME, variant: target_variant.clone(), complexity: "", }, @@ -644,8 +792,7 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { middle_idx, ReductionEdgeData { parameter_contract: empty_parameter_contract(), - reduce_fn: None, - reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), + reduce_fn: Some(reduce_source_to_middle_offset), turing: false, }, ); @@ -654,8 +801,7 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { target_idx, ReductionEdgeData { parameter_contract: empty_parameter_contract(), - reduce_fn: None, - reduce_aggregate_fn: Some(reduce_middle_to_target_aggregate), + reduce_fn: Some(reduce_middle_to_target_offset), turing: false, }, ); @@ -664,109 +810,120 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { graph, nodes, name_to_nodes: HashMap::from([ - (AggregateChainSource::NAME, vec![source_idx]), - (AggregateChainMiddle::NAME, vec![middle_idx]), - (AggregateChainTarget::NAME, vec![target_idx]), + (OffsetChainSource::NAME, vec![source_idx]), + (OffsetChainMiddle::NAME, vec![middle_idx]), + (OffsetChainTarget::NAME, vec![target_idx]), ]), default_variants: HashMap::new(), }; let path = ReductionPath { steps: vec![ ReductionStep { - name: AggregateChainSource::NAME.to_string(), + name: OffsetChainSource::NAME.to_string(), variant: source_variant, }, ReductionStep { - name: AggregateChainMiddle::NAME.to_string(), + name: OffsetChainMiddle::NAME.to_string(), variant: middle_variant, }, ReductionStep { - name: AggregateChainTarget::NAME.to_string(), + name: OffsetChainTarget::NAME.to_string(), variant: target_variant, }, ], }; let chain = reduction_graph - .reduce_aggregate_along_path(&path, &AggregateChainSource as &dyn Any) - .expect("aggregate reduction should not fail") - .expect("expected aggregate reduction chain"); + .reduce_along_path(&path, &OffsetChainSource as &dyn Any) + .expect("offset reduction should not fail") + .expect("expected offset reduction chain"); assert_eq!( - chain.target_problem::().dimensions(), + crate::solvers::cartesian_dimensions(chain.target_problem::()).unwrap(), vec![1] ); - assert_eq!(chain.extract_value_dyn(json!(7)), json!(12)); + assert_eq!( + chain + .recover_result::( + &OffsetChainSource, + SolveOutcome::optimal(chain.target_problem::(), vec![7]) + .unwrap() + ) + .unwrap(), + SolveOutcome::Optimal { + solution: vec![12], + evaluation: Min(Some(12)) + } + ); } #[test] -fn witness_path_search_rejects_aggregate_only_edge() { +fn default_path_search_rejects_turing_only_edge() { let source_variant = BTreeMap::new(); let target_variant = BTreeMap::new(); let graph = build_two_node_graph( - AggregateChainSource::NAME, + OffsetChainSource::NAME, source_variant.clone(), - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, target_variant.clone(), ReductionEdgeData { parameter_contract: empty_parameter_contract(), reduce_fn: None, - reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), - turing: false, + turing: true, }, ); assert!(graph - .find_all_paths_mode( - AggregateChainSource::NAME, + .find_paths_up_to( + OffsetChainSource::NAME, &source_variant, - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, &target_variant, - ReductionMode::Witness + 1, ) .is_empty()); assert!(!graph .find_all_paths_mode( - AggregateChainSource::NAME, + OffsetChainSource::NAME, &source_variant, - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, &target_variant, - ReductionMode::Aggregate + ReductionMode::Turing ) .is_empty()); } #[test] -fn aggregate_path_search_rejects_witness_only_edge() { +fn turing_path_search_rejects_witness_only_edge() { let source_variant = BTreeMap::new(); let target_variant = BTreeMap::new(); let graph = build_two_node_graph( - AggregateChainSource::NAME, + OffsetChainSource::NAME, source_variant.clone(), - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, target_variant.clone(), ReductionEdgeData { parameter_contract: empty_parameter_contract(), reduce_fn: Some(reduce_source_to_middle_witness), - reduce_aggregate_fn: None, + turing: false, }, ); assert!(graph .find_all_paths_mode( - AggregateChainSource::NAME, + OffsetChainSource::NAME, &source_variant, - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, &target_variant, - ReductionMode::Aggregate + ReductionMode::Turing ) .is_empty()); assert!(!graph .find_all_paths_mode( - AggregateChainSource::NAME, + OffsetChainSource::NAME, &source_variant, - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, &target_variant, ReductionMode::Witness ) @@ -774,7 +931,7 @@ fn aggregate_path_search_rejects_witness_only_edge() { } #[test] -fn witness_executor_does_not_imply_aggregate_capability() { +fn witness_executor_does_not_imply_turing_capability() { let source_variant = BTreeMap::from([("graph".to_string(), "Source".to_string())]); let target_variant = BTreeMap::from([("graph".to_string(), "Target".to_string())]); let graph = build_two_node_graph( @@ -785,7 +942,7 @@ fn witness_executor_does_not_imply_aggregate_capability() { ReductionEdgeData { parameter_contract: empty_parameter_contract(), reduce_fn: Some(reduce_natural_variant_witness), - reduce_aggregate_fn: None, + turing: false, }, ); @@ -805,69 +962,68 @@ fn witness_executor_does_not_imply_aggregate_capability() { &source_variant, NaturalVariantProblem::NAME, &target_variant, - ReductionMode::Aggregate + ReductionMode::Turing ) .is_empty()); } #[test] -fn reduce_aggregate_along_path_rejects_single_step_path() { +fn reduce_result_along_path_rejects_single_step_path() { let source_variant = BTreeMap::new(); let graph = build_two_node_graph( - AggregateChainSource::NAME, + OffsetChainSource::NAME, source_variant.clone(), - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, BTreeMap::new(), ReductionEdgeData { parameter_contract: empty_parameter_contract(), - reduce_fn: None, - reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), + reduce_fn: Some(reduce_source_to_middle_offset), turing: false, }, ); let single_step_path = ReductionPath { steps: vec![ReductionStep { - name: AggregateChainSource::NAME.to_string(), + name: OffsetChainSource::NAME.to_string(), variant: source_variant, }], }; assert!(graph - .reduce_aggregate_along_path(&single_step_path, &AggregateChainSource as &dyn Any) + .reduce_along_path(&single_step_path, &OffsetChainSource as &dyn Any) .expect("single-step path lookup should not fail") .is_none()); } #[test] -fn reduce_aggregate_returns_none_for_witness_only_edge() { +fn reduce_result_returns_none_for_turing_only_edge() { let source_variant = BTreeMap::new(); let target_variant = BTreeMap::new(); let graph = build_two_node_graph( - AggregateChainSource::NAME, + OffsetChainSource::NAME, source_variant.clone(), - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, target_variant.clone(), ReductionEdgeData { parameter_contract: empty_parameter_contract(), - reduce_fn: Some(reduce_source_to_middle_witness), - reduce_aggregate_fn: None, - turing: false, + reduce_fn: None, + + turing: true, }, ); let path = ReductionPath { steps: vec![ ReductionStep { - name: AggregateChainSource::NAME.to_string(), + name: OffsetChainSource::NAME.to_string(), variant: source_variant, }, ReductionStep { - name: AggregateChainMiddle::NAME.to_string(), + name: OffsetChainMiddle::NAME.to_string(), variant: target_variant, }, ], }; assert!(graph - .reduce_aggregate_along_path(&path, &AggregateChainSource as &dyn Any) - .expect("witness-only edge lookup should not fail") + .reduce_along_path(&path, &OffsetChainSource as &dyn Any) + .expect("Turing-only edge lookup should not fail") .is_none()); } @@ -876,39 +1032,39 @@ fn reduce_along_path_preserves_edge_failure() { let source_variant = BTreeMap::new(); let target_variant = BTreeMap::new(); let graph = build_two_node_graph( - AggregateChainSource::NAME, + OffsetChainSource::NAME, source_variant.clone(), - AggregateChainMiddle::NAME, + OffsetChainMiddle::NAME, target_variant.clone(), ReductionEdgeData { parameter_contract: empty_parameter_contract(), reduce_fn: Some(fail_source_to_middle_witness), - reduce_aggregate_fn: None, + turing: false, }, ); let path = ReductionPath { steps: vec![ ReductionStep { - name: AggregateChainSource::NAME.to_string(), + name: OffsetChainSource::NAME.to_string(), variant: source_variant, }, ReductionStep { - name: AggregateChainMiddle::NAME.to_string(), + name: OffsetChainMiddle::NAME.to_string(), variant: target_variant, }, ], }; - let error = match graph.reduce_along_path(&path, &AggregateChainSource as &dyn Any) { + let error = match graph.reduce_along_path(&path, &OffsetChainSource as &dyn Any) { Err(error) => error, Ok(_) => panic!("registered edge failure must be returned"), }; assert_eq!( error, crate::rules::ReductionError::InvalidTarget { - source_problem: AggregateChainSource::NAME, - target_problem: AggregateChainMiddle::NAME, + source_problem: OffsetChainSource::NAME, + target_problem: OffsetChainMiddle::NAME, message: "synthetic target construction failure".to_string(), } ); @@ -1027,7 +1183,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 +1305,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 +1326,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 +1335,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 +1372,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] @@ -1563,7 +1721,7 @@ fn test_reduction_chain_direct() { let solver = BruteForce::new(); let target_solution = solver.solve(target).unwrap().unwrap(); - let source_solution = chain.extract_solution(&target_solution).unwrap(); + let source_solution = chain.recover_result::, MinimumVertexCover>(&problem, SolveOutcome::optimal(target, target_solution.clone()).unwrap()).map(|outcome| outcome.into_solution().unwrap()).unwrap(); let metric = problem.evaluate(&source_solution).unwrap(); assert!(metric.is_valid()); } @@ -1594,7 +1752,13 @@ fn test_reduction_chain_multi_step() { let solver = BruteForce::new(); let target_solution = solver.solve(target).unwrap().unwrap(); - let source_solution = chain.extract_solution(&target_solution).unwrap(); + let source_solution = chain + .recover_result::, MaximumSetPacking>( + &problem, + SolveOutcome::optimal(target, target_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); let metric = problem.evaluate(&source_solution).unwrap(); assert!(metric.is_valid()); } @@ -1641,7 +1805,7 @@ fn test_reduction_chain_with_variant_reductions() { let solver = BruteForce::new(); let target_solution = solver.solve(target).unwrap().unwrap(); - let source_solution = chain.extract_solution(&target_solution).unwrap(); + let source_solution = chain.recover_result::, MinimumVertexCover>(&mis, SolveOutcome::optimal(target, target_solution.clone()).unwrap()).map(|outcome| outcome.into_solution().unwrap()).unwrap(); let metric = mis.evaluate(&source_solution).unwrap(); assert!(metric.is_valid()); @@ -1660,9 +1824,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"); @@ -1684,7 +1853,7 @@ fn test_reduction_chain_with_variant_reductions() { let target: &MaximumIndependentSet = ksat_chain.target_problem(); let target_solution = solver.solve(target).unwrap().unwrap(); - let original_solution = ksat_chain.extract_solution(&target_solution).unwrap(); + let original_solution = ksat_chain.recover_result::, MaximumIndependentSet>(&ksat, SolveOutcome::optimal(target, target_solution).unwrap()).map(|outcome| outcome.into_solution().unwrap()).unwrap(); // Verify the extracted solution satisfies the original 3-SAT formula assert!(ksat.evaluate(&original_solution).unwrap()); @@ -1916,3 +2085,162 @@ 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)); } + +#[test] +fn witness_and_value_mapping_share_one_executed_construction() { + use crate::rules::registry::ExecutedStep; + use std::rc::Rc; + + static CONSTRUCTIONS: AtomicUsize = AtomicUsize::new(0); + + let chain = crate::rules::ReductionChain::execute( + &OffsetChainSource, + &[|_| { + CONSTRUCTIONS.fetch_add(1, Ordering::SeqCst); + let result = Rc::new(SourceToMiddleWitnessResult { + target: OffsetChainMiddle, + }); + Ok(ExecutedStep { witness: result }) + }], + ) + .unwrap(); + let step = &chain.steps[0]; + assert!(std::ptr::eq( + step.witness + .target_problem_any() + .downcast_ref::() + .unwrap(), + chain.target_problem::(), + )); + let witness = vec![7usize]; + assert_eq!( + chain + .recover_result::( + &OffsetChainSource, + SolveOutcome::optimal(chain.target_problem::(), witness.clone()) + .unwrap(), + ) + .unwrap(), + SolveOutcome::Optimal { + solution: witness, + evaluation: Min(Some(7)) + } + ); + assert_eq!(CONSTRUCTIONS.load(Ordering::SeqCst), 1); +} + +#[test] +fn composed_witness_agrees_across_direct_chain_path_and_json() { + use crate::rules::ReduceTo; + type Cover = MinimumVertexCover; + type IndependentSet = MaximumIndependentSet; + let source = Cover::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1; 3]); + let first = ReduceTo::::reduce_to(&source).unwrap(); + let second = ReduceTo::>::reduce_to(first.target_problem()).unwrap(); + let third = ReduceTo::>::reduce_to(second.target_problem()).unwrap(); + let target_solution = vec![1i64, 0, 1]; + assert!(third + .target_problem() + .evaluate(&target_solution) + .unwrap() + .is_valid()); + let target_result = + SolveOutcome::optimal(third.target_problem(), target_solution.clone()).unwrap(); + let middle_result = third + .recover_result(second.target_problem(), target_result.clone()) + .unwrap(); + let first_result = second + .recover_result(first.target_problem(), middle_result) + .unwrap(); + let expected = first + .recover_result(&source, first_result) + .unwrap() + .into_solution() + .unwrap(); + assert_eq!(expected, vec![false, true, false]); + let path = ReductionPath { + steps: [ + (Cover::NAME, Cover::variant()), + (IndependentSet::NAME, IndependentSet::variant()), + ( + MaximumSetPacking::::NAME, + MaximumSetPacking::::variant(), + ), + (ILP::::NAME, ILP::::variant()), + ] + .into_iter() + .map(|(name, variant)| ReductionStep { + name: name.into(), + variant: ReductionGraph::variant_to_map(&variant), + }) + .collect(), + }; + let graph = ReductionGraph::new(); + let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + let executed = graph.execute_paths(&[path], &source).unwrap(); + assert_eq!( + chain + .recover_result::>(&source, target_result.clone()) + .unwrap() + .into_solution() + .unwrap(), + expected + ); + assert_eq!( + executed[0] + .recover_result::>(&source, target_result.clone()) + .unwrap() + .into_solution() + .unwrap(), + expected + ); + assert_eq!( + chain + .recover_result_json( + &source, + json!({"status": "optimal", "solution": target_solution}) + ) + .unwrap() + .0, + SolveOutcome::Optimal { + solution: json!(expected), + evaluation: "Min(1)".into() + } + ); +} + +#[test] +fn insufficient_quality_names_the_failing_hop_of_a_chain() { + use crate::models::misc::Partition; + // Total 11 is odd, so no Knapsack candidate decodes to a balanced partition. + let source = Partition::new(vec![2, 4, 5]).unwrap(); + let path = ReductionPath { + steps: [ + (Partition::NAME, Partition::variant()), + (Knapsack::NAME, Knapsack::variant()), + (ILP::::NAME, ILP::::variant()), + ] + .into_iter() + .map(|(name, variant)| ReductionStep { + name: name.into(), + variant: ReductionGraph::variant_to_map(&variant), + }) + .collect(), + }; + let chain = ReductionGraph::new() + .reduce_along_path(&path, &source) + .unwrap() + .unwrap(); + // Knapsack -> ILP preserves the feasible incumbent; Partition -> Knapsack cannot use it. + let incumbent = + SolveOutcome::feasible(chain.target_problem::>(), vec![0, 0, 1]).unwrap(); + let error = chain + .recover_result::>(&source, incumbent) + .unwrap_err(); + assert!(error.is_insufficient_quality()); + assert_eq!( + error.to_string(), + "Partition -> Knapsack: the target result does not establish the conditions required \ + for source recovery" + ); +} diff --git a/src/unit_tests/rules/graphpartitioning_ilp.rs b/src/unit_tests/rules/graphpartitioning_ilp.rs index dc50e06a1..9533778e0 100644 --- a/src/unit_tests/rules/graphpartitioning_ilp.rs +++ b/src/unit_tests/rules/graphpartitioning_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{Comparison, ObjectiveSense}; use crate::models::graph::GraphPartitioning; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -90,7 +91,14 @@ fn test_graphpartitioning_to_ilp_closed_loop() { let bf_obj = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_obj, Min(Some(3))); @@ -122,7 +130,14 @@ fn test_solution_extraction() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = vec![0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false, false, true, true, true]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(3))); diff --git a/src/unit_tests/rules/graphpartitioning_maxcut.rs b/src/unit_tests/rules/graphpartitioning_maxcut.rs index d2e94e59a..c9bdd3ce9 100644 --- a/src/unit_tests/rules/graphpartitioning_maxcut.rs +++ b/src/unit_tests/rules/graphpartitioning_maxcut.rs @@ -1,6 +1,7 @@ use crate::models::graph::{GraphPartitioning, MaxCut}; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; fn issue_example() -> GraphPartitioning { @@ -56,7 +57,14 @@ fn test_graphpartitioning_to_maxcut_extract_solution_identity() { let target_solution = super::ISSUE_EXAMPLE_WITNESS.to_vec(); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), target_solution ); } @@ -66,3 +74,45 @@ fn test_graphpartitioning_to_maxcut_penalty_overflow_panics() { let result = std::panic::catch_unwind(|| super::penalty_weight(i64::MAX as usize)); assert!(result.is_err()); } + +#[test] +fn odd_partition_recovers_infeasibility_from_every_maxcut_optimum() { + use crate::solvers::{BruteForce, SolveOutcome}; + let source = GraphPartitioning::new(SimpleGraph::new(1, vec![])); + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let optima = BruteForce::new() + .find_all_witnesses(reduction.target_problem()) + .unwrap(); + assert_eq!(optima.len(), 2); + for solution in optima { + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + } +} + +#[test] +fn test_graphpartitioning_to_maxcut_rejects_feasible_target_incumbent() { + let source = issue_example(); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); + let optimum = crate::solvers::BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .unwrap(); + + // Balanced but suboptimal: {0, 2, 4} | {1, 3, 5} cuts 5 source edges; the optimum cuts 3. + crate::rules::test_helpers::assert_suboptimal_feasible_target_is_insufficient( + &source, + &reduction, + vec![true, false, true, false, true, false], + &optimum, + ); +} diff --git a/src/unit_tests/rules/graphpartitioning_qubo.rs b/src/unit_tests/rules/graphpartitioning_qubo.rs index b378a227c..287934785 100644 --- a/src/unit_tests/rules/graphpartitioning_qubo.rs +++ b/src/unit_tests/rules/graphpartitioning_qubo.rs @@ -42,7 +42,7 @@ fn test_graphpartitioning_to_qubo_matrix_matches_issue_example() { let expected_diagonal = [-48, -47, -46, -46, -47, -48]; for (index, expected) in expected_diagonal.into_iter().enumerate() { - assert_eq!(qubo.get(index, index), Some(&expected)); + assert_eq!(qubo.get(index, index), Some(expected)); } let edge_pairs = [ @@ -57,12 +57,12 @@ fn test_graphpartitioning_to_qubo_matrix_matches_issue_example() { (4, 5), ]; for &(u, v) in &edge_pairs { - assert_eq!(qubo.get(u, v), Some(&18), "edge ({u}, {v})"); + assert_eq!(qubo.get(u, v), Some(18), "edge ({u}, {v})"); } let non_edge_pairs = [(0, 3), (0, 4), (0, 5), (1, 4), (1, 5), (2, 5)]; for &(u, v) in &non_edge_pairs { - assert_eq!(qubo.get(u, v), Some(&20), "non-edge ({u}, {v})"); + assert_eq!(qubo.get(u, v), Some(20), "non-edge ({u}, {v})"); } } @@ -80,3 +80,44 @@ fn test_graphpartitioning_to_qubo_canonical_example_spec() { assert_eq!(example.target.instance["num_vars"], 6); assert!(!example.solutions.is_empty()); } + +#[test] +fn odd_partition_recovers_infeasibility_from_every_qubo_optimum() { + use crate::solvers::{BruteForce, SolveOutcome}; + let source = GraphPartitioning::new(SimpleGraph::new(1, vec![])); + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let optima = BruteForce::new() + .find_all_witnesses(reduction.target_problem()) + .unwrap(); + assert_eq!(optima.len(), 2); + for solution in optima { + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + } +} + +#[test] +fn test_graphpartitioning_to_qubo_rejects_feasible_target_incumbent() { + let source = example_problem(); + let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); + let optimum = crate::solvers::BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .unwrap(); + + // Balanced but suboptimal: {0, 2, 4} | {1, 3, 5} cuts 5 edges; the optimum cuts 3. + crate::rules::test_helpers::assert_suboptimal_feasible_target_is_insufficient( + &source, + &reduction, + vec![true, false, true, false, true, false], + &optimum, + ); +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index 490356692..169d04005 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -3,7 +3,9 @@ use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::Problem; fn cycle4_hc() -> HamiltonianCircuit { @@ -66,7 +68,14 @@ fn test_hamiltoniancircuit_to_biconnectivityaugmentation_extract_solution() { // Select edges (0,1), (0,3), (1,2), (2,3) => config [1, 0, 1, 1, 0, 1] let target_config = vec![true, false, true, true, false, true]; - let extracted = reduction.extract_solution(&target_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert!( @@ -143,7 +152,7 @@ fn test_hamiltoniancircuit_to_biconnectivityaugmentation_small_graphs() { assert_eq!(target.num_potential_edges(), 0); assert_eq!(*target.budget(), 0); assert!(!target.evaluate(&vec![]).unwrap().0); - assert!(reduction.extract_solution(&vec![]).is_err()); + assert!(BruteForce::new().solve(&source).unwrap().is_none()); assert!(BruteForce::new().solve(target).unwrap().is_none()); } @@ -173,9 +182,16 @@ fn test_hamiltoniancircuit_to_biconnectivityaugmentation_all_graphs_and_certific for mask in 0..1usize << pairs.len() { let config: Vec<_> = (0..pairs.len()).map(|i| mask & (1 << i) != 0).collect(); let feasible = reduction.target_problem().evaluate(&config).unwrap().0; - let extracted = reduction.extract_solution(&config); - assert_eq!(extracted.is_ok(), feasible); - if let Ok(circuit) = extracted { + if feasible { + let circuit = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&circuit).unwrap().0); target_yes = true; } @@ -194,10 +210,22 @@ fn test_hamiltoniancircuit_to_biconnectivityaugmentation_rejects_infeasible_cert let reduction = ReduceTo::>::reduce_to(&source).unwrap(); // A spanning cycle made only of non-edges exceeds the budget and is not a source cycle. - assert!(reduction.extract_solution(&vec![true; 3]).is_err()); - assert!(reduction.extract_solution(&vec![false; 3]).is_err()); - assert!(reduction.extract_solution(&vec![true; 2]).is_err()); - assert!(reduction.extract_solution(&vec![true; 4]).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![true; 3]) + .unwrap() + .is_valid()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![false; 3]) + .unwrap() + .is_valid()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![true; 2]), + Err(InvalidConfiguration(_)) + )); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![true; 4]), + Err(InvalidConfiguration(_)) + )); let source = HamiltonianCircuit::new(SimpleGraph::complete(6)); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); @@ -208,5 +236,8 @@ fn test_hamiltoniancircuit_to_biconnectivityaugmentation_rejects_infeasible_cert .iter() .map(|&(u, v, _)| (u < 3) == (v < 3)) .collect(); - assert!(reduction.extract_solution(&config).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } diff --git a/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index 5623656a4..e2b7f6c4a 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -3,6 +3,7 @@ use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::Min; use crate::Problem; @@ -78,10 +79,63 @@ fn test_hamiltoniancircuit_to_bottlenecktravelingsalesman_extract_solution_cycle .map(|(u, v)| cycle_edges.contains(&(u, v)) || cycle_edges.contains(&(v, u))) .collect(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Bottleneck should be 1 (all selected edges are original cycle edges) assert_eq!(target.evaluate(&target_solution).unwrap(), Min(Some(1))); assert_eq!(extracted.len(), 5); assert!(source.evaluate(&extracted).unwrap().is_valid()); } + +/// Edge selection of the closed tour visiting `order` in the complete target graph. +fn tour_edges(graph: &SimpleGraph, order: &[usize]) -> Vec { + graph + .edges() + .into_iter() + .map(|(u, v)| { + (0..order.len()).any(|i| { + let (a, b) = (order[i], order[(i + 1) % order.len()]); + (a, b) == (u, v) || (a, b) == (v, u) + }) + }) + .collect() +} + +#[test] +fn test_hamiltoniancircuit_to_bottlenecktravelingsalesman_feasible_target_incumbents() { + let source = cycle5_hc(); + let reduction = ReduceTo::::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem(); + + // 0-1-2-3-4 uses only source edges (bottleneck 1): the incumbent is a Hamiltonian circuit. + let circuit = tour_edges(target.graph(), &[0, 1, 2, 3, 4]); + assert_eq!(target.evaluate(&circuit).unwrap(), Min(Some(1))); + let recovered = reduction + .recover_result(&source, SolveOutcome::feasible(target, circuit).unwrap()) + .unwrap(); + let SolveOutcome::Feasible { + solution, + evaluation, + } = recovered + else { + panic!("a Hamiltonian incumbent must stay feasible, got {recovered:?}"); + }; + assert_eq!(evaluation, crate::types::Or(true)); + assert_eq!(source.evaluate(&solution).unwrap(), crate::types::Or(true)); + + // 0-2-4-1-3 is the pentagram (bottleneck 2): a valid tour that proves nothing about the source. + let pentagram = tour_edges(target.graph(), &[0, 2, 4, 1, 3]); + assert_eq!(target.evaluate(&pentagram).unwrap(), Min(Some(2))); + assert_eq!( + reduction.recover_result(&source, SolveOutcome::feasible(target, pentagram).unwrap()), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs b/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs index 4f0482880..c3f367344 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -3,6 +3,7 @@ use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::Problem; @@ -60,7 +61,14 @@ fn test_hamiltoniancircuit_to_hamiltonianpath_extract_solution() { // HP solution: s=5, 0, 1, 2, 3, v'=4, t=6 let hp_config = vec![5, 0, 1, 2, 3, 4, 6]; - let extracted = reduction.extract_solution(&hp_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), hp_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert!( @@ -77,7 +85,14 @@ fn test_hamiltoniancircuit_to_hamiltonianpath_extract_reversed() { // HP solution reversed: t=6, v'=4, 3, 2, 1, 0, s=5 let hp_config = vec![6, 4, 3, 2, 1, 0, 5]; - let extracted = reduction.extract_solution(&hp_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), hp_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert!( diff --git a/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs index e7d230743..30d5eb7a0 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs @@ -1,10 +1,13 @@ +use crate::models::decision::Decision; 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; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; -use crate::types::Max; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::types::OptimizationValue; use crate::Problem; fn cycle4_hc() -> HamiltonianCircuit { @@ -13,29 +16,37 @@ 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), + (crate::types::Max(None), false), + (crate::types::Max(Some(3)), false), + (crate::types::Max(Some(4)), true), ] { assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), + crate::types::Or(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(); - assert!(reduction.extract_solution(&vec![true; 3]).is_err()); + let reduction = + ReduceTo::>>::reduce_to(&short_cycle).unwrap(); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![true; 3]) + .unwrap() + .is_valid()); } #[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,56 +56,54 @@ 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(); // Same graph structure - assert_eq!(target.graph().num_vertices(), 4); - assert_eq!(target.graph().num_edges(), 4); + assert_eq!(target.inner().graph().num_vertices(), 4); + assert_eq!(target.inner().graph().num_edges(), 4); // All unit weights - assert!(target.edge_lengths().iter().all(|&w| w == 1)); + assert!(target.inner().edge_lengths().iter().all(|&w| w == 1)); } #[test] 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 solver = BruteForce::new(); let witness = solver.solve(target).unwrap(); - match witness { - Some(sol) => { - let value = target.evaluate(&sol).unwrap(); - // Optimal circuit length must be strictly less than n=4 - assert!( - value.unwrap() < 4, - "star graph should not have a circuit of length 4" - ); - } - None => { - // No circuit at all in a star graph — also acceptable - } - } + assert!(witness.is_none()); } #[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(); // All edges selected forms a Hamiltonian circuit on the cycle graph let target_solution = vec![true, true, true, true]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); - assert_eq!(target.evaluate(&target_solution).unwrap(), Max(Some(4))); + assert_eq!( + target.inner().evaluate(&target_solution).unwrap(), + crate::types::Max(Some(4)) + ); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).unwrap()); } @@ -115,27 +124,33 @@ fn test_hamiltoniancircuit_extraction_matches_all_small_target_configurations() .collect(); let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction); - for mask in 0usize..(1 << target.num_edges()) { - let config: Vec<_> = (0..target.num_edges()) + ReduceTo::>>::reduce_to(&source).unwrap(); + let target = crate::rules::ReductionResult::target_problem(&reduction); + for mask in 0usize..(1 << target.inner().num_edges()) { + let config: Vec<_> = (0..target.inner().num_edges()) .map(|i| (mask >> i) & 1 == 1) .collect(); - let value = target.evaluate(&config).unwrap(); + let value = target.inner().evaluate(&config).unwrap(); let certifies = value.0 == Some(n as i64); - let extracted = reduction.extract_solution(&config); - assert_eq!( - extracted.is_ok(), - certifies, - "n={n}, graph={graph_mask}, config={mask}" - ); - if let Ok(order) = extracted { + if certifies { + let order = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&order).unwrap().0); } } - assert!(reduction - .extract_solution(&vec![false; target.num_edges() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![false; target.inner().num_edges() + 1]), + Err(InvalidConfiguration(_)) + )); } } } diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs index 466d811f9..35087ad21 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -1,11 +1,13 @@ 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; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; -use crate::types::Min; +use crate::types::OptimizationValue; use crate::Problem; fn cycle4_hc() -> HamiltonianCircuit { @@ -15,10 +17,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,15 +30,15 @@ fn test_hamiltoniancircuit_to_quadraticassignment_closed_loop() { #[test] fn test_hamiltoniancircuit_to_quadraticassignment_structure() { let source = cycle4_hc(); - let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); - assert_eq!(target.num_facilities(), 4); - assert_eq!(target.num_locations(), 4); + assert_eq!(target.inner().num_facilities(), 4); + assert_eq!(target.inner().num_locations(), 4); // Cost matrix: cycle adjacency on positions - let cost = target.cost_matrix(); + let cost = target.inner().cost_matrix(); for (i, cost_row) in cost.iter().enumerate() { for (j, &cost_val) in cost_row.iter().enumerate() { let expected = if j == (i + 1) % 4 { 1 } else { 0 }; @@ -45,7 +47,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_structure() { } // Distance matrix: edges and diagonal cost zero, non-edges cost one. - let dist = target.distance_matrix(); + let dist = target.inner().distance_matrix(); for (k, dist_row) in dist.iter().enumerate() { for (l, &dist_val) in dist_row.iter().enumerate() { let expected = i64::from(k != l && !source.graph().has_edge(k, l)); @@ -57,8 +59,8 @@ fn test_hamiltoniancircuit_to_quadraticassignment_structure() { #[test] fn test_hamiltoniancircuit_to_quadraticassignment_optimal_cost_is_zero() { let source = cycle4_hc(); - let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); // The identity permutation [0,1,2,3] is a valid HC on a 4-cycle, @@ -67,23 +69,28 @@ fn test_hamiltoniancircuit_to_quadraticassignment_optimal_cost_is_zero() { .solve(target) .unwrap() .expect("QAP should have an optimal solution"); - let value = target.evaluate(&best).unwrap(); - assert_eq!(value, Min(Some(0)), "optimal QAP cost should be zero"); + let value = target.inner().evaluate(&best).unwrap(); + assert_eq!( + value, + crate::types::Min(Some(0)), + "optimal QAP cost should be zero" + ); } #[test] fn test_hamiltoniancircuit_to_quadraticassignment_nonhamiltonian_cost_gap() { // Star graph on 4 vertices has no Hamiltonian circuit let source = HamiltonianCircuit::new(SimpleGraph::star(4)); - let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); + assert!(BruteForce::new().solve(target).unwrap().is_none()); let best = BruteForce::new() - .solve(target) + .solve(target.inner()) .unwrap() .expect("QAP always has a solution"); - let value = target.evaluate(&best).unwrap(); + let value = target.inner().evaluate(&best).unwrap(); assert!( value.is_valid(), "QAP solution should have a valid objective" @@ -98,12 +105,19 @@ 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]; - let extracted = reduction.extract_solution(&target_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!( source.evaluate(&extracted).unwrap().0, @@ -131,14 +145,28 @@ 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"); - let qap_sol = r2.extract_solution(&ilp_sol).unwrap(); - let hc_sol = r1.extract_solution(&qap_sol).unwrap(); + let qap_sol = r2 + .recover_result( + r1.target_problem().inner(), + SolveOutcome::optimal(r2.target_problem(), ilp_sol).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); + let hc_sol = r1 + .recover_result( + &hc, + SolveOutcome::optimal(r1.target_problem(), qap_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( hc.evaluate(&hc_sol).unwrap().0, @@ -159,21 +187,31 @@ fn test_hamiltoniancircuit_to_quadraticassignment_small_graphs_are_no() { ] { let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); assert!(!source.evaluate(&(0..n).collect()).unwrap().0); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem(); - 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!(reduction.extract_solution(&best).is_err()); + assert!(BruteForce::new().solve(target).unwrap().is_none()); + assert_eq!(target.inner().num_facilities(), 3); + assert_eq!(target.inner().num_locations(), 3); + let best = BruteForce::new().solve(target.inner()).unwrap().unwrap(); + let value = target.inner().evaluate(&best).unwrap(); + assert_eq!(value, crate::types::Min(Some(3))); + assert!( + !crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&best) + .unwrap() + .is_valid()); } } #[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], @@ -181,12 +219,27 @@ fn test_hamiltoniancircuit_to_quadraticassignment_rejects_invalid_certificates() vec![0, 0, 1, 2], vec![0, 2, 1, 3], ] { - assert!(reduction.extract_solution(&config).is_err(), "{config:?}"); + assert!( + !matches!(reduction.target_problem().inner().evaluate(&config), + Ok(value) if crate::types::Or(OptimizationValue::meets_bound(&(value), crate::rules::ReductionResult::target_problem(&reduction).bound())).0) + ); } - for value in [Min(None), Min(Some(-1)), Min(Some(1))] { - assert!(!crate::rules::AggregateReductionResult::extract_value(&reduction, value).0); + for value in [crate::types::Min(None), crate::types::Min(Some(1))] { + assert!( + !crate::types::Or(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::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(Some(0))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); } #[test] @@ -206,7 +259,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,26 +274,46 @@ 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(), - Min(None) + reduction.target_problem().inner().evaluate(&order).unwrap(), + crate::types::Min(None) ); - assert!(reduction.extract_solution(&order).is_err()); continue; } 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(); - assert_eq!(value, Min(Some(i64::try_from(missing).unwrap()))); + let value = reduction.target_problem().inner().evaluate(&order).unwrap(); + assert_eq!( + value, + crate::types::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::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0, expected ); if expected { - assert_eq!(reduction.extract_solution(&order).unwrap(), order); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), order.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + order + ); } else { - assert!(reduction.extract_solution(&order).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&order) + .unwrap() + .is_valid()); } } } @@ -250,7 +323,6 @@ fn test_hamiltoniancircuit_to_quadraticassignment_all_small_graphs_and_orders() #[test] fn test_hamiltoniancircuit_to_quadraticassignment_registered_aggregate_path() { use crate::rules::{ReductionGraph, ReductionPath, ReductionStep}; - use crate::types::Or; let graph = ReductionGraph::new(); let path = ReductionPath { @@ -263,7 +335,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_registered_aggregate_path() { .collect(), }, ReductionStep { - name: QuadraticAssignment::NAME.to_string(), + name: Decision::::NAME.to_string(), variant: Default::default(), }, ], @@ -276,17 +348,24 @@ fn test_hamiltoniancircuit_to_quadraticassignment_registered_aggregate_path() { false, ), ] { - let chain = graph - .reduce_aggregate_along_path(&path, &source) - .unwrap() - .unwrap(); - let target = chain.target_problem::(); - let best = BruteForce::new().solve(target).unwrap().unwrap(); + let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + let target = chain.target_problem::>(); + let best = BruteForce::new().solve(target.inner()).unwrap().unwrap(); let optimum = target.evaluate(&best).unwrap(); - assert_eq!( - chain.extract_value_dyn(serde_json::to_value(optimum).unwrap()), - serde_json::to_value(Or(expected)).unwrap(), - ); + let outcome = if optimum.0 { + SolveOutcome::optimal(target, best).unwrap() + } else { + SolveOutcome::Infeasible + }; + let recovered = chain + .recover_result::, Decision>( + &source, outcome, + ) + .unwrap(); + assert_eq!(recovered.solution().is_some(), expected); + if let Some(witness) = recovered.solution() { + assert_eq!(source.evaluate(witness).unwrap(), crate::types::Or(true)); + } assert_eq!( BruteForce::new().solve(&source).unwrap().is_some(), expected diff --git a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs index b13ad1365..00c77d90a 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs @@ -1,10 +1,11 @@ +use crate::models::decision::Decision; 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; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; -use crate::types::Min; use crate::Problem; fn triangle_hc() -> HamiltonianCircuit { @@ -18,10 +19,10 @@ 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 +32,10 @@ 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,19 +45,19 @@ 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(); // 3 vertices -> 6 vertices - assert_eq!(target.num_vertices(), 6); + assert_eq!(target.inner().num_vertices(), 6); // 3 required edges + 2*3 connectivity edges = 9 - assert_eq!(target.num_edges(), 9); + assert_eq!(target.inner().num_edges(), 9); // 3 required edges (one per vertex) - assert_eq!(target.num_required_edges(), 3); + assert_eq!(target.inner().num_required_edges(), 3); // All edges have weight 1 - let weights = target.edge_lengths(); + let weights = target.inner().edge_lengths(); for (i, &w) in weights.iter().enumerate() { assert_eq!(w, 1, "edge {i} should have weight 1"); } @@ -65,23 +66,23 @@ 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(); // 4 vertices -> 8 vertices - assert_eq!(target.num_vertices(), 8); + assert_eq!(target.inner().num_vertices(), 8); // 4 required edges + 2*4 connectivity edges = 12 - assert_eq!(target.num_edges(), 12); + assert_eq!(target.inner().num_edges(), 12); // 4 required edges - assert_eq!(target.num_required_edges(), 4); + assert_eq!(target.inner().num_required_edges(), 4); } #[test] 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 best = BruteForce::new() @@ -89,8 +90,12 @@ fn test_hamiltoniancircuit_to_ruralpostman_optimal_cost() { .unwrap() .expect("should find a solution"); - let metric = target.evaluate(&best).unwrap(); - assert_eq!(metric, Min(Some(6)), "optimal cost should be 2n=6"); + let metric = target.inner().evaluate(&best).unwrap(); + assert_eq!( + metric, + crate::types::Min(Some(6)), + "optimal cost should be 2n=6" + ); } #[test] @@ -99,7 +104,7 @@ 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(); @@ -110,7 +115,7 @@ fn test_hamiltoniancircuit_to_ruralpostman_nonhamiltonian_cost_gap() { // The RPP optimal cost should exceed 2n = 8 let best = BruteForce::new().solve(target).unwrap(); if let Some(config) = best { - let metric = target.evaluate(&config).unwrap(); + let metric = target.inner().evaluate(&config).unwrap(); assert!( metric.is_valid(), "best RPP solution should be a valid circuit" @@ -127,7 +132,7 @@ 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(); @@ -136,7 +141,14 @@ fn test_hamiltoniancircuit_to_ruralpostman_extract_solution() { .unwrap() .expect("should find a solution"); - let extracted = reduction.extract_solution(&best).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), best.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted.len(), 3, @@ -147,3 +159,41 @@ fn test_hamiltoniancircuit_to_ruralpostman_extract_solution() { "extracted solution should be a valid Hamiltonian circuit" ); } + +#[test] +fn aggregate_distinguishes_hamiltonian_tour_cost() { + for (edges, expected) in [ + (vec![(0, 1), (1, 2), (0, 2)], true), + (vec![(0, 1), (1, 2)], false), + ] { + let source = HamiltonianCircuit::new(SimpleGraph::new(3, edges)); + let reduction = + ReduceTo::>>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + let solution = crate::solvers::ILPSolver::new() + .solve(target.inner()) + .unwrap(); + assert_eq!( + target.evaluate(&solution).unwrap(), + crate::types::Or(expected) + ); + if expected { + assert!( + source + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) + .unwrap() + .0 + ); + } + } +} diff --git a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs index 58121a30f..56400be9d 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs @@ -1,11 +1,14 @@ +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; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; -use crate::types::Min; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::types::OptimizationValue; use crate::Problem; fn cycle4_hc() -> HamiltonianCircuit { @@ -15,9 +18,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,22 +31,23 @@ fn test_hamiltoniancircuit_to_stackercrane_closed_loop() { #[test] fn test_hamiltoniancircuit_to_stackercrane_structure() { let source = cycle4_hc(); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target = reduction.target_problem(); // 4 vertices -> 8 target vertices (2 per original vertex) - assert_eq!(target.num_vertices(), 8); + assert_eq!(target.inner().num_vertices(), 8); // 4 arcs (one per original vertex) - assert_eq!(target.num_arcs(), 4); + assert_eq!(target.inner().num_arcs(), 4); // 4 original edges -> 8 undirected connector edges - assert_eq!(target.num_edges(), 8); + assert_eq!(target.inner().num_edges(), 8); // All arcs have length 1 - for &len in target.arc_lengths() { + for &len in target.inner().arc_lengths() { assert_eq!(len, 1); } // All connector edges have length 1 - for &len in target.edge_lengths() { + for &len in target.inner().edge_lengths() { assert_eq!(len, 1); } } @@ -51,15 +56,16 @@ fn test_hamiltoniancircuit_to_stackercrane_structure() { fn test_hamiltoniancircuit_to_stackercrane_optimal_cost() { // A 4-cycle has a Hamiltonian circuit; optimal StackerCrane cost = 2n = 8. let source = cycle4_hc(); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target = reduction.target_problem(); let witness = BruteForce::new() .solve(target) .unwrap() .expect("target should have a solution"); - let cost = target.evaluate(&witness).unwrap(); - assert_eq!(cost, Min(Some(8))); + let cost = target.inner().evaluate(&witness).unwrap(); + assert_eq!(cost, crate::types::Min(Some(8))); } #[test] @@ -67,13 +73,14 @@ fn test_hamiltoniancircuit_to_stackercrane_non_hamiltonian() { // Star graph on 4 vertices: no Hamiltonian circuit. // The optimal StackerCrane cost should exceed 2n = 8. let source = HamiltonianCircuit::new(SimpleGraph::star(4)); - let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = + ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target = reduction.target_problem(); let witness = BruteForce::new().solve(target).unwrap(); match witness { Some(w) => { - let cost = target.evaluate(&w).unwrap(); + let cost = target.inner().evaluate(&w).unwrap(); assert!( cost.0.unwrap() > 8, "non-Hamiltonian graph should have cost > 2n" @@ -88,12 +95,20 @@ 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. let target_config = vec![0, 1, 2, 3]; - let extracted = reduction.extract_solution(&target_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!( source.evaluate(&extracted).unwrap().0, @@ -118,9 +133,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 +156,8 @@ fn test_stackercrane_certificate_for_all_small_configurations() { .filter_map(|(i, &e)| ((mask >> i) & 1 == 1).then_some(e)) .collect(); let source = HamiltonianCircuit::new(SimpleGraph::new(n, edges)); - let reduction = ReduceTo::::reduce_to(&source).unwrap(); - let target = crate::rules::AggregateReductionResult::target_problem(&reduction); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target = crate::rules::ReductionResult::target_problem(&reduction); // All coordinate configurations, including repeated arc indices. for mut code in 0..n.pow(n as u32) { let config: Vec<_> = (0..n) @@ -152,24 +168,41 @@ fn test_stackercrane_certificate_for_all_small_configurations() { }) .collect(); let expected = source.evaluate(&config).unwrap().0; - let value = target.evaluate(&config).unwrap(); + let value = target.inner().evaluate(&config).unwrap(); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0, + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0, expected ); - let decoded = reduction.extract_solution(&config); - assert_eq!( - decoded.is_ok(), - expected, - "n={n}, mask={mask}, config={config:?}" - ); - if let Ok(order) = decoded { + if expected { + let order = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&order).unwrap().0); } } - assert!(reduction.extract_solution(&vec![0; n + 1]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![0; n + 1]), + Err(InvalidConfiguration(_)) + )); if n > 0 { - assert!(reduction.extract_solution(&vec![n; n]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![n; n]), + Err(InvalidConfiguration(_)) + )); } } } diff --git a/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index f81fd7003..8958c681d 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -3,6 +3,7 @@ use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::Problem; @@ -90,7 +91,14 @@ fn test_hamiltoniancircuit_to_strongconnectivityaugmentation_extract_solution() assert!(target.is_valid_solution(&target_config).unwrap()); - let extracted = reduction.extract_solution(&target_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert!( source.evaluate(&extracted).unwrap().is_valid(), diff --git a/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs b/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs index 4eecf4f08..24968b101 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs @@ -3,6 +3,7 @@ use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization use crate::rules::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::types::Min; use crate::Problem; @@ -70,9 +71,62 @@ fn test_hamiltoniancircuit_to_travelingsalesman_extract_solution_cycle() { .map(|(u, v)| cycle_edges.contains(&(u, v)) || cycle_edges.contains(&(v, u))) .collect(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(target.evaluate(&target_solution).unwrap(), Min(Some(4))); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).unwrap()); } + +/// Edge selection of the closed tour visiting `order` in the complete target graph. +fn tour_edges(graph: &SimpleGraph, order: &[usize]) -> Vec { + graph + .edges() + .into_iter() + .map(|(u, v)| { + (0..order.len()).any(|i| { + let (a, b) = (order[i], order[(i + 1) % order.len()]); + (a, b) == (u, v) || (a, b) == (v, u) + }) + }) + .collect() +} + +#[test] +fn test_hamiltoniancircuit_to_travelingsalesman_feasible_target_incumbents() { + let source = cycle4_hc(); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); + let target = reduction.target_problem(); + + // 0-1-2-3 uses only source edges (cost 4): the incumbent already is a Hamiltonian circuit. + let circuit = tour_edges(target.graph(), &[0, 1, 2, 3]); + assert_eq!(target.evaluate(&circuit).unwrap(), Min(Some(4))); + let recovered = reduction + .recover_result(&source, SolveOutcome::feasible(target, circuit).unwrap()) + .unwrap(); + let SolveOutcome::Feasible { + solution, + evaluation, + } = recovered + else { + panic!("a Hamiltonian incumbent must stay feasible, got {recovered:?}"); + }; + assert_eq!(evaluation, crate::types::Or(true)); + assert_eq!(source.evaluate(&solution).unwrap(), crate::types::Or(true)); + + // 0-1-3-2 uses the two diagonals (cost 6): a valid tour that proves nothing about the source. + let detour = tour_edges(target.graph(), &[0, 1, 3, 2]); + assert_eq!(target.evaluate(&detour).unwrap(), Min(Some(6))); + assert_eq!( + reduction.recover_result(&source, SolveOutcome::feasible(target, detour).unwrap()), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); +} diff --git a/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index 06fc3a4a9..c2f530637 100644 --- a/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -1,6 +1,7 @@ use crate::models::graph::{DegreeConstrainedSpanningTree, HamiltonianPath}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; @@ -52,7 +53,14 @@ fn test_hamiltonianpath_to_degreeconstrainedspanningtree_extract_solution_recons &[(0, 1), (1, 2), (2, 3)], ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!(source.evaluate(&extracted).unwrap()); diff --git a/src/unit_tests/rules/hamiltonianpath_ilp.rs b/src/unit_tests/rules/hamiltonianpath_ilp.rs index 66a2a897e..0c84c65f5 100644 --- a/src/unit_tests/rules/hamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/hamiltonianpath_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -36,7 +37,14 @@ fn test_hamiltonianpath_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -63,7 +71,14 @@ fn test_hamiltonianpath_to_ilp_cycle_graph() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -83,8 +98,9 @@ fn test_hamiltonianpath_to_ilp_no_path() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); - assert!( - result.is_err(), + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), "Disconnected graph should have no Hamiltonian path" ); } @@ -98,6 +114,13 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs index f0503e307..583199a44 100644 --- a/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -3,6 +3,7 @@ use crate::models::graph::{HamiltonianPath, IsomorphicSpanningTree}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -87,7 +88,14 @@ fn test_hamiltonianpath_to_isomorphicspanningtree_complete_graph() { .solve(result.target_problem()) .unwrap() .expect("K4 should have an IST solution"); - let extracted = result.extract_solution(&target_solution).unwrap(); + let extracted = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Extracted solution should be a valid Hamiltonian path assert!( source.evaluate(&extracted).unwrap().0, diff --git a/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs index 4e87892d5..102f804be 100644 --- a/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -1,10 +1,14 @@ -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::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::types::One; +use crate::types::OptimizationValue; #[test] fn test_hamiltonianpathbetweentwovertices_to_longestpath_closed_loop() { @@ -14,16 +18,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(); - 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_eq!(target.inner().num_vertices(), 5); + assert_eq!(target.inner().num_edges(), 6); + assert_eq!(target.inner().source_vertex(), 0); + assert_eq!(target.inner().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 +42,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 +62,12 @@ 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(); + assert!(solver.solve(result.target_problem()).unwrap().is_none()); let target_best = solver - .solve(result.target_problem()) + .solve(result.target_problem().inner()) .unwrap() .expect("LongestPath should have some valid path"); @@ -82,10 +87,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 +105,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(); - assert_eq!(target.num_vertices(), 3); - assert_eq!(target.num_edges(), 3); + assert_eq!(target.inner().num_vertices(), 3); + assert_eq!(target.inner().num_edges(), 3); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, "HamiltonianPathBetweenTwoVertices->LongestPath triangle", @@ -138,33 +143,47 @@ 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::ReductionResult::target_problem(&reduction); for mask in 0usize..(1 << edges.len()) { let config: Vec<_> = (0..edges.len()).map(|i| (mask >> i) & 1 == 1).collect(); - let value = target.evaluate(&config).unwrap(); + let value = target.inner().evaluate(&config).unwrap(); let expected = value.0 == Some(n as i64 - 1); assert_eq!( - crate::rules::AggregateReductionResult::extract_value( - &reduction, value - ) + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) .0, expected ); - let result = reduction.extract_solution(&config); - assert_eq!( - result.is_ok(), - expected, - "n={n}, graph={graph_mask}, s={start}, t={end}, config={mask}" - ); - if let Ok(order) = result { + if expected { + let order = reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + config.clone(), + ) + .unwrap(), + ) + .map(|result| { + result.into_solution().expect( + "qualifying target result must recover a source solution", + ) + }) + .unwrap(); assert!(source.evaluate(&order).unwrap().0); } } - assert!(reduction - .extract_solution(&vec![false; edges.len() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![false; edges.len() + 1]), + Err(InvalidConfiguration(_)) + )); } } } diff --git a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs index 2dc13ee79..5735ce1a6 100644 --- a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs +++ b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::HighlyConnectedDeletion; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -70,7 +71,14 @@ fn test_highlyconnecteddeletion_to_ilp_extract_solution_decode() { target_solution[3] = 1; // singleton {3} target_solution[4] = 1; // triangle {0,1,2} - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Edges in input order: (0,1), (0,2), (1,2) all inside the triangle (kept); // (2,3) crosses clusters and is deleted. @@ -85,12 +93,10 @@ fn test_highlyconnecteddeletion_to_ilp_rejects_unassigned_vertex() { let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target_solution = vec![0; reduction.target_problem().num_vars()]; - assert_eq!( - reduction - .extract_solution(&target_solution) - .unwrap_err() - .to_string(), - "vertex 0 has no selected cluster" + assert!( + !crate::traits::Problem::evaluate(reduction.target_problem(), &target_solution) + .unwrap() + .is_valid() ); } @@ -125,3 +131,16 @@ fn test_highlyconnecteddeletion_to_ilp_disconnected_no_cluster() { assert_bf_vs_ilp(&source, &reduction); } + +#[test] +fn subset_mask_limit_belongs_to_the_reduction() { + let source = HighlyConnectedDeletion::new(SimpleGraph::new(64, vec![])); + assert_eq!( + source.evaluate(&vec![]).unwrap(), + crate::types::Min(Some(0)) + ); + assert!(matches!( + ReduceTo::>::reduce_to(&source), + Err(crate::rules::ReductionError::IntegerOverflow { .. }) + )); +} diff --git a/src/unit_tests/rules/ilp_bool_ilp_i64.rs b/src/unit_tests/rules/ilp_bool_ilp_i64.rs index 2c31e6393..de1bc87e7 100644 --- a/src/unit_tests/rules/ilp_bool_ilp_i64.rs +++ b/src/unit_tests/rules/ilp_bool_ilp_i64.rs @@ -1,6 +1,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; #[test] @@ -30,7 +31,14 @@ fn test_ilp_bool_to_ilp_i64_closed_loop() { // Extract solution back to source and verify optimality let target_solution = ILPSolver::new().solve(target).unwrap(); - let source_solution = result.extract_solution(&target_solution).unwrap(); + let source_solution = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&source_solution).unwrap(), source_obj); } diff --git a/src/unit_tests/rules/ilp_helpers.rs b/src/unit_tests/rules/ilp_helpers.rs index 9ca18265d..9d82c06dd 100644 --- a/src/unit_tests/rules/ilp_helpers.rs +++ b/src/unit_tests/rules/ilp_helpers.rs @@ -153,7 +153,7 @@ fn test_one_hot_decode_permutation() { solution[2] = 1; // item 0 -> slot 2 solution[3] = 1; // item 1 -> slot 0 solution[7] = 1; // item 2 -> slot 1 - let decoded = one_hot_decode(&solution, 3, 3, 0).unwrap(); + let decoded = one_hot_decode(&solution, 3, 3, 0); assert_eq!(decoded, vec![1, 2, 0]); // slot 0 gets item 1, slot 1 gets item 2, slot 2 gets item 0 } @@ -164,25 +164,16 @@ fn test_one_hot_decode_with_offset() { solution[7] = 1; // 5 + 2 solution[8] = 1; // 5 + 3 solution[12] = 1; // 5 + 7 - let decoded = one_hot_decode(&solution, 3, 3, 5).unwrap(); + let decoded = one_hot_decode(&solution, 3, 3, 5); assert_eq!(decoded, vec![1, 2, 0]); } -#[test] -fn test_one_hot_decode_rejects_missing_and_duplicate_items() { - assert!(one_hot_decode(&[0, 0, 0, 0], 2, 2, 0).is_err()); - assert!(one_hot_decode(&[1, 0, 1, 0], 2, 2, 0).is_err()); - assert!(one_hot_decode(&[1, 1, 0, 0], 2, 2, 0).is_err()); -} - #[test] fn test_one_hot_decode_rows_accepts_exactly_one_column_per_row() { assert_eq!( - one_hot_decode_rows(&[0, 1, 0, 1, 0, 0], 2, 3, 0).unwrap(), + one_hot_decode_rows(&[0, 1, 0, 1, 0, 0], 2, 3, 0), vec![1, 0] ); - assert!(one_hot_decode_rows(&[0, 0, 0, 1, 0, 0], 2, 3, 0).is_err()); - assert!(one_hot_decode_rows(&[1, 1, 0, 1, 0, 0], 2, 3, 0).is_err()); } #[test] diff --git a/src/unit_tests/rules/ilp_i64_ilp_bool.rs b/src/unit_tests/rules/ilp_i64_ilp_bool.rs index 75be4dc93..384e5d14c 100644 --- a/src/unit_tests/rules/ilp_i64_ilp_bool.rs +++ b/src/unit_tests/rules/ilp_i64_ilp_bool.rs @@ -1,6 +1,7 @@ use crate::models::algebraic::{IntegerVariable, LinearConstraint, ObjectiveSense, ILP}; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; fn integer_ilp( bounds: &[(i64, i64)], @@ -22,8 +23,23 @@ fn integer_ilp( fn solve_via_bool(source: &ILP) -> Option<(Vec, i64)> { let reduction = ReduceTo::>::reduce_to(source).expect("reduction should succeed"); - let witness = ILPSolver::new().solve(reduction.target_problem()).ok()?; - let source_solution = reduction.extract_solution(&witness).unwrap(); + let witness = match ILPSolver::new().solve(reduction.target_problem()) { + Ok(solution) => solution, + Err(crate::solvers::ILPSolveError::Infeasible) => return None, + Err(error) => panic!("ILP execution failed: {error}"), + }; + let source_solution = reduction + .recover_result( + source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert!( + source.is_feasible(&source_solution).unwrap(), + "decoded integer ILP solution must be feasible" + ); let objective = source.evaluate_objective(&source_solution).unwrap(); Some((source_solution, objective)) } @@ -39,8 +55,7 @@ fn test_ilp_i64_to_ilp_bool_closed_loop() { vec![(0, -5), (1, -6)], ObjectiveSense::Minimize, ); - let (solution, objective) = solve_via_bool(&source).unwrap(); - assert!(source.is_feasible(&solution).unwrap()); + let (_, objective) = solve_via_bool(&source).unwrap(); assert_eq!(objective, -27); } @@ -52,8 +67,7 @@ fn test_ilp_i64_to_ilp_bool_maximize() { vec![(0, 3), (1, 5)], ObjectiveSense::Maximize, ); - let (solution, objective) = solve_via_bool(&source).unwrap(); - assert!(source.is_feasible(&solution).unwrap()); + let (_, objective) = solve_via_bool(&source).unwrap(); assert_eq!(objective, 24); } @@ -98,8 +112,7 @@ fn test_ilp_i64_to_ilp_bool_equality_constraint() { vec![(0, 1)], ObjectiveSense::Minimize, ); - let (solution, objective) = solve_via_bool(&source).unwrap(); - assert!(source.is_feasible(&solution).unwrap()); + let (_, objective) = solve_via_bool(&source).unwrap(); assert_eq!(objective, 1); } @@ -130,7 +143,10 @@ fn test_ilp_i64_to_ilp_bool_infeasible() { ObjectiveSense::Minimize, ); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] diff --git a/src/unit_tests/rules/ilp_casts.rs b/src/unit_tests/rules/ilp_i64_ilp_f64.rs similarity index 56% rename from src/unit_tests/rules/ilp_casts.rs rename to src/unit_tests/rules/ilp_i64_ilp_f64.rs index 2784c1f0b..af2ece291 100644 --- a/src/unit_tests/rules/ilp_casts.rs +++ b/src/unit_tests/rules/ilp_i64_ilp_f64.rs @@ -1,7 +1,10 @@ use super::*; use crate::models::algebraic::{IntegerVariable, ObjectiveSense}; -use crate::rules::ReductionGraph; +use crate::rules::{ReductionGraph, ReductionResult}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::traits::Problem; use crate::types::MAX_EXACT_F64_INTEGER; #[test] @@ -25,7 +28,14 @@ fn test_ilp_i64_coefficients_to_f64_closed_loop() { ); let target_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![1, 0] ); } @@ -35,7 +45,7 @@ fn test_ilp_i64_coefficients_to_f64_rejects_inexact_value() { let source = ILP::::new( 1, vec![], - vec![(0, MAX_EXACT_F64_INTEGER + 1)], + vec![(0, MAX_EXACT_F64_INTEGER + 2)], ObjectiveSense::Minimize, ) .unwrap(); @@ -47,7 +57,7 @@ fn test_ilp_i64_coefficients_to_f64_rejects_inexact_value() { } #[test] -fn test_ilp_cast_rechecks_source_feasibility() { +fn test_ilp_integer_coefficients_preserve_large_exact_constraint() { let rhs = 1_000_000_000_000_i64; let source = ILP::::with_variables( vec![IntegerVariable::nonnegative()], @@ -57,13 +67,36 @@ fn test_ilp_cast_rechecks_source_feasibility() { ) .unwrap(); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let target_solution = vec![rhs + 1]; + assert_eq!(reduction.target_problem().variables(), source.variables()); + assert_eq!( + reduction.target_problem().constraints()[0].terms(), + &[(0, 1.0)] + ); + assert_eq!( + reduction.target_problem().constraints()[0].rhs(), + rhs as f64 + ); + let target_solution = vec![rhs]; assert!(reduction .target_problem() .is_feasible(&target_solution) .unwrap()); - assert!(reduction.extract_solution(&target_solution).is_err()); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + target_solution + ); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); } #[test] diff --git a/src/unit_tests/rules/ilp_qubo.rs b/src/unit_tests/rules/ilp_qubo.rs index 08300e2c8..9d33b708d 100644 --- a/src/unit_tests/rules/ilp_qubo.rs +++ b/src/unit_tests/rules/ilp_qubo.rs @@ -1,7 +1,10 @@ use super::*; use crate::models::algebraic::{LinearConstraint, ObjectiveSense}; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; #[test] fn test_ilp_to_qubo_closed_loop() { @@ -25,12 +28,26 @@ fn test_ilp_to_qubo_closed_loop() { let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ilp.is_feasible(&extracted).unwrap()); } // Optimal should be [1, 0, 1] - let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); + let best = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), qubo_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(best, vec![1, 0, 1]); } @@ -53,11 +70,25 @@ fn test_ilp_to_qubo_minimize() { let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ilp.is_feasible(&extracted).unwrap()); } - let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); + let best = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), qubo_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(best, vec![1, 0, 0]); } @@ -83,7 +114,14 @@ fn test_ilp_to_qubo_equality() { assert_eq!(qubo_solutions.len(), 3); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ilp.is_feasible(&extracted).unwrap()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); } @@ -105,18 +143,32 @@ fn test_ilp_to_qubo_ge_with_slack() { let qubo = reduction.target_problem(); // 3 original + ceil(log2(3))=2 slack = 5 QUBO variables - assert_eq!(qubo.num_variables(), 5); + assert_eq!(qubo.num_variables().unwrap(), 5); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ilp.is_feasible(&extracted).unwrap()); } // Optimal: exactly one variable = 1 - let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); + let best = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), qubo_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(best.iter().sum::(), 1); } @@ -136,18 +188,32 @@ fn test_ilp_to_qubo_le_with_slack() { let qubo = reduction.target_problem(); // 3 original + ceil(log2(3))=2 slack = 5 QUBO variables - assert_eq!(qubo.num_variables(), 5); + assert_eq!(qubo.num_variables().unwrap(), 5); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ilp.is_feasible(&extracted).unwrap()); } // Optimal: exactly 2 of 3 variables = 1 (3 solutions) - let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); + let best = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), qubo_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(best.iter().sum::(), 2); } @@ -164,12 +230,11 @@ fn test_ilp_to_qubo_structure() { let qubo = reduction.target_problem(); // Verify QUBO has appropriate structure - assert!(qubo.num_variables() >= ilp.num_vars()); + assert!(qubo.num_variables().unwrap() >= ilp.num_vars()); } #[test] fn test_ilp_qubo_all_small_rows_and_target_assignments() { - use crate::rules::AggregateReductionResult; use crate::Problem; for n in 0usize..=3 { for mut code in 0..3usize.pow(n as u32) { @@ -195,7 +260,7 @@ fn test_ilp_qubo_all_small_rows_and_target_assignments() { ILP::::new(n, vec![row.clone()], objective.clone(), sense) .unwrap(); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - let target = AggregateReductionResult::target_problem(&reduction); + let target = crate::rules::ReductionResult::target_problem(&reduction); let penalty = objective.iter().map(|(_, c)| c.abs()).sum::() + rhs.abs() + 1; let constant = penalty * rhs * rhs; @@ -215,21 +280,29 @@ fn test_ilp_qubo_all_small_rows_and_target_assignments() { // Independently detect zero squared-residual penalty. let certifies = source_value.is_valid() && energy.0.unwrap() + constant == normalized_objective; - let decoded = reduction.extract_solution(&config); - assert_eq!(decoded.is_ok(), certifies); - let extracted_value = - AggregateReductionResult::extract_value(&reduction, energy); + let extracted_value = reduction.map_value(energy); assert_eq!(extracted_value.is_valid(), certifies); - if let Ok(solution) = decoded { + if certifies { + let solution = reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + config.clone(), + ) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect( + "qualifying target result must recover a source solution", + ); assert_eq!(source.evaluate(&solution).unwrap(), source_value); assert_eq!(extracted_value, source_value); } } let best = BruteForce::new().solve(target).unwrap().unwrap(); - let actual = AggregateReductionResult::extract_value( - &reduction, - target.evaluate(&best).unwrap(), - ); + let actual = reduction.map_value(target.evaluate(&best).unwrap()); let mut expected = match sense { ObjectiveSense::Minimize => crate::types::Extremum::minimize(None), ObjectiveSense::Maximize => crate::types::Extremum::maximize(None), @@ -244,9 +317,16 @@ fn test_ilp_qubo_all_small_rows_and_target_assignments() { .unwrap(); } assert_eq!(actual, expected); - assert!(reduction - .extract_solution(&vec![false; target.num_vars() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![ + false; + target + .num_vars( + ) + + 1 + ]), + Err(InvalidConfiguration(_)) + )); } } } @@ -256,7 +336,6 @@ fn test_ilp_qubo_all_small_rows_and_target_assignments() { #[test] fn test_ilp_qubo_inconsistent_rows_and_absent_aggregate() { - use crate::rules::AggregateReductionResult; use crate::Problem; for sense in [ObjectiveSense::Minimize, ObjectiveSense::Maximize] { let source = ILP::::new( @@ -274,23 +353,15 @@ fn test_ilp_qubo_inconsistent_rows_and_absent_aggregate() { let value = ReductionResult::target_problem(&reduction) .evaluate(&config) .unwrap(); - assert!(!AggregateReductionResult::extract_value(&reduction, value).is_valid()); - assert!(reduction.extract_solution(&config).is_err()); + assert!(!reduction.map_value(value).is_valid()); } - assert!( - !AggregateReductionResult::extract_value(&reduction, crate::types::Min(None)) - .is_valid() - ); - assert!(!AggregateReductionResult::extract_value( - &reduction, - crate::types::Min(Some(i64::MIN)) - ) - .is_valid()); - assert!(!AggregateReductionResult::extract_value( - &reduction, - crate::types::Min(Some(i64::MAX)) - ) - .is_valid()); + assert!(!reduction.map_value(crate::types::Min(None)).is_valid()); + assert!(!reduction + .map_value(crate::types::Min(Some(i64::MIN))) + .is_valid()); + assert!(!reduction + .map_value(crate::types::Min(Some(i64::MAX))) + .is_valid()); } } @@ -321,3 +392,90 @@ fn test_ilp_qubo_checked_dimensions_and_energy_interval() { )); } } + +#[test] +fn test_ilp_to_qubo_feasible_target_incumbents() { + use crate::Problem; + // maximize x0 + 2*x1 + 3*x2 s.t. x0 + x1 <= 1, x1 + x2 <= 1 + let ilp = ILP::::new( + 3, + vec![ + LinearConstraint::le(vec![(0, 1), (1, 1)], 1), + LinearConstraint::le(vec![(1, 1), (2, 1)], 1), + ], + vec![(0, 1), (1, 2), (2, 3)], + ObjectiveSense::Maximize, + ) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&ilp).unwrap(); + let qubo = reduction.target_problem(); + let num_target_vars = qubo.num_vars(); + + // Every target assignment is a valid QUBO incumbent. It may be mapped back only + // when its penalty vanishes, and then it must be the source-feasible prefix. + let mut recovered_sources = std::collections::BTreeSet::new(); + let mut rejected = 0; + for bits in 0..1usize << num_target_vars { + let candidate: Vec = (0..num_target_vars).map(|i| bits & (1 << i) != 0).collect(); + let prefix: Vec = candidate[..3].iter().map(|&bit| i64::from(bit)).collect(); + match reduction.recover_result(&ilp, SolveOutcome::feasible(qubo, candidate).unwrap()) { + Ok(SolveOutcome::Feasible { + solution, + evaluation, + }) => { + assert!(ilp.is_feasible(&prefix).unwrap()); + assert_eq!(solution, prefix); + assert_eq!(evaluation, ilp.evaluate(&prefix).unwrap()); + recovered_sources.insert(prefix); + } + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) => rejected += 1, + other => panic!("unexpected incumbent recovery: {other:?}"), + } + } + + // Exactly the five source-feasible assignments are reachable, including the + // suboptimal ones such as x = (1, 0, 0) with objective 1 < 4. + let feasible_sources: std::collections::BTreeSet> = (0..8) + .map(|bits| (0..3).map(|i| i64::from(bits & (1 << i) != 0)).collect()) + .filter(|x: &Vec| ilp.is_feasible(x).unwrap()) + .collect(); + assert_eq!(feasible_sources.len(), 5); + assert_eq!(recovered_sources, feasible_sources); + assert!(rejected > 0); +} + +#[test] +fn test_ilp_to_qubo_infeasible_source_recovers_infeasible() { + for sense in [ObjectiveSense::Minimize, ObjectiveSense::Maximize] { + let source = ILP::::new( + 3, + vec![ + LinearConstraint::eq(vec![(0, 1)], 0), + LinearConstraint::eq(vec![(0, 1)], 1), + ], + vec![(1, 2), (2, -1)], + sense, + ) + .unwrap(); + assert!((0..8).all(|bits| { + let x: Vec = (0..3).map(|i| i64::from(bits & (1 << i) != 0)).collect(); + !source.is_feasible(&x).unwrap() + })); + + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let qubo = reduction.target_problem(); + let optimum = BruteForce::new().solve(qubo).unwrap().unwrap(); + assert_eq!( + reduction.recover_result( + &source, + SolveOutcome::optimal(qubo, optimum.clone()).unwrap() + ), + Ok(SolveOutcome::Infeasible) + ); + // The same assignment without an optimality proof establishes nothing. + assert_eq!( + reduction.recover_result(&source, SolveOutcome::feasible(qubo, optimum).unwrap()), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); + } +} diff --git a/src/unit_tests/rules/integerknapsack_ilp.rs b/src/unit_tests/rules/integerknapsack_ilp.rs index 8b421114a..b08eac243 100644 --- a/src/unit_tests/rules/integerknapsack_ilp.rs +++ b/src/unit_tests/rules/integerknapsack_ilp.rs @@ -5,6 +5,7 @@ use crate::models::set::IntegerKnapsack; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; #[test] fn test_integerknapsack_to_ilp_closed_loop() { @@ -16,7 +17,14 @@ fn test_integerknapsack_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 2]); } @@ -64,7 +72,14 @@ fn test_integerknapsack_to_ilp_zero_capacity() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("zero-capacity ILP should still be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0]); } diff --git a/src/unit_tests/rules/integralflowbundles_ilp.rs b/src/unit_tests/rules/integralflowbundles_ilp.rs index b9bea620c..7c2885d1f 100644 --- a/src/unit_tests/rules/integralflowbundles_ilp.rs +++ b/src/unit_tests/rules/integralflowbundles_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -78,7 +79,14 @@ fn test_integral_flow_bundles_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap()); } @@ -89,7 +97,15 @@ fn test_integral_flow_bundles_to_ilp_extract_solution_is_identity() { let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); assert_eq!( - reduction.extract_solution(&satisfying_config()).unwrap(), + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), satisfying_config().clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![1, 0, 1, 0, 0, 0] ); } @@ -99,7 +115,10 @@ fn test_integral_flow_bundles_to_ilp_unsat_instance_is_infeasible() { let problem = no_instance(); let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] diff --git a/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs b/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs index 07888d3b8..c6ff245ac 100644 --- a/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs +++ b/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -27,7 +28,14 @@ fn test_integralflowhomologousarcs_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs b/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs index bf0662855..e226aeb76 100644 --- a/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs +++ b/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -26,7 +27,14 @@ fn test_integralflowwithmultipliers_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/isomorphicspanningtree_ilp.rs b/src/unit_tests/rules/isomorphicspanningtree_ilp.rs index 4f90f2ee2..d71c009a6 100644 --- a/src/unit_tests/rules/isomorphicspanningtree_ilp.rs +++ b/src/unit_tests/rules/isomorphicspanningtree_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -51,7 +52,14 @@ fn test_isomorphicspanningtree_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -67,7 +75,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs index fb22fc12f..8dfb72e47 100644 --- a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; @@ -46,7 +47,14 @@ fn test_kclique_to_bcbs_complete_graph() { let bf = BruteForce::new(); let witness = bf.solve(target).unwrap().expect("K4 should contain K3"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); // Exactly 3 vertices should be selected assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 3); @@ -96,7 +104,14 @@ fn test_kclique_to_bcbs_k_equals_2() { .solve(target) .unwrap() .expect("graph has edges, so 2-clique exists"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 2); } @@ -116,7 +131,14 @@ fn test_kclique_to_bcbs_k_equals_1() { let bf = BruteForce::new(); let witness = bf.solve(target).unwrap().expect("should find a 1-clique"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 1); } diff --git a/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs b/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs index 63591a562..9d325cd65 100644 --- a/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs +++ b/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs @@ -4,6 +4,7 @@ use crate::rules::kclique_conjunctivebooleanquery::ReductionKCliqueToCBQ; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Or; @@ -73,7 +74,14 @@ fn test_solution_extraction() { .solve(reduction.target_problem()) .unwrap() .expect("CBQ should be satisfiable"); - let extracted = reduction.extract_solution(&cbq_witness).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), cbq_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); // All 3 vertices should be selected assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 3); @@ -97,6 +105,13 @@ fn test_trivial_k1() { .solve(reduction.target_problem()) .unwrap() .expect("k=1 should be feasible"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/kclique_ilp.rs b/src/unit_tests/rules/kclique_ilp.rs index 5e5b86e60..e93402409 100644 --- a/src/unit_tests/rules/kclique_ilp.rs +++ b/src/unit_tests/rules/kclique_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -34,7 +35,14 @@ fn test_kclique_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -48,7 +56,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); // Should select at least k=3 vertices (ILP may return a larger valid clique) assert!(extracted.iter().filter(|&&selected| selected).count() >= 3); diff --git a/src/unit_tests/rules/kclique_subgraphisomorphism.rs b/src/unit_tests/rules/kclique_subgraphisomorphism.rs index 7ba1863be..3b40799dd 100644 --- a/src/unit_tests/rules/kclique_subgraphisomorphism.rs +++ b/src/unit_tests/rules/kclique_subgraphisomorphism.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; @@ -49,7 +50,14 @@ fn test_kclique_to_subgraphisomorphism_complete_graph() { // Solve the target and extract back to source let bf = BruteForce::new(); let witness = bf.solve(target).unwrap().expect("K4 should contain K3"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); // Exactly 3 vertices should be selected assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 3); @@ -95,7 +103,14 @@ fn test_kclique_to_subgraphisomorphism_k_equals_1() { .solve(target) .unwrap() .expect("should find a single vertex"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 1); } @@ -117,7 +132,14 @@ fn test_kclique_to_subgraphisomorphism_k_equals_2() { .solve(target) .unwrap() .expect("graph has edges, so K2 exists"); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 2); } diff --git a/src/unit_tests/rules/kcoloring_bicliquecover.rs b/src/unit_tests/rules/kcoloring_bicliquecover.rs index 9d86c589b..fc05f974d 100644 --- a/src/unit_tests/rules/kcoloring_bicliquecover.rs +++ b/src/unit_tests/rules/kcoloring_bicliquecover.rs @@ -1,6 +1,9 @@ use super::*; use crate::models::graph::BicliqueCover; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Or; use crate::variant::KN; @@ -27,7 +30,14 @@ fn test_kcoloring_to_bicliquecover_closed_loop_trivial() { .solve(target) .unwrap() .expect("trivial target must be feasible"); - let coloring = reduction.extract_solution(&witness).unwrap(); + let coloring = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(coloring.len(), 1); assert!(source.is_valid_solution(&coloring)); // The source brute force agrees. @@ -102,7 +112,14 @@ fn test_kcoloring_to_bicliquecover_forward_witness_path_q2() { // Witness covers all edges with rank <= n + q. assert!(target.is_valid_cover(&witness)); // Extraction recovers a proper coloring. - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); } @@ -121,7 +138,14 @@ fn test_kcoloring_to_bicliquecover_forward_witness_cycle_q2() { let witness = forward_witness(&source, &coloring); assert!(target.is_valid_cover(&witness)); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); } @@ -188,7 +212,14 @@ fn test_kcoloring_to_bicliquecover_extract_solution_on_forward_witness() { let witness = forward_witness(&source, &coloring); assert!(target.is_valid_cover(&witness)); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); // K_3 forces 3 distinct colors. let mut seen = std::collections::BTreeSet::new(); @@ -247,7 +278,14 @@ fn test_kcoloring_to_bicliquecover_extract_trivial_layout() { assert!(cell(&witness, 0, 1)); assert!(cell(&witness, 2, 1)); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0]); } @@ -262,7 +300,6 @@ fn test_kcoloring_to_bicliquecover_native_loops_are_infeasible() { assert_eq!(target.graph().left_edges(), &[(0, 0)]); assert!(target.evaluate(&vec![]).unwrap().0.is_none()); assert!(BruteForce::new().solve(target).unwrap().is_none()); - assert!(reduction.extract_solution(&vec![]).is_err()); if q == 0 { assert!(source.evaluate(&vec![0; n]).is_err()); } else { @@ -286,7 +323,14 @@ fn test_kcoloring_to_bicliquecover_normalizes_all_color_counts() { assert!(source.evaluate(&coloring).unwrap().0); let witness = forward_witness(&source, &coloring); assert!(target.evaluate(&witness).unwrap().0.is_some()); - let decoded = reduction.extract_solution(&witness).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&decoded).unwrap().0); } } @@ -298,13 +342,17 @@ fn test_kcoloring_to_bicliquecover_rejects_invalid_certificates() { let source = KColoring::::with_k(SimpleGraph::new(2, vec![(0, 1)]), 2); let reduction = ReduceTo::::reduce_to(&source).unwrap(); let target = reduction.target_problem(); - for invalid in [ - vec![], - vec![vec![true; 7]; 4], - vec![vec![true; 8]; 4], - vec![vec![false; 8]; 4], - ] { - assert!(reduction.extract_solution(&invalid).is_err()); + for invalid in [vec![], vec![vec![true; 7]; 4]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&invalid), + Err(InvalidConfiguration(_)) + )); + } + for invalid in [vec![vec![true; 8]; 4], vec![vec![false; 8]; 4]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&invalid) + .unwrap() + .is_valid()); } let valid = forward_witness(&source, &[0, 1]); assert!(target.evaluate(&valid).unwrap().0.is_some()); @@ -312,7 +360,17 @@ fn test_kcoloring_to_bicliquecover_rejects_invalid_certificates() { reordered.reverse(); assert!( source - .evaluate(&reduction.extract_solution(&reordered).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), reordered.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); @@ -332,7 +390,15 @@ fn test_kcoloring_to_bicliquecover_repeated_reversed_edges() { let witness = forward_witness(&repeated, &[0, 1, 0]); assert!( repeated - .evaluate(&b.extract_solution(&witness).unwrap()) + .evaluate( + &b.recover_result( + &repeated, + SolveOutcome::optimal(b.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); @@ -355,9 +421,15 @@ fn test_kcoloring_to_bicliquecover_all_single_vertex_target_configs() { }) .collect(); let value = target.evaluate(&config).unwrap(); - let decoded = reduction.extract_solution(&config); - assert_eq!(decoded.is_ok(), value.0.is_some()); - if let Ok(coloring) = decoded { + if value.0.is_some() { + let coloring = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); feasible = true; assert!(source.evaluate(&coloring).unwrap().0); } diff --git a/src/unit_tests/rules/kcoloring_clustering.rs b/src/unit_tests/rules/kcoloring_clustering.rs index 36473f489..9fdb18205 100644 --- a/src/unit_tests/rules/kcoloring_clustering.rs +++ b/src/unit_tests/rules/kcoloring_clustering.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::variant::K3; @@ -42,7 +43,17 @@ fn test_kcoloring_to_clustering_extract_solution_identity() { let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); let config = vec![0, 1, 0]; - assert_eq!(reduction.extract_solution(&config).unwrap(), config); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + config + ); } #[test] @@ -65,7 +76,14 @@ fn test_kcoloring_to_clustering_empty_graph() { assert_eq!(target.num_clusters(), 3); assert_eq!(target.diameter_bound(), 0); assert_eq!( - reduction.extract_solution(&vec![2]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![2].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); assert_satisfaction_round_trip_from_satisfaction_target(&source, &reduction, "empty graph"); diff --git a/src/unit_tests/rules/kcoloring_partitionintocliques.rs b/src/unit_tests/rules/kcoloring_partitionintocliques.rs index c05b094c7..1282e2d35 100644 --- a/src/unit_tests/rules/kcoloring_partitionintocliques.rs +++ b/src/unit_tests/rules/kcoloring_partitionintocliques.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::Graph; use crate::variant::KN; @@ -42,7 +43,17 @@ fn test_kcoloring_to_partitionintocliques_extract_solution_identity() { .expect("reduction should succeed"); let config = vec![0, 1, 0]; - assert_eq!(reduction.extract_solution(&config).unwrap(), config); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + config + ); } #[test] diff --git a/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs index 46338d9c3..3298cd6da 100644 --- a/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -1,10 +1,12 @@ -use super::*; use crate::models::graph::KColoring; use crate::models::set::TwoDimensionalConsecutiveSets; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::traits::ReduceTo; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::variant::K3; @@ -108,7 +110,14 @@ fn test_kcoloring_to_tdcs_extract_solution_valid() { .unwrap(); for target_sol in &target_solutions { - let source_sol = reduction.extract_solution(target_sol).unwrap(); + let source_sol = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (target_sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source_sol.len(), 3); // Verify it is a valid coloring assert!( @@ -127,7 +136,14 @@ fn test_kcoloring_to_tdcs_empty_graph_has_a_target_witness() { assert_eq!(target.alphabet_size(), 1); assert_eq!(target.num_subsets(), 0); let witness = BruteForce::new().solve(target).unwrap().unwrap(); - let coloring = reduction.extract_solution(&witness).unwrap(); + let coloring = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(coloring.is_empty()); assert!(source.evaluate(&coloring).unwrap().0); } @@ -145,7 +161,10 @@ fn test_kcoloring_to_tdcs_native_loops_are_no() { for a in 0..3 { for b in 0..3 { for c in 0..3 { - assert!(reduction.extract_solution(&vec![a, b, c]).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![a, b, c]) + .unwrap() + .is_valid()); } } } @@ -156,9 +175,16 @@ fn test_kcoloring_to_tdcs_native_loops_are_no() { fn test_kcoloring_to_tdcs_rejects_noncertificates() { let source = KColoring::::new(SimpleGraph::new(2, vec![(0, 1)])); let reduction = ReduceTo::::reduce_to(&source).unwrap(); - for config in [vec![], vec![0, 1], vec![0, 1, 3], vec![0, 0, 0]] { - assert!(reduction.extract_solution(&config).is_err()); + for config in [vec![], vec![0, 1], vec![0, 1, 3]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&config), + Err(InvalidConfiguration(_)) + )); } + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0, 0, 0]) + .unwrap() + .is_valid()); } #[test] @@ -181,7 +207,14 @@ fn test_kcoloring_to_tdcs_many_groups_gaps_and_repeated_edges() { let source = KColoring::::new(SimpleGraph::new(n, edges)); let reduction = ReduceTo::::reduce_to(&source).unwrap(); assert!(reduction.target_problem().evaluate(&grouping).unwrap().0); - let coloring = reduction.extract_solution(&grouping).unwrap(); + let coloring = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), grouping.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(coloring, expected); assert!(source.evaluate(&coloring).unwrap().0); } @@ -214,9 +247,16 @@ fn test_kcoloring_to_tdcs_all_tiny_graphs_and_target_assignments() { }) .collect(); let feasible = target.evaluate(&grouping).unwrap().0; - let extracted = reduction.extract_solution(&grouping); - assert_eq!(extracted.is_ok(), feasible); - if let Ok(coloring) = extracted { + if feasible { + let coloring = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), grouping.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&coloring).unwrap().0); target_yes = true; } diff --git a/src/unit_tests/rules/knapsack_ilp.rs b/src/unit_tests/rules/knapsack_ilp.rs index bf72d4cff..f9ee3bcd1 100644 --- a/src/unit_tests/rules/knapsack_ilp.rs +++ b/src/unit_tests/rules/knapsack_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -14,7 +15,14 @@ fn test_knapsack_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &knapsack, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, true, false]); } @@ -29,7 +37,14 @@ fn test_knapsack_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &knapsack, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = knapsack.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -61,7 +76,14 @@ fn test_knapsack_to_ilp_zero_capacity() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("zero-capacity ILP should still be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &knapsack, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false]); } @@ -81,7 +103,14 @@ fn test_knapsack_to_ilp_empty_instance() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("empty Knapsack ILP should still be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &knapsack, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, Vec::::new()); } diff --git a/src/unit_tests/rules/knapsack_qubo.rs b/src/unit_tests/rules/knapsack_qubo.rs index 29ff7203c..54fa9d5ef 100644 --- a/src/unit_tests/rules/knapsack_qubo.rs +++ b/src/unit_tests/rules/knapsack_qubo.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; #[test] @@ -28,7 +29,14 @@ fn test_knapsack_to_qubo_single_item() { let solver = BruteForce::new(); let best_target = solver.find_all_witnesses(qubo).unwrap(); - let extracted = reduction.extract_solution(&best_target[0]).unwrap(); + let extracted = reduction + .recover_result( + &knapsack, + SolveOutcome::optimal(reduction.target_problem(), best_target[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true]); } @@ -42,7 +50,14 @@ fn test_knapsack_to_qubo_infeasible_rejected() { let best_target = solver.find_all_witnesses(qubo).unwrap(); for sol in &best_target { - let source_sol = reduction.extract_solution(sol).unwrap(); + let source_sol = reduction + .recover_result( + &knapsack, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let eval = knapsack.evaluate(&source_sol).unwrap(); assert!( eval.is_valid(), @@ -61,7 +76,14 @@ fn test_knapsack_to_qubo_empty() { let solver = BruteForce::new(); let best_target = solver.find_all_witnesses(qubo).unwrap(); - let extracted = reduction.extract_solution(&best_target[0]).unwrap(); + let extracted = reduction + .recover_result( + &knapsack, + SolveOutcome::optimal(reduction.target_problem(), best_target[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false]); } @@ -80,3 +102,21 @@ fn test_knapsack_to_qubo_canonical_example_spec() { assert_eq!(example.target.instance["num_vars"], 7); assert!(!example.solutions.is_empty()); } + +#[test] +fn test_knapsack_to_qubo_rejects_feasible_target_incumbent() { + let knapsack = Knapsack::new(vec![2, 3, 4, 5], vec![3, 4, 5, 7], 7); + let reduction = ReduceTo::>::reduce_to(&knapsack).expect("reduction should succeed"); + let optimum = BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .unwrap(); + + // A QUBO incumbent carries no optimality proof, so the slack penalty may be active. + crate::rules::test_helpers::assert_suboptimal_feasible_target_is_insufficient( + &knapsack, + &reduction, + vec![false; 7], + &optimum, + ); +} diff --git a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs index d12fc33e7..34b11858d 100644 --- a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs +++ b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs @@ -3,7 +3,9 @@ use crate::models::formula::{CNFClause, KSatisfiability}; use crate::models::graph::AcyclicPartition; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::Graph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::variant::K3; @@ -23,37 +25,62 @@ fn test_ksatisfiability_to_acyclicpartition_closed_loop() { count += 1; assert!( source - .evaluate(&reduction.extract_solution(&labels).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), labels.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); - let renamed = labels.iter().map(|&x| if x == 0 { 8 } else { 3 }).collect(); + let renamed: Vec<_> = labels.iter().map(|&x| if x == 0 { 8 } else { 3 }).collect(); assert!( source - .evaluate(&reduction.extract_solution(&renamed).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), renamed.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); } else { - assert!(reduction.extract_solution(&labels).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&labels) + .unwrap() + .is_valid()); } } assert_eq!(count, 3); } #[test] -fn test_acyclicpartition_extraction_rejects_invalid_targets() { +fn test_acyclicpartition_target_rejects_invalid_assignments() { let source = KSatisfiability::::new(1, vec![CNFClause::new(vec![1, 1, 1])]); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - for labels in [ - vec![], - vec![0; 8], - vec![0; 10], - vec![9; 9], - vec![0; 9], - vec![2, 1, 1, 0, 0, 1, 1, 0, 1], - ] { - assert!(reduction.extract_solution(&labels).is_err()); + for labels in [vec![], vec![0; 8], vec![0; 10], vec![9; 9]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&labels), + Err(InvalidConfiguration(_)) + )); + } + for labels in [vec![0; 9], vec![2, 1, 1, 0, 0, 1, 1, 0, 1]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&labels) + .unwrap() + .is_valid()); } } @@ -69,7 +96,17 @@ fn test_acyclicpartition_native_empty_and_short_clauses() { for labels in witnesses { assert!( source - .evaluate(&reduction.extract_solution(&labels).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), labels.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); @@ -134,7 +171,17 @@ fn test_ksatisfiability_to_acyclicpartition_multi_variable_closed_loop() { assert!(reduction.target_problem().evaluate(&labels).unwrap().0); assert!( source - .evaluate(&reduction.extract_solution(&labels).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), labels.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); diff --git a/src/unit_tests/rules/ksatisfiability_bicliquecover.rs b/src/unit_tests/rules/ksatisfiability_bicliquecover.rs index 9c6b4b2c9..fb9ca9a43 100644 --- a/src/unit_tests/rules/ksatisfiability_bicliquecover.rs +++ b/src/unit_tests/rules/ksatisfiability_bicliquecover.rs @@ -19,6 +19,9 @@ use super::*; use crate::models::formula::CNFClause; use crate::models::graph::BicliqueCover; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::variant::K3; @@ -120,13 +123,18 @@ fn test_ksatisfiability_to_bicliquecover_rejects_invalid_covers() { sparse[0][0] = true; assert!(target.evaluate(&sparse).unwrap().0.is_none()); for invalid in [ - sparse, vec![], - vec![vec![true; target.num_vertices()]; target.k()], vec![vec![false; target.num_vertices() - 1]; target.k()], ] { - assert!(reduction.extract_solution(&invalid).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&invalid), + Err(InvalidConfiguration(_)) + )); } + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![vec![true; target.num_vertices()]; target.k()]) + .unwrap() + .is_valid()); } #[test] @@ -138,7 +146,14 @@ fn test_ksatisfiability_to_bicliquecover_extracts_every_row_rotation() { assert!(cost.0.is_some()); for _ in 0..cover.len() { assert_eq!(reduction.target_problem().evaluate(&cover).unwrap(), cost); - let assignment = reduction.extract_solution(&cover).unwrap(); + let assignment = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), cover.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(assignment, vec![true]); assert!(source.evaluate(&assignment).unwrap().0); cover.rotate_left(1); @@ -166,7 +181,14 @@ fn test_ksatisfiability_to_bicliquecover_closed_loop_smallest() { "forward witness must be a valid biclique cover" ); - let extracted = reduction.extract_solution(&witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 1); assert!( extracted[0], @@ -199,7 +221,10 @@ fn test_ksatisfiability_to_bicliquecover_construct_two_vars_no_panic() { // m_normalized = 2 source + 2 * 2 = 6. // k_f = 4*2 + 2*ceil(log2 6) + 6 = 8 + 6 + 6 = 20. // rank = 20 + 4 + 2 = 26. - assert_eq!(reduction.normalized_n, 4); + assert_eq!( + normalize(&source, &reduction.source_variables).unwrap().0, + 4 + ); assert_eq!(target.k(), 26); } @@ -210,9 +235,19 @@ fn test_ksatisfiability_to_bicliquecover_sparse_variable_inverse() { let source = KSatisfiability::::new_allow_less(7, vec![CNFClause::new(vec![7])]); let reduction = ReduceTo::::reduce_to(&source).unwrap(); assert_eq!(reduction.source_variables, vec![6]); - assert_eq!(reduction.normalized_n, 2); + assert_eq!( + normalize(&source, &reduction.source_variables).unwrap().0, + 2 + ); let cover = super::forward_witness_single_variable_single_clause(&source); - let assignment = reduction.extract_solution(&cover).unwrap(); + let assignment = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), cover.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( assignment, vec![false, false, false, false, false, false, true] @@ -242,7 +277,17 @@ fn test_ksatisfiability_to_bicliquecover_empty_conjunction_and_clause() { let yes = KSatisfiability::::new(n, vec![]); let reduction = ReduceTo::::reduce_to(&yes).unwrap(); assert_eq!(reduction.target_problem().num_vertices(), 0); - assert_eq!(reduction.extract_solution(&vec![]).unwrap(), vec![false; n]); + assert_eq!( + reduction + .recover_result( + &yes, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![false; n] + ); assert!(yes.evaluate(&vec![false; n]).unwrap().0); let no = KSatisfiability::::new_allow_less(n, vec![CNFClause::new(vec![])]); let reduction = ReduceTo::::reduce_to(&no).unwrap(); @@ -252,7 +297,6 @@ fn test_ksatisfiability_to_bicliquecover_empty_conjunction_and_clause() { .unwrap() .0 .is_none()); - assert!(reduction.extract_solution(&vec![]).is_err()); assert!(!no.evaluate(&vec![false; n]).unwrap().0); } } diff --git a/src/unit_tests/rules/ksatisfiability_cyclicordering.rs b/src/unit_tests/rules/ksatisfiability_cyclicordering.rs index 75c1ab6f7..0e9e44bc4 100644 --- a/src/unit_tests/rules/ksatisfiability_cyclicordering.rs +++ b/src/unit_tests/rules/ksatisfiability_cyclicordering.rs @@ -1,6 +1,9 @@ use super::*; use crate::models::formula::CNFClause; use crate::models::misc::CyclicOrdering; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::variant::K3; // Construct the paper's seven local orders and merge their auxiliary @@ -95,7 +98,14 @@ fn test_ksatisfiability_to_cyclicordering_single_clause_reference_vector() { ); let target_solution = forward_witness(&source, &[true, true, true]); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, true]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -141,7 +151,14 @@ fn test_ksatisfiability_to_cyclicordering_extract_solution_from_reference_witnes .0 ); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, true, true] ); } @@ -154,7 +171,17 @@ fn test_ksatisfiability_to_cyclicordering_clause_gadget_truth_patterns() { let assignment: Vec<_> = (0..3).map(|bit| mask & (1 << bit) != 0).collect(); let config = forward_witness(&source, &assignment); assert!(reduction.target_problem().evaluate(&config).unwrap().0); - assert_eq!(reduction.extract_solution(&config).unwrap(), assignment); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + assignment + ); } } @@ -199,7 +226,14 @@ fn test_ksatisfiability_to_cyclicordering_closed_loop() { "target solution must evaluate as satisfying" ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( source.evaluate(&extracted).unwrap().0, "extracted source config must satisfy the source" @@ -235,7 +269,14 @@ fn repeated_short_and_unsorted_clauses_preserve_all_source_assignments() { if source.evaluate(&assignment).unwrap().0 { let config = forward_witness(&source, &assignment); assert!(reduction.target_problem().evaluate(&config).unwrap().0); - let extracted = reduction.extract_solution(&config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); for (original, used) in (0..6).map(|i| (i, normalized.source_variables.contains(&i))) @@ -254,7 +295,14 @@ fn empty_formula_and_empty_clause_have_opposite_fixed_targets() { let reduction = ReduceTo::::reduce_to(&source).unwrap(); assert_eq!(reduction.target_problem().num_elements(), 1); assert_eq!( - reduction.extract_solution(&vec![0]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![0].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false; num_vars] ); let source = @@ -271,7 +319,6 @@ fn empty_formula_and_empty_clause_have_opposite_fixed_targets() { vec![2, 1, 0], ] { assert!(!reduction.target_problem().evaluate(&config).unwrap().0); - assert!(reduction.extract_solution(&config).is_err()); } } } @@ -283,17 +330,33 @@ fn reject_invalid_orderings_and_accept_every_rotation() { let config = forward_witness(&source, &[true, false, true]); let n = config.len(); for shift in 0..n { - let rotated = config + let rotated: Vec<_> = config .iter() .map(|position| (position + shift) % n) .collect(); assert_eq!( - reduction.extract_solution(&rotated).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), rotated.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, true] ); } - for config in [vec![], vec![0; n], vec![n; n], (0..n).collect()] { - assert!(reduction.extract_solution(&config).is_err()); + for config in [vec![], vec![n; n]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&config), + Err(InvalidConfiguration(_)) + )); + } + for config in [vec![0; n], (0..n).collect()] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } } diff --git a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs index 387b9de80..0635eed9d 100644 --- a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -4,6 +4,7 @@ use crate::models::formula::CNFClause; use crate::models::graph::MinimumVertexCover; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::variant::K3; @@ -17,7 +18,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 +43,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 +54,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 +72,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, @@ -82,7 +83,28 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_extract_solution() { crate::types::Or(true) ); assert_eq!( - reduction.extract_solution(&cover).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), cover.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, false, true] ); } + +#[test] +fn test_ksatisfiability_to_decisionminimumvertexcover_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_satisfaction_target( + &ksat, + &reduction, + "3SAT all negated -> MVC", + ); +} diff --git a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs index 6ad979fc3..8d188e87e 100644 --- a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -7,6 +7,7 @@ use crate::models::formula::CNFClause; use crate::models::graph::DirectedTwoCommodityIntegralFlow; use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::variant::K3; @@ -40,9 +41,24 @@ fn solve_target_via_ilp( problem: &crate::models::graph::DirectedTwoCommodityIntegralFlow, ) -> Option> { let reduction = ReduceTo::>::reduce_to(problem).expect("reduction should succeed"); - let ilp_solution = ILPSolver::new().solve(reduction.target_problem()).ok()?; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); - problem.evaluate(&extracted).unwrap().0.then_some(extracted) + let ilp_solution = match ILPSolver::new().solve(reduction.target_problem()) { + Ok(solution) => solution, + Err(crate::solvers::ILPSolveError::Infeasible) => return None, + Err(error) => panic!("ILP execution failed: {error}"), + }; + let extracted = reduction + .recover_result( + problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert!( + problem.evaluate(&extracted).unwrap().0, + "decoded flow must be feasible" + ); + Some(extracted) } #[test] @@ -91,7 +107,17 @@ fn test_ksatisfiability_to_directedtwocommodityintegralflow_extract_solution_fro let assignment = vec![true, true, false]; let flow = reduction.encode_assignment(&assignment); assert!(reduction.target_problem().evaluate(&flow).unwrap().0); - assert_eq!(reduction.extract_solution(&flow).unwrap(), assignment); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), flow.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + assignment + ); } #[test] @@ -112,7 +138,14 @@ fn test_ksatisfiability_to_directedtwocommodityintegralflow_closed_loop() { .0 ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } diff --git a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs index ef7624f3d..61e9d51fa 100644 --- a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs @@ -1,7 +1,10 @@ use super::*; use crate::models::algebraic::ILP; use crate::models::formula::CNFClause; +use crate::rules::ReductionResult; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Or; use std::collections::BTreeSet; @@ -130,7 +133,14 @@ fn test_ksatisfiability_to_feasible_register_assignment_extract_solution() { let realization = forward_witness(&source, &[true, false]); assert!(reduction.target_problem().evaluate(&realization).unwrap().0); - let extracted = reduction.extract_solution(&realization).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), realization.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false]); } @@ -146,13 +156,27 @@ fn test_ksatisfiability_to_feasible_register_assignment_closed_loop_via_ilp() { let ilp_solution = ILPSolver::new() .solve(fra_to_ilp.target_problem()) .expect("satisfiable FRA gadget should reduce to a feasible ILP"); - let fra_solution = fra_to_ilp.extract_solution(&ilp_solution).unwrap(); + let fra_solution = fra_to_ilp + .recover_result( + reduction.target_problem(), + SolveOutcome::optimal(fra_to_ilp.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( reduction.target_problem().evaluate(&fra_solution).unwrap(), Or(true) ); - let extracted = reduction.extract_solution(&fra_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), fra_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); } @@ -170,8 +194,9 @@ fn test_ksatisfiability_to_feasible_register_assignment_unsatisfiable_instance() let fra_to_ilp = ReduceTo::>::reduce_to(reduction.target_problem()) .expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(fra_to_ilp.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(fra_to_ilp.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "an unsatisfiable source formula should yield an infeasible FRA instance" ); } @@ -193,13 +218,23 @@ fn native_empty_clause_is_infeasible_and_empty_conjunction_is_feasible() { vec![2, 1, 0], ] { assert!(!reduction.target_problem().evaluate(&config).unwrap().0); - assert!(reduction.extract_solution(&config).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } let source = KSatisfiability::::new(num_vars, vec![]); let reduction = ReduceTo::::reduce_to(&source).unwrap(); assert_eq!(reduction.target_problem().num_vertices(), 0); assert_eq!( - reduction.extract_solution(&vec![]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false; num_vars] ); } @@ -236,7 +271,14 @@ fn short_repeated_and_mixed_clauses_have_complete_forward_witnesses() { if source.evaluate(&values).unwrap().0 { let config = forward_witness(&source, &values); assert!(reduction.target_problem().evaluate(&config).unwrap().0); - let extracted = reduction.extract_solution(&config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); for original in 0..6 { assert_eq!( @@ -281,8 +323,17 @@ fn invalid_realizations_are_rejected() { let source = issue_example(); let reduction = ReduceTo::::reduce_to(&source).unwrap(); let n = reduction.target_problem().num_vertices(); - for config in [vec![], vec![n; n], vec![0; n], (0..n).collect()] { - assert!(reduction.extract_solution(&config).is_err()); + for config in [vec![], vec![n; n]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&config), + Err(InvalidConfiguration(_)) + )); + } + for config in [vec![0; n], (0..n).collect()] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } } diff --git a/src/unit_tests/rules/ksatisfiability_kclique.rs b/src/unit_tests/rules/ksatisfiability_kclique.rs index 0e0bf789b..7c544c187 100644 --- a/src/unit_tests/rules/ksatisfiability_kclique.rs +++ b/src/unit_tests/rules/ksatisfiability_kclique.rs @@ -1,6 +1,9 @@ use super::*; use crate::models::formula::CNFClause; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; #[test] @@ -22,14 +25,31 @@ fn test_ksatisfiability_to_kclique_closed_loop() { assert!(witness[6]); assert!( source - .evaluate(&reduction.extract_solution(&witness).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); } let witness = vec![false, false, true, true, false, false, true]; assert_eq!( - reduction.extract_solution(&witness).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, false, true] ); let no = KSatisfiability::::new( @@ -60,7 +80,14 @@ fn test_kclique_empty_formulas_and_short_clauses() { (1, 1) ); assert_eq!( - reduction.extract_solution(&vec![true]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![true].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false; n] ); } @@ -84,7 +111,17 @@ fn test_kclique_empty_formulas_and_short_clauses() { for witness in solutions { assert!( source - .evaluate(&reduction.extract_solution(&witness).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); @@ -142,12 +179,29 @@ fn test_kclique_all_two_clause_formulas_and_target_selections() { target_yes = true; assert!( source - .evaluate(&reduction.extract_solution(&witness).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + witness.clone() + ) + .unwrap() + ) + .map(|result| result.into_solution().expect( + "qualifying target result must recover a source solution" + )) + .unwrap() + ) .unwrap() .0 ); } else { - assert!(reduction.extract_solution(&witness).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() + .is_valid()); } } assert_eq!(source_yes, target_yes); @@ -162,14 +216,21 @@ fn test_kclique_rejects_malformed_or_non_clique_selections() { vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + for bad in [vec![], vec![true; 6]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&bad), + Err(InvalidConfiguration(_)) + )); + } for bad in [ - vec![], - vec![true; 6], vec![false; 5], vec![true, true, false, false, true], vec![true, false, true, false, true], ] { - assert!(reduction.extract_solution(&bad).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&bad) + .unwrap() + .is_valid()); } } diff --git a/src/unit_tests/rules/ksatisfiability_kernel.rs b/src/unit_tests/rules/ksatisfiability_kernel.rs index 200045a36..3f5f75f1c 100644 --- a/src/unit_tests/rules/ksatisfiability_kernel.rs +++ b/src/unit_tests/rules/ksatisfiability_kernel.rs @@ -3,6 +3,9 @@ use crate::models::graph::Kernel; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::traits::Problem; use crate::variant::K3; #[test] @@ -72,8 +75,17 @@ fn test_ksatisfiability_to_kernel_extract_solution_reads_variable_gadgets() { assert_eq!( reduction - .extract_solution(&vec![true, false, false, true, false, false, false]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, false, false, true, false, false, false].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false] ); } @@ -119,7 +131,14 @@ fn test_ksatisfiability_to_kernel_native_clause_domain() { .map(|v| mask & (1 << v) != 0) .collect(); if target.evaluate(&config).unwrap().0 { - let decoded = reduction.extract_solution(&config).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&decoded).unwrap().0); witnessed[usize::from(decoded[0])] = true; } @@ -152,7 +171,14 @@ fn test_ksatisfiability_to_kernel_sparse_inverse() { .collect(); if target.evaluate(&config).unwrap().0 { found = true; - let decoded = reduction.extract_solution(&config).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(decoded.len(), 6); assert!(source.evaluate(&decoded).unwrap().0); for (variable, value) in decoded.iter().enumerate() { @@ -173,8 +199,17 @@ fn test_ksatisfiability_to_kernel_sparse_inverse() { fn test_ksatisfiability_to_kernel_rejects_non_kernel() { let source = KSatisfiability::::new(1, vec![CNFClause::new(vec![1, 1, 1])]); let reduction = ReduceTo::::reduce_to(&source).unwrap(); - for config in [vec![], vec![false; 5], vec![true; 5], vec![false; 6]] { - assert!(reduction.extract_solution(&config).is_err()); + for config in [vec![], vec![false; 6]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&config), + Err(InvalidConfiguration(_)) + )); + } + for config in [vec![false; 5], vec![true; 5]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } } 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_monochromatictriangle.rs b/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs index eaa8484bd..d6f10d97c 100644 --- a/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs @@ -3,8 +3,10 @@ use crate::models::algebraic::{LinearConstraint, ILP}; use crate::models::formula::{CNFClause, KSatisfiability}; use crate::models::graph::MonochromaticTriangle; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::solvers::{ILPSolveError, ILPSolver}; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::variant::K3; @@ -70,10 +72,40 @@ fn test_ksatisfiability_to_monochromatic_triangle_all_source_projections() { match ILPSolver::new().solve(&fixed) { Ok(solution) => { assert!(source.evaluate(&assignment).unwrap().0); - let coloring = to_ilp.extract_solution(&solution).unwrap(); - assert_eq!(reduction.extract_solution(&coloring).unwrap(), assignment); - let swapped = coloring.iter().map(|value| !value).collect(); - assert_eq!(reduction.extract_solution(&swapped).unwrap(), assignment); + let coloring = to_ilp + .recover_result( + reduction.target_problem(), + SolveOutcome::optimal(to_ilp.target_problem(), solution.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), coloring.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + assignment + ); + let swapped: Vec<_> = coloring.iter().map(|value| !value).collect(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), swapped.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + assignment + ); } Err(ILPSolveError::Infeasible) => assert!(!source.evaluate(&assignment).unwrap().0), Err(error) => panic!("unexpected solver error: {error}"), @@ -95,11 +127,24 @@ fn test_ksatisfiability_to_monochromatic_triangle_closed_loop() { let coloring = ILPSolver::new().solve(reduction.target_problem()).unwrap(); assert!( source - .evaluate(&reduction.extract_solution(&coloring).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), coloring.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); - assert!(reduction.extract_solution(&vec![]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); } #[test] @@ -154,7 +199,21 @@ fn test_ksatisfiability_to_monochromatic_triangle_short_and_repeated_literals() assert!(feasible); assert!( source - .evaluate(&reduction.extract_solution(&coloring).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + coloring.clone() + ) + .unwrap() + ) + .map(|result| result.into_solution().expect( + "qualifying target result must recover a source solution" + )) + .unwrap() + ) .unwrap() .0 ); @@ -171,7 +230,14 @@ fn test_ksatisfiability_to_monochromatic_triangle_zero_variables() { let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let coloring = ILPSolver::new().solve(reduction.target_problem()).unwrap(); assert_eq!( - reduction.extract_solution(&coloring).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), coloring.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); } diff --git a/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs b/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs index 5792a4456..558105ed6 100644 --- a/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs +++ b/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs @@ -1,7 +1,10 @@ use super::*; use crate::models::formula::{CNFClause, OneInThreeSatisfiability}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::variant::K3; @@ -104,7 +107,14 @@ fn test_ksatisfiability_to_oneinthreesatisfiability_extract_solution() { ]; assert!(target.evaluate(&target_solution).unwrap().0); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false, true]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -143,7 +153,14 @@ fn test_oneinthree_native_empty_short_and_sparse_clauses() { .collect(); if target.evaluate(&config).unwrap().0 { target_exists = true; - let extracted = reduction.extract_solution(&config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), num_vars); assert!(source.evaluate(&extracted).unwrap().0); for (v, &value) in extracted.iter().enumerate() { @@ -169,7 +186,14 @@ fn test_oneinthree_all_literal_truth_patterns_and_auxiliary_assignments() { .collect(); if target.evaluate(&config).unwrap().0 { extensions[mask & 7] += 1; - let extracted = reduction.extract_solution(&config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } } @@ -180,8 +204,17 @@ fn test_oneinthree_all_literal_truth_patterns_and_auxiliary_assignments() { fn test_oneinthree_rejects_infeasible_target_assignments() { let source = KSatisfiability::::new(1, vec![CNFClause::new(vec![1; 3])]); let reduction = ReduceTo::::reduce_to(&source).unwrap(); - for config in [vec![], vec![false; 9], vec![true; 9], vec![false; 10]] { - assert!(reduction.extract_solution(&config).is_err()); + for config in [vec![], vec![false; 10]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&config), + Err(InvalidConfiguration(_)) + )); + } + for config in [vec![false; 9], vec![true; 9]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } } diff --git a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs index 375509297..89b53f601 100644 --- a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::ILP; use crate::models::formula::CNFClause; use crate::models::misc::{PrecedenceConstrainedScheduling, PreemptiveScheduling}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; use crate::variant::K3; @@ -32,8 +33,19 @@ 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 slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution).unwrap(); + 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!("ILP execution failed: {error}"), + }; + let slot_assignment = pcs_to_ilp + .recover_result( + &pcs, + SolveOutcome::optimal(pcs_to_ilp.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let mut config = vec![vec![false; target.d_max()]; target.num_tasks()]; for (task, &slot) in slot_assignment.iter().enumerate() { @@ -50,10 +62,10 @@ 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); + assert_eq!(target.num_precedences(), 69); assert!(target.lengths().iter().all(|&length| length == 1)); } @@ -71,7 +83,14 @@ fn test_ksatisfiability_to_preemptivescheduling_extract_solution_from_constructe Min(Some(4)) ); - let extracted = reduction.extract_solution(&schedule).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -92,7 +111,14 @@ fn test_ksatisfiability_to_preemptivescheduling_multi_variable_round_trip() { construct_schedule_from_assignment(result.target_problem(), &[true, true, false], &source) .expect("satisfying assignment should yield a witness schedule"); - let extracted = result.extract_solution(&schedule).unwrap(); + let extracted = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), schedule.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, false]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -115,7 +141,14 @@ fn test_ksatisfiability_to_preemptivescheduling_closed_loop() { Min(Some(i64::try_from(reduction.threshold()).unwrap())) ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -132,3 +165,150 @@ fn test_ksatisfiability_to_preemptivescheduling_unsatisfiable_threshold_gap() { "unsatisfiable instance should not admit a schedule by the threshold" ); } + +#[test] +fn filler_clock_excludes_every_unsatisfying_slot_zero_assignment() { + use crate::models::algebraic::{LinearConstraint, ObjectiveSense}; + let source = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, 3])]); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + let construction = build_ullman_construction(&source); + assert!(construction + .filler_jobs_by_slot + .iter() + .all(|layer| !layer.is_empty())); + assert_eq!( + target.num_tasks(), + target.num_processors() * reduction.threshold() + ); + let pcs = PrecedenceConstrainedScheduling::new( + target.num_tasks(), + target.num_processors(), + reduction.threshold() as i64, + target.precedences().to_vec(), + ); + let pcs_to_ilp = ReduceTo::>::reduce_to(&pcs).unwrap(); + let base = pcs_to_ilp.target_problem(); + // Fix the extracted assignment, not one particular schedule. This searches + // ALL threshold schedules with that assignment, including noncanonical ones. + for bits in 0..8 { + let assignment: Vec<_> = (0..3).map(|i| bits & (1 << i) != 0).collect(); + let mut constraints = base.constraints().to_vec(); + for (&job, &value) in reduction.positive_start_jobs.iter().zip(&assignment) { + constraints.push(LinearConstraint::eq( + vec![(job * reduction.threshold(), 1)], + i64::from(value), + )); + } + let constrained = ILP::::new( + base.num_vars(), + constraints, + vec![], + ObjectiveSense::Minimize, + ) + .unwrap(); + match ILPSolver::new().solve(&constrained) { + Ok(witness) => { + assert!(source.evaluate(&assignment).unwrap().0); + let slots = pcs_to_ilp + .recover_result(&pcs, SolveOutcome::optimal(base, witness).unwrap()) + .unwrap() + .into_solution() + .unwrap(); + let mut schedule = vec![vec![false; target.d_max()]; target.num_tasks()]; + for (job, slot) in slots.into_iter().enumerate() { + schedule[job][slot] = true; + } + for (slot, layer) in construction.filler_jobs_by_slot.iter().enumerate() { + assert!(layer.iter().all(|&job| schedule[job][slot])); + } + let recovered = reduction + .recover_result( + &source, + SolveOutcome::optimal(target, schedule.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .unwrap(); + assert_eq!(recovered, assignment); + assert!(reduction + .recover_result(&source, SolveOutcome::feasible(target, schedule).unwrap()) + .unwrap() + .into_solution() + .is_some()); + } + Err(crate::solvers::ILPSolveError::Infeasible) => { + assert!(!source.evaluate(&assignment).unwrap().0) + } + Err(error) => panic!("ILP execution failed: {error}"), + } + } +} + +#[test] +fn short_and_empty_clauses_preserve_truth_and_recovery_status() { + for (n, clauses) in [ + (0, vec![]), + (2, vec![]), + (0, vec![vec![]]), + (2, vec![vec![]]), + (1, vec![vec![1]]), + (1, vec![vec![1], vec![-1]]), + (2, vec![vec![1, -2]]), + ] { + let source = KSatisfiability::::new_allow_less( + n, + clauses.into_iter().map(CNFClause::new).collect(), + ); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); + let expected = crate::solvers::BruteForce::new().solve(&source).unwrap(); + let schedule = if reduction.threshold() == 0 { + None + } else { + solve_threshold_schedule_via_ilp(reduction.target_problem(), reduction.threshold()) + }; + assert_eq!(schedule.is_some(), expected.is_some()); + if let Some(schedule) = schedule { + assert!(reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule).unwrap() + ) + .unwrap() + .into_solution() + .is_some()); + let witness = construct_schedule_from_assignment( + reduction.target_problem(), + &expected.unwrap(), + &source, + ) + .unwrap(); + assert!(reduction + .target_problem() + .evaluate(&witness) + .unwrap() + .0 + .is_some()); + } + if reduction.threshold() == 0 { + let schedule = vec![vec![true]]; + assert!(matches!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule.clone()) + .unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + )); + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(reduction.target_problem(), schedule).unwrap() + ), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); + } + } +} diff --git a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs index 098a88b18..344b01e0b 100644 --- a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::formula::CNFClause; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; use num_traits::ToPrimitive; @@ -25,7 +26,17 @@ fn test_ksatisfiability_to_quadraticcongruences_closed_loop() { ); assert_eq!( source - .evaluate(&reduction.extract_solution(&solution).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap(), Or(true) ); @@ -58,20 +69,30 @@ fn test_native_clauses_and_arbitrary_crt_signs() { .collect(); let witness = witness_value_from_alphas(&signs, &construction.thetas); let valid = reduction.target_problem().evaluate(&witness).unwrap().0; - let extracted = reduction.extract_solution(&witness); - if valid { - let extracted = extracted.unwrap(); - assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); - for (i, &original) in construction.active_to_source.iter().enumerate() { - assert_eq!( - extracted[original], - signs[0] != signs[2 * construction.clauses.len() + i + 1] - ); - } - recovered.insert(extracted); - } else { - assert!(extracted.is_err()); + let target = SolveOutcome::optimal(reduction.target_problem(), witness.clone()); + if !valid { + assert_eq!( + target, + Err(crate::traits::EvaluationError::ConstraintViolation) + ); + continue; + } + let extracted = reduction + .recover_result(&source, target.unwrap()) + .map(|result| { + result + .into_solution() + .expect("qualifying target result must recover a source solution") + }); + let extracted = extracted.unwrap(); + assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); + for (i, &original) in construction.active_to_source.iter().enumerate() { + assert_eq!( + extracted[original], + signs[0] != signs[2 * construction.clauses.len() + i + 1] + ); } + recovered.insert(extracted); } // Enumerate only appearing variables; unused coordinates are free. let mut expected = BTreeSet::new(); @@ -87,7 +108,18 @@ fn test_native_clauses_and_arbitrary_crt_signs() { reduction.target_problem().evaluate(&witness).unwrap(), Or(true) ); - assert_eq!(reduction.extract_solution(&witness).unwrap(), assignment); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + assignment + ); expected.insert(assignment); } else { assert!(build_alphas(&construction, &assignment).is_none()); @@ -157,7 +189,13 @@ fn test_normalization_preserves_free_variables_and_formula() { let assignment = [true, true, true, true, false]; let witness = witness_config_for_assignment(&redundant, &assignment).unwrap(); assert_eq!( - second.extract_solution(&witness).unwrap(), + second + .recover_result( + &redundant, + SolveOutcome::optimal(second.target_problem(), witness).unwrap() + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(), vec![false, true, false, false, false] ); assert!(witness_config_for_assignment(&redundant, &[]).is_none()); @@ -177,7 +215,6 @@ fn test_rejects_infeasible_and_out_of_bound_integers() { reduction.target.c() + 1u32, ] { assert_eq!(reduction.target.evaluate(&witness).unwrap(), Or(false)); - assert!(reduction.extract_solution(&witness).is_err()); } } diff --git a/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs index 4b9c73f43..b3b150797 100644 --- a/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::QuadraticDiophantineEquations; use crate::models::formula::CNFClause; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; use crate::variant::K3; @@ -30,7 +31,14 @@ fn test_ksatisfiability_to_quadraticdiophantineequations_closed_loop() { Or(true) ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); } @@ -45,7 +53,14 @@ fn test_ksatisfiability_to_quadraticdiophantineequations_canonical_witness() { assert_eq!(target.evaluate(&target_config).unwrap(), Or(true)); - let extracted = reduction.extract_solution(&target_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, false]); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/ksatisfiability_qubo.rs b/src/unit_tests/rules/ksatisfiability_qubo.rs index 22aad0944..d95f061f5 100644 --- a/src/unit_tests/rules/ksatisfiability_qubo.rs +++ b/src/unit_tests/rules/ksatisfiability_qubo.rs @@ -1,8 +1,13 @@ use super::*; +use crate::models::decision::Decision; use crate::models::formula::CNFClause; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; use crate::variant::{K2, K3}; #[test] @@ -18,7 +23,8 @@ fn test_ksatisfiability_to_qubo_closed_loop() { CNFClause::new(vec![-2, -3]), // ¬x2 ∨ ¬x3 ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); @@ -26,7 +32,14 @@ fn test_ksatisfiability_to_qubo_closed_loop() { // Verify all solutions satisfy all clauses for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ksat.evaluate(&extracted).unwrap()); } } @@ -35,14 +48,22 @@ fn test_ksatisfiability_to_qubo_closed_loop() { fn test_ksatisfiability_to_qubo_simple() { // 2 vars, 1 clause: (x1 ∨ x2) → 3 satisfying assignments let ksat = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, 2])]); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ksat.evaluate(&extracted).unwrap()); } } @@ -59,14 +80,15 @@ fn test_ksatisfiability_to_qubo_contradiction() { CNFClause::new(vec![-1, -1]), // ¬x1 ∨ ¬x1 = ¬x1 ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); - // Both x=0 and x=1 satisfy exactly 1 clause - assert_eq!(qubo_solutions.len(), 2); + // Neither assignment meets the satisfaction threshold. + assert!(qubo_solutions.is_empty()); } #[test] @@ -80,14 +102,22 @@ fn test_ksatisfiability_to_qubo_reversed_vars() { CNFClause::new(vec![1, 2]), ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ksat.evaluate(&extracted).unwrap()); } } @@ -98,11 +128,12 @@ fn test_ksatisfiability_to_qubo_structure() { 3, vec![CNFClause::new(vec![1, 2]), CNFClause::new(vec![-1, 3])], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); // QUBO should have at least the original variables - assert!(qubo.num_variables() >= ksat.num_vars()); + assert!(qubo.num_variables().unwrap() >= ksat.num_vars()); } #[test] @@ -120,18 +151,26 @@ fn test_k3satisfiability_to_qubo_closed_loop() { CNFClause::new(vec![3, -4, -5]), // x3 ∨ ¬x4 ∨ ¬x5 ], ); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); // QUBO should have 5 + 7 = 12 variables - assert_eq!(qubo.num_variables(), 12); + assert_eq!(qubo.num_variables().unwrap(), 12); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); // Verify all extracted solutions maximize satisfied clauses for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 5); let satisfied = ksat.count_satisfied(&extracted).unwrap(); assert_eq!(satisfied, 7, "Expected all 7 clauses satisfied"); @@ -142,18 +181,26 @@ fn test_k3satisfiability_to_qubo_closed_loop() { fn test_k3satisfiability_to_qubo_single_clause() { // Single 3-SAT clause: (x1 ∨ x2 ∨ x3) — 7 satisfying assignments let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, 3])]); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); // 3 vars + 1 auxiliary = 4 total - assert_eq!(qubo.num_variables(), 4); + assert_eq!(qubo.num_variables().unwrap(), 4); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); // All solutions should satisfy the single clause for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted).unwrap()); } @@ -165,14 +212,22 @@ fn test_k3satisfiability_to_qubo_single_clause() { fn test_k3satisfiability_to_qubo_all_negated() { // All negated: (¬x1 ∨ ¬x2 ∨ ¬x3) — 7 satisfying assignments let ksat = KSatisfiability::::new(3, vec![CNFClause::new(vec![-1, -2, -3])]); - let reduction = ReduceTo::>::reduce_to(&ksat).expect("reduction should succeed"); + let reduction = + ReduceTo::>>::reduce_to(&ksat).expect("reduction should succeed"); let qubo = reduction.target_problem(); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ksat.evaluate(&extracted).unwrap()); } // 7 out of 8 assignments satisfy (¬x1 ∨ ¬x2 ∨ ¬x3) @@ -181,8 +236,6 @@ fn test_k3satisfiability_to_qubo_all_negated() { #[test] fn test_sat_qubo_all_short_clauses_and_raw_targets() { - use crate::rules::AggregateReductionResult; - use crate::types::{Min, Or}; macro_rules! verify { ($k:ty, $width:expr) => {{ let mut clauses = vec![vec![]]; @@ -201,14 +254,14 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { 1, vec![CNFClause::new(a.clone()), CNFClause::new(b.clone())], ); - let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let reduction = ReduceTo::>>::reduce_to(&source).unwrap(); let target = ReductionResult::target_problem(&reduction); let mut minimum = i64::MAX; - for mask in 0..(1 << target.num_vars()) { - let witness: Vec<_> = (0..target.num_vars()) + for mask in 0..(1 << target.inner().num_vars()) { + let witness: Vec<_> = (0..target.inner().num_vars()) .map(|p| mask & (1 << p) != 0) .collect(); - let energy = target.evaluate(&witness).unwrap().0.unwrap(); + let energy = target.inner().evaluate(&witness).unwrap().0.unwrap(); let mut penalty = 0; for (j, clause) in [a, b].iter().enumerate() { let y: Vec = clause @@ -226,16 +279,36 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { _ => unreachable!(), }; } - assert_eq!(energy - reduction.zero_penalty_energy, penalty); assert_eq!( - AggregateReductionResult::extract_value(&reduction, Min(Some(energy))), - Or(penalty == 0) + energy - *ReductionResult::target_problem(&reduction).bound(), + penalty + ); + assert_eq!( + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(Some(energy))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(penalty == 0) ); if penalty == 0 { - let decoded = reduction.extract_solution(&witness).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + witness.clone(), + ) + .unwrap(), + ) + .map(|result| { + result.into_solution().expect( + "qualifying target result must recover a source solution", + ) + }) + .unwrap(); assert!(source.evaluate(&decoded).unwrap().0); } else { - assert!(reduction.extract_solution(&witness).is_err()); + assert!(!target.evaluate(&witness).unwrap().is_valid()); } minimum = minimum.min(energy); } @@ -243,24 +316,47 @@ 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))), - Or(sat) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(Some(minimum))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(sat) ); assert_eq!( - AggregateReductionResult::extract_value(&reduction, Min(None)), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(false) ); - assert!(reduction.extract_solution(&vec![]).is_err()); - assert!(reduction - .extract_solution(&vec![false; target.num_vars() + 1]) - .is_err()); + assert!(matches!( + target.inner().evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); + assert!(matches!( + target + .inner() + .evaluate(&vec![false; target.inner().num_vars() + 1]), + Err(InvalidConfiguration(_)) + )); } } 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(), + reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + (&vec![false; n]).clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false; n] ); } @@ -272,29 +368,15 @@ fn test_sat_qubo_all_short_clauses_and_raw_targets() { #[test] fn test_sat_qubo_checked_numeric_boundaries() { - let mut matrix = vec![vec![i64::MAX]]; + let mut matrix = vec![std::collections::BTreeMap::from([(0, i64::MAX)])]; assert!(add_coefficient(&mut matrix, 0, 0, 1).is_err()); - let mut matrix = vec![vec![i64::MIN]]; + let mut matrix = vec![std::collections::BTreeMap::from([(0, i64::MIN)])]; assert!(add_coefficient(&mut matrix, 0, 0, -1).is_err()); assert!(build_qubo_matrix(usize::MAX, &[], 1).is_err()); - // This variable count is legal for the source on both 32- and 64-bit hosts, - // but its dense target cannot have an addressable number of entries. - let n = usize::MAX / 2; - let k2 = KSatisfiability::::new(n, vec![]); - let k3 = KSatisfiability::::new(n, vec![]); - assert!(matches!( - ReduceTo::>::reduce_to(&k2), - Err(crate::rules::ReductionError::IntegerOverflow { .. }) - )); - assert!(matches!( - 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,28 +384,30 @@ 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) - ); + let step = (edge.reduce_fn.unwrap())(&source).unwrap(); + let outcome = if expected { + SolveOutcome::optimal(reduction.target_problem(), witness).unwrap() + } else { + SolveOutcome::Infeasible + }; + let recovered = step + .witness + .recover_result_dyn(&source, crate::solvers::erase_outcome(outcome)) + .unwrap(); + assert_eq!(!matches!(recovered, SolveOutcome::Infeasible), expected); } }; } diff --git a/src/unit_tests/rules/ksatisfiability_registersufficiency.rs b/src/unit_tests/rules/ksatisfiability_registersufficiency.rs index 301cf7af3..dd20600c5 100644 --- a/src/unit_tests/rules/ksatisfiability_registersufficiency.rs +++ b/src/unit_tests/rules/ksatisfiability_registersufficiency.rs @@ -1,6 +1,8 @@ use super::*; use crate::models::formula::CNFClause; use crate::models::misc::RegisterSufficiency; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; use crate::variant::K3; @@ -92,7 +94,6 @@ fn test_ksatisfiability_to_register_sufficiency_rejects_invalid_snapshot_order() let positions = positions_from_order(&order, target.num_vertices()); assert_eq!(target.evaluate(&positions).unwrap(), Or(false)); - assert!(reduction.extract_solution(&positions).is_err()); } #[test] @@ -114,7 +115,14 @@ fn test_ksatisfiability_to_register_sufficiency_forward_schedule() { Or(true) ); - let extracted = reduction.extract_solution(®ister_schedule).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), register_schedule.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); assert_eq!(extracted, vec![true]); } @@ -159,11 +167,22 @@ fn test_ksatisfiability_to_registersufficiency_closed_loop_boundaries() { let solution = BruteForce::new().solve(reduction.target_problem()).unwrap(); assert_eq!(solution.is_some(), feasible); if let Some(solution) = solution { - let extracted = reduction.extract_solution(&solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false; declared]); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); } else { - assert!(reduction.extract_solution(&vec![0]).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0]) + .unwrap() + .is_valid()); } } } @@ -211,13 +230,30 @@ fn test_short_repeated_and_tautological_clauses() { source_value ); if source_value.0 { - let decoded = reduction.extract_solution(&positions).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + positions.clone(), + ) + .unwrap(), + ) + .map(|result| { + result.into_solution().expect( + "qualifying target result must recover a source solution", + ) + }) + .unwrap(); assert_eq!(source.evaluate(&decoded).unwrap(), Or(true)); for &i in &reduction.source_variables { assert_eq!(decoded[i], original[i]); } } else { - assert!(reduction.extract_solution(&positions).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&positions) + .unwrap() + .is_valid()); } } } @@ -234,7 +270,14 @@ fn test_sparse_original_variables_and_padding_bound() { let layout = reduction.layout.as_ref().unwrap(); assert_eq!(layout.num_vars, 2); let positions = layout.schedule_for_assignment(&[true, false]); - let decoded = reduction.extract_solution(&positions).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), positions.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(decoded.len(), 17); assert!(decoded[1]); assert!(!decoded[16]); @@ -276,7 +319,14 @@ fn test_feasible_snapshot_may_leave_both_literals_uncomputed() { && positions[layout.x_neg(1)] > positions[layout.w(2)] ); assert_eq!( - reduction.extract_solution(&positions).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), positions.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, false] ); } diff --git a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs index 6646e6d14..701f8fd86 100644 --- a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::formula::CNFClause; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::variant::K3; @@ -17,7 +18,7 @@ fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { .expect("reduction should succeed"); let target = reduction.target_problem(); - assert_eq!(target.lcm_moduli(), 15); + assert_eq!(target.lcm_moduli().unwrap(), 15); assert_eq!(target.num_pairs(), 6); let solver = BruteForce::new(); @@ -25,7 +26,14 @@ fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { .solve(target) .unwrap() .expect("target should be satisfiable"); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } @@ -82,7 +90,14 @@ fn test_ksatisfiability_to_simultaneous_incongruences_tautological_clause_is_red .solve(reduction.target_problem()) .unwrap() .expect("target should remain satisfiable"); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/ksatisfiability_subsetsum.rs b/src/unit_tests/rules/ksatisfiability_subsetsum.rs index 5cacbc628..c69dd2e14 100644 --- a/src/unit_tests/rules/ksatisfiability_subsetsum.rs +++ b/src/unit_tests/rules/ksatisfiability_subsetsum.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::formula::CNFClause; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::variant::K3; use num_bigint::BigUint; @@ -30,7 +31,14 @@ fn test_ksatisfiability_to_subsetsum_closed_loop() { // Every SubsetSum solution must map back to a satisfying 3-SAT assignment for sol in &solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted).unwrap()); } @@ -73,7 +81,14 @@ fn test_ksatisfiability_to_subsetsum_single_clause() { // Each SubsetSum solution maps to a satisfying assignment let mut sat_assignments = std::collections::HashSet::new(); for sol in &solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ksat.evaluate(&extracted).unwrap()); sat_assignments.insert(extracted); } @@ -122,7 +137,14 @@ fn test_ksatisfiability_to_subsetsum_all_negated() { let mut sat_assignments = std::collections::HashSet::new(); for sol in &solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ksat.evaluate(&extracted).unwrap()); sat_assignments.insert(extracted); } @@ -156,7 +178,14 @@ fn test_ksatisfiability_to_subsetsum_extract_solution_example() { ]; assert!(target.evaluate(&specific_config).unwrap()); - let extracted = reduction.extract_solution(&specific_config).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), specific_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, true]); // x1=T, x2=T, x3=T assert!(ksat.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs index e9c62c153..136d18c44 100644 --- a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs +++ b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::ILP; use crate::models::formula::CNFClause; use crate::models::misc::TimetableDesign; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::variant::K3; @@ -64,7 +65,14 @@ fn test_ksatisfiability_to_timetabledesign_extract_solution_from_constructed_tim .0 ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } @@ -81,7 +89,14 @@ fn test_ksatisfiability_to_timetabledesign_multi_variable_round_trip() { ) .expect("a satisfying 3SAT assignment should lift to a timetable witness"); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, false]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -96,7 +111,14 @@ fn test_ksatisfiability_to_timetabledesign_closed_loop() { let ilp_solution = ILPSolver::new() .solve(target_reduction.target_problem()) .expect("satisfiable source instance should produce a feasible timetable"); - let target_solution = target_reduction.extract_solution(&ilp_solution).unwrap(); + let target_solution = target_reduction + .recover_result( + reduction.target_problem(), + SolveOutcome::optimal(target_reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( reduction @@ -106,7 +128,14 @@ fn test_ksatisfiability_to_timetabledesign_closed_loop() { .0 ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } @@ -118,10 +147,9 @@ fn test_ksatisfiability_to_timetabledesign_unsatisfiable() { let target_reduction = ReduceTo::>::reduce_to(reduction.target_problem()) .expect("timetable reduction should succeed"); - assert!( - ILPSolver::new() - .solve(target_reduction.target_problem()) - .is_err(), + assert_eq!( + ILPSolver::new().solve(target_reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "unsatisfiable 3SAT instance should produce an infeasible timetable" ); } diff --git a/src/unit_tests/rules/lengthboundeddisjointpaths_ilp.rs b/src/unit_tests/rules/lengthboundeddisjointpaths_ilp.rs index c9ca738b9..ef05ab332 100644 --- a/src/unit_tests/rules/lengthboundeddisjointpaths_ilp.rs +++ b/src/unit_tests/rules/lengthboundeddisjointpaths_ilp.rs @@ -2,8 +2,11 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::ReduceTo; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Max; @@ -51,7 +54,15 @@ fn test_lengthboundeddisjointpaths_to_ilp_triangle_subgraphs() { assert_eq!(source.evaluate(&reference).unwrap(), Max(Some(expected))); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(expected))); } } @@ -75,7 +86,14 @@ fn test_lengthboundeddisjointpaths_to_ilp_preserves_edge_order() { .target_problem() .is_feasible(&target_solution) .unwrap()); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted, vec![ @@ -102,7 +120,14 @@ fn test_lengthboundeddisjointpaths_to_ilp_extracts_path_from_circulation() { .target_problem() .is_feasible(&target_solution) .unwrap()); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![vec![true, false, false, false]]); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(1))); } @@ -112,7 +137,14 @@ fn test_lengthboundeddisjointpaths_to_ilp_extracts_path_from_circulation() { fn test_lengthboundeddisjointpaths_to_ilp_rejects_invalid_target_solutions() { let source = LengthBoundedDisjointPaths::new(SimpleGraph::new(2, vec![(0, 1)]), 0, 1, 1); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - for solution in [vec![], vec![2, 0, 1], vec![0, 0, 1], vec![1, 0, 0]] { - assert!(reduction.extract_solution(&solution).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); + for solution in [vec![2, 0, 1], vec![0, 0, 1], vec![1, 0, 0]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&solution) + .unwrap() + .is_valid()); } } diff --git a/src/unit_tests/rules/longestcircuit_ilp.rs b/src/unit_tests/rules/longestcircuit_ilp.rs index 46f03d612..ca7811768 100644 --- a/src/unit_tests/rules/longestcircuit_ilp.rs +++ b/src/unit_tests/rules/longestcircuit_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -56,7 +57,14 @@ fn test_longestcircuit_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&extracted).unwrap().0.is_some(), "ILP solution should be a valid circuit" @@ -92,7 +100,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } @@ -118,7 +133,14 @@ fn test_longestcircuit_to_ilp_cycle_excludes_any_vertex() { ); let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); let target_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, true, true]); } } @@ -136,7 +158,14 @@ fn test_longestcircuit_to_ilp_selects_one_best_cycle() { let problem = LongestCircuit::new(SimpleGraph::new(6, edges), lengths); let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); let target_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), crate::types::Max(Some(15)) diff --git a/src/unit_tests/rules/longestcommonsubsequence_ilp.rs b/src/unit_tests/rules/longestcommonsubsequence_ilp.rs index f920fa554..c7e0361f3 100644 --- a/src/unit_tests/rules/longestcommonsubsequence_ilp.rs +++ b/src/unit_tests/rules/longestcommonsubsequence_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::ILP; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Max; @@ -17,7 +18,14 @@ fn test_lcs_to_ilp_yes_instance() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), problem.max_length()); let value = problem.evaluate(&extracted).unwrap(); @@ -35,7 +43,14 @@ fn test_lcs_to_ilp_closed_loop_three_strings() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!(matches!(ilp_value, Max(Some(_)))); @@ -57,7 +72,14 @@ fn test_lcs_to_ilp_extracts_valid_witness() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), problem.max_length()); let value = problem.evaluate(&extracted).unwrap(); @@ -74,7 +96,14 @@ fn test_lcs_to_ilp_matches_brute_force() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); let brute_force = BruteForce::new(); @@ -95,7 +124,14 @@ fn test_lcs_to_ilp_single_position_all_padding() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert_eq!(value, Max(Some(0))); diff --git a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs index 7daf6a1e0..7eda8d642 100644 --- a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::Graph; use crate::traits::Problem; @@ -127,7 +128,14 @@ fn test_lcs_to_mis_extract_solution() { .solve(reduction.target_problem()) .unwrap() .expect("should have a solution"); - let source_sol = reduction.extract_solution(&witness).unwrap(); + let source_sol = reduction + .recover_result( + &lcs, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // The extracted solution should be valid for the source let value = lcs.evaluate(&source_sol).unwrap(); diff --git a/src/unit_tests/rules/longestpath_ilp.rs b/src/unit_tests/rules/longestpath_ilp.rs index 8e23b37be..1dedb4942 100644 --- a/src/unit_tests/rules/longestpath_ilp.rs +++ b/src/unit_tests/rules/longestpath_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -72,7 +73,14 @@ fn test_longestpath_to_ilp_closed_loop_on_issue_example() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.is_valid_solution(&extracted)); assert_eq!(problem.evaluate(&extracted).unwrap(), best_value); @@ -86,7 +94,14 @@ fn test_solution_extraction_from_handcrafted_ilp_assignment() { // x_{0->1}, x_{1->0}, x_{1->2}, x_{2->1}, o_0, o_1, o_2 let target_solution = vec![1, 0, 1, 0, 0, 1, 2]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true]); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(5))); @@ -106,7 +121,14 @@ fn test_source_equals_target_uses_empty_path() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should solve the trivial empty-path case"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false, false]); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(0))); diff --git a/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs b/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs index 51c788b17..52488597b 100644 --- a/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs +++ b/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::graph::{MaxCut, MinimumCutIntoBoundedSets}; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::rules::traits::ReduceTo; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; #[test] @@ -124,7 +125,14 @@ fn test_maxcut_to_minimumcutintoboundedsets_extract_solution_size() { // Target has 8 vertices, extract should return 3 let dummy_target_sol = vec![false, true, false, true, false, true, false, true]; - let extracted = reduction.extract_solution(&dummy_target_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), dummy_target_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); } diff --git a/src/unit_tests/rules/maxcut_minimummatrixcover.rs b/src/unit_tests/rules/maxcut_minimummatrixcover.rs index 7e6ef4a1b..5ea3fce00 100644 --- a/src/unit_tests/rules/maxcut_minimummatrixcover.rs +++ b/src/unit_tests/rules/maxcut_minimummatrixcover.rs @@ -4,6 +4,7 @@ use crate::models::graph::MaxCut; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::rules::traits::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::{Max, Min}; @@ -203,7 +204,17 @@ fn test_extract_solution_is_identity() { let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); let target_sol = vec![true, false, true]; - assert_eq!(reduction.extract_solution(&target_sol).unwrap(), target_sol); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_sol.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + target_sol + ); } #[test] diff --git a/src/unit_tests/rules/maximalis_ilp.rs b/src/unit_tests/rules/maximalis_ilp.rs index 928156425..11c6e8da3 100644 --- a/src/unit_tests/rules/maximalis_ilp.rs +++ b/src/unit_tests/rules/maximalis_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -32,7 +33,14 @@ fn test_maximalis_to_ilp_bf_vs_ilp() { let bf_value = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -48,7 +56,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); } diff --git a/src/unit_tests/rules/maximum2satisfiability_ilp.rs b/src/unit_tests/rules/maximum2satisfiability_ilp.rs index 758a80d6a..0687875dd 100644 --- a/src/unit_tests/rules/maximum2satisfiability_ilp.rs +++ b/src/unit_tests/rules/maximum2satisfiability_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; use crate::models::formula::CNFClause; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -30,7 +31,14 @@ fn test_maximum2satisfiability_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Optimal: 6 satisfied clauses let value = problem.evaluate(&extracted).unwrap(); assert_eq!(value, crate::types::Max(Some(6))); @@ -47,7 +55,14 @@ fn test_maximum2satisfiability_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -102,7 +117,14 @@ fn test_maximum2satisfiability_to_ilp_all_satisfiable() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); // Both clauses should be satisfiable assert_eq!(value, crate::types::Max(Some(2))); diff --git a/src/unit_tests/rules/maximum2satisfiability_maxcut.rs b/src/unit_tests/rules/maximum2satisfiability_maxcut.rs index 17bf8c50f..60a61f147 100644 --- a/src/unit_tests/rules/maximum2satisfiability_maxcut.rs +++ b/src/unit_tests/rules/maximum2satisfiability_maxcut.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::formula::{CNFClause, Maximum2Satisfiability}; use crate::models::graph::MaxCut; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Max; @@ -67,7 +68,14 @@ fn test_maximum2satisfiability_to_maxcut_issue_affine_relation_on_all_partitions let target_solution: Vec = (0..target.num_vertices()) .map(|bit| ((mask >> bit) & 1) == 1) .collect(); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let satisfied = source.evaluate(&source_solution).unwrap().unwrap(); let cut_weight = target.evaluate(&target_solution).unwrap().unwrap(); @@ -87,22 +95,49 @@ fn test_maximum2satisfiability_to_maxcut_extract_solution_uses_reference_vertex( assert_eq!( reduction - .extract_solution(&vec![false, true, false, false]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![false, true, false, false].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, true, true] ); assert_eq!( reduction - .extract_solution(&vec![true, false, true, true]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, false, true, true].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, true, true] ); assert_eq!( source .evaluate( &reduction - .extract_solution(&vec![true, false, true, true]) + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, false, true, true].clone() + ) + .unwrap() + ) .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") ) .unwrap(), Max(Some(5)) diff --git a/src/unit_tests/rules/maximumclique_ilp.rs b/src/unit_tests/rules/maximumclique_ilp.rs index 4cc94c341..c640eb98e 100644 --- a/src/unit_tests/rules/maximumclique_ilp.rs +++ b/src/unit_tests/rules/maximumclique_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; /// Check if a configuration represents a valid clique in the graph. /// A clique is valid if all selected vertices are pairwise adjacent. @@ -122,7 +123,14 @@ fn test_maximumclique_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Both should find optimal size = 3 (all vertices form a clique) let ilp_size = clique_size(&problem, &extracted); @@ -154,7 +162,14 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = clique_size(&problem, &extracted); assert_eq!(bf_size, 2); @@ -181,7 +196,14 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = brute_force_max_clique(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = clique_size(&problem, &extracted); assert_eq!(bf_obj, 101); @@ -200,7 +222,14 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, false, false]); // Verify this is a valid clique (0 and 1 are adjacent) @@ -236,7 +265,14 @@ fn test_empty_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Only one vertex should be selected assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 1); @@ -261,7 +297,14 @@ fn test_complete_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // All vertices should be selected assert_eq!(extracted, vec![true, true, true, true]); @@ -284,7 +327,14 @@ fn test_bipartite_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(is_valid_clique(&problem, &extracted)); assert_eq!(clique_size(&problem, &extracted), 2); @@ -311,7 +361,14 @@ fn test_star_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(is_valid_clique(&problem, &extracted)); assert_eq!(clique_size(&problem, &extracted), 2); diff --git a/src/unit_tests/rules/maximumclique_maximumindependentset.rs b/src/unit_tests/rules/maximumclique_maximumindependentset.rs index 2097108cf..45907db80 100644 --- a/src/unit_tests/rules/maximumclique_maximumindependentset.rs +++ b/src/unit_tests/rules/maximumclique_maximumindependentset.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::Graph; use crate::traits::Problem; use crate::types::One; @@ -54,7 +55,14 @@ fn test_maximumclique_to_maximumindependentset_triangle() { .any(|s| s.iter().filter(|&&selected| selected).count() == 3)); // Extract solution: should be the full clique {0,1,2} - let source_sol = reduction.extract_solution(&target_solutions[0]).unwrap(); + let source_sol = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&source_sol).unwrap().unwrap(), 3); } diff --git a/src/unit_tests/rules/maximumcokplex_ilp.rs b/src/unit_tests/rules/maximumcokplex_ilp.rs index aa92d32f2..f24c0cc9f 100644 --- a/src/unit_tests/rules/maximumcokplex_ilp.rs +++ b/src/unit_tests/rules/maximumcokplex_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MaximumCoKPlex; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::{Max, One}; @@ -70,7 +71,14 @@ fn test_maximumcokplex_to_ilp_k_equals_1_regression() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("k=1 instance should be ILP-solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(2))); assert_eq!(extracted.iter().filter(|&&selected| selected).count(), 2); @@ -83,7 +91,14 @@ fn test_maximumcokplex_to_ilp_extract_solution_identity() { let reduction: ReductionCoKPlexToILP = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target_solution = vec![1, 0, 1, 0, 1]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true, false, true]); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(12))); diff --git a/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs b/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs index 7d63945d2..595781ced 100644 --- a/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::{LabelledArc, LabelledDigraph, MaximumCommonEdgeSubgraph}; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Max; @@ -64,7 +65,14 @@ fn test_maximumcommonedgesubgraph_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("matched paths ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(2))); @@ -95,7 +103,14 @@ fn test_maximumcommonedgesubgraph_to_ilp_truncated_target() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("truncated ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(1))); @@ -120,7 +135,14 @@ fn test_maximumcommonedgesubgraph_to_ilp_empty_graphs() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("empty-arc ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(0))); } @@ -138,7 +160,14 @@ fn test_maximumcommonedgesubgraph_to_ilp_self_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("self-loop ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(1))); diff --git a/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs b/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs index 75b2378ce..d4a4290d6 100644 --- a/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs +++ b/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MaximumContactMapOverlap; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Max; @@ -49,7 +50,14 @@ fn test_maximumcontactmapoverlap_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("canonical CMO ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // The optimal alignment preserves both contacts of G_1. assert!(source.is_valid_solution(&extracted)); @@ -74,7 +82,14 @@ fn test_maximumcontactmapoverlap_to_ilp_trivial_no_contacts() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("empty-contact ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(0))); } @@ -102,7 +117,14 @@ fn test_maximumcontactmapoverlap_to_ilp_order_preserving_forbidden() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP must be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(1))); @@ -129,7 +151,14 @@ fn test_maximumcontactmapoverlap_to_ilp_extract_solution_partial() { let mut target_sol = vec![0_i64; reduction.target_problem().num_vars()]; target_sol[1] = 1; target_sol[n2 + 2] = 1; - let extracted = reduction.extract_solution(&target_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Encoding: vertex j of G_2 is represented as j+1. assert_eq!(extracted, vec![2, 3]); assert!(source.is_valid_solution(&extracted)); diff --git a/src/unit_tests/rules/maximumdomaticnumber_ilp.rs b/src/unit_tests/rules/maximumdomaticnumber_ilp.rs index 54a6e2e1a..5a8edfff3 100644 --- a/src/unit_tests/rules/maximumdomaticnumber_ilp.rs +++ b/src/unit_tests/rules/maximumdomaticnumber_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::ObjectiveSense; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Max; @@ -21,7 +22,14 @@ fn test_maximumdomaticnumber_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); // Both should find domatic number = 2 @@ -76,7 +84,14 @@ fn test_maximumdomaticnumber_to_ilp_complete_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert_eq!(value, Max(Some(3))); @@ -92,7 +107,14 @@ fn test_maximumdomaticnumber_to_ilp_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert_eq!(value, Max(Some(1))); @@ -111,7 +133,14 @@ fn test_maximumdomaticnumber_to_ilp_solution_extraction() { // x_{2,0}=1, x_{2,1}=0, x_{2,2}=0, // y_0=1, y_1=1, y_2=0 let ilp_solution = vec![1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 0]); // Verify this is a valid partition with 2 dominating sets diff --git a/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs b/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs index aaf58b7ee..0df071d64 100644 --- a/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs +++ b/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MaximumEdgeWeightedKClique; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Max; @@ -42,7 +43,14 @@ fn test_maximumedgeweightedkclique_to_ilp_extract_solution_identity() { let source = issue_instance(); let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target_solution = vec![1, 1, 1, 0, 1, 1, 1, 0, 0]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, true, false]); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(8))); } diff --git a/src/unit_tests/rules/maximumindependentset_gridgraph.rs b/src/unit_tests/rules/maximumindependentset_gridgraph.rs index 7053dde31..02b99728c 100644 --- a/src/unit_tests/rules/maximumindependentset_gridgraph.rs +++ b/src/unit_tests/rules/maximumindependentset_gridgraph.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::graph::MaximumIndependentSet; use crate::rules::unitdiskmapping::ksg; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, KingsSubgraph, SimpleGraph}; use crate::types::One; @@ -90,7 +91,14 @@ fn test_mis_simple_one_to_kings_one_closed_loop() { let grid_solutions = solver.find_all_witnesses(target).unwrap(); assert!(!grid_solutions.is_empty()); - let original_solution = result.extract_solution(&grid_solutions[0]).unwrap(); + let original_solution = result + .recover_result( + &problem, + SolveOutcome::optimal(result.target_problem(), grid_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(original_solution.len(), 5); let size: usize = original_solution .iter() @@ -120,7 +128,14 @@ fn test_mis_simple_one_to_kings_one_all_four_vertex_graphs() { let target_solution = solve_mis_config(target.graph().num_vertices(), &target.graph().edges()); let target_solution = crate::config::config_to_bits(&target_solution); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( is_independent_set(&edges, &crate::config::bits_to_config(&source_solution),), @@ -133,3 +148,23 @@ fn test_mis_simple_one_to_kings_one_all_four_vertex_graphs() { ); } } + +#[test] +fn test_mis_simple_one_to_kings_one_rejects_feasible_target_incumbent() { + let problem = MaximumIndependentSet::new( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]), + vec![One; 5], + ); + let result = ReduceTo::>::reduce_to(&problem) + .expect("reduction should succeed"); + let target = result.target_problem(); + let optimum = BruteForce::new().solve(target).unwrap().unwrap(); + + // The empty set is independent in the grid graph but says nothing about the source optimum. + crate::rules::test_helpers::assert_suboptimal_feasible_target_is_insufficient( + &problem, + &result, + vec![false; target.graph().num_vertices()], + &optimum, + ); +} diff --git a/src/unit_tests/rules/maximumindependentset_ilp.rs b/src/unit_tests/rules/maximumindependentset_ilp.rs index b20f2b265..45b981af6 100644 --- a/src/unit_tests/rules/maximumindependentset_ilp.rs +++ b/src/unit_tests/rules/maximumindependentset_ilp.rs @@ -1,6 +1,7 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MaximumIndependentSet; use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -60,7 +61,13 @@ fn test_maximumindependentset_to_ilp_via_path_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted: Vec = chain.extract_solution(&ilp_solution).unwrap(); + let extracted: Vec = chain + .recover_result::, ILP>( + &problem, + SolveOutcome::optimal(ilp, ilp_solution).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); let ilp_size = extracted.iter().filter(|&&selected| selected).count(); assert_eq!(ilp_size, 2); @@ -76,7 +83,13 @@ fn test_maximumindependentset_to_ilp_via_path_weighted() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution).unwrap(); + let extracted = chain + .recover_result::, ILP>( + &problem, + SolveOutcome::optimal(ilp, ilp_solution).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(100))); assert_eq!(extracted, vec![false, true, false]); @@ -93,6 +106,12 @@ fn test_maximumindependentset_to_ilp_bf_vs_ilp() { let bf_value_solution = BruteForce::new().solve(&problem).unwrap().unwrap(); let bf_value = problem.evaluate(&bf_value_solution).unwrap(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution).unwrap(); + let extracted = chain + .recover_result::, ILP>( + &problem, + SolveOutcome::optimal(ilp, ilp_solution).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_value); } diff --git a/src/unit_tests/rules/maximumindependentset_maximumclique.rs b/src/unit_tests/rules/maximumindependentset_maximumclique.rs index f6e2813b1..defa68e44 100644 --- a/src/unit_tests/rules/maximumindependentset_maximumclique.rs +++ b/src/unit_tests/rules/maximumindependentset_maximumclique.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::One; @@ -46,7 +47,14 @@ fn test_maximumindependentset_to_maximumclique_weighted() { let solver = BruteForce::new(); let best = solver.find_all_witnesses(target).unwrap(); for sol in &best { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = source.evaluate(&extracted).unwrap(); assert!(metric.is_valid()); } diff --git a/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs b/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs index 202b4819b..070b3f68d 100644 --- a/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs +++ b/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs @@ -1,8 +1,9 @@ use super::*; +use crate::solvers::SolveOutcome; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; use crate::types::One; -include!("../jl_helpers.rs"); #[test] fn test_maximumindependentset_to_maximumsetpacking_closed_loop() { @@ -206,7 +207,14 @@ fn test_maximumindependentset_one_to_maximumsetpacking_closed_loop() { let sp_solutions = solver.find_all_witnesses(sp_problem).unwrap(); assert!(!sp_solutions.is_empty()); - let original_solution = reduction.extract_solution(&sp_solutions[0]).unwrap(); + let original_solution = reduction + .recover_result( + &is_problem, + SolveOutcome::optimal(reduction.target_problem(), sp_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(original_solution.len(), 3); let size: usize = original_solution .iter() @@ -230,7 +238,14 @@ fn test_maximumsetpacking_one_to_maximumindependentset_closed_loop() { let is_solutions = solver.find_all_witnesses(is_problem).unwrap(); assert!(!is_solutions.is_empty()); - let original_solution = reduction.extract_solution(&is_solutions[0]).unwrap(); + let original_solution = reduction + .recover_result( + &sp_problem, + SolveOutcome::optimal(reduction.target_problem(), is_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(original_solution.len(), 3); let size: usize = original_solution .iter() diff --git a/src/unit_tests/rules/maximumindependentset_qubo.rs b/src/unit_tests/rules/maximumindependentset_qubo.rs index 99db973b3..1692d796b 100644 --- a/src/unit_tests/rules/maximumindependentset_qubo.rs +++ b/src/unit_tests/rules/maximumindependentset_qubo.rs @@ -3,6 +3,7 @@ use crate::models::graph::MaximumIndependentSet; use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Max; @@ -42,12 +43,18 @@ fn test_maximumindependentset_to_qubo_via_path_closed_loop() { path.type_names(), vec!["MaximumIndependentSet", "MaximumSetPacking", "QUBO"] ); - assert_eq!(qubo.num_variables(), 4); + assert_eq!(qubo.num_variables().unwrap(), 4); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = chain.extract_solution(sol).unwrap(); + let extracted = chain + .recover_result::, QUBO>( + &problem, + SolveOutcome::optimal(qubo, (sol).clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(extracted.iter().filter(|&&x| x).count(), 2); } @@ -65,7 +72,13 @@ fn test_maximumindependentset_to_qubo_via_path_weighted() { .solve(qubo) .unwrap() .expect("QUBO should be solvable via path"); - let extracted = chain.extract_solution(&qubo_solution).unwrap(); + let extracted = chain + .recover_result::, QUBO>( + &problem, + SolveOutcome::optimal(qubo, qubo_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(100))); assert_eq!(extracted, vec![false, true, false]); @@ -77,14 +90,20 @@ fn test_maximumindependentset_to_qubo_via_path_empty_graph() { let (_, chain) = reduce_mis_to_qubo(&problem); let qubo: &QUBO = chain.target_problem(); - assert_eq!(qubo.num_variables(), 3); + assert_eq!(qubo.num_variables().unwrap(), 3); let solver = BruteForce::new(); let qubo_solution = solver .solve(qubo) .unwrap() .expect("QUBO should be solvable"); - let extracted = chain.extract_solution(&qubo_solution).unwrap(); + let extracted = chain + .recover_result::, QUBO>( + &problem, + SolveOutcome::optimal(qubo, qubo_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(extracted, vec![true, true, true]); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(3))); diff --git a/src/unit_tests/rules/maximumindependentset_triangular.rs b/src/unit_tests/rules/maximumindependentset_triangular.rs index 7205ac3f5..f72325b6c 100644 --- a/src/unit_tests/rules/maximumindependentset_triangular.rs +++ b/src/unit_tests/rules/maximumindependentset_triangular.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::graph::MaximumIndependentSet; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph, TriangularSubgraph}; use crate::types::One; @@ -57,7 +58,14 @@ fn test_mis_simple_one_to_triangular_closed_loop() { // Map a trivial zero solution back to verify dimensions let zero_config = vec![false; target.graph().num_vertices()]; - let original_solution = result.extract_solution(&zero_config).unwrap(); + let original_solution = result + .recover_result( + &problem, + SolveOutcome::optimal(result.target_problem(), zero_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(original_solution.len(), 3); } @@ -78,7 +86,14 @@ fn test_mis_simple_one_to_triangular_preserves_optimum_and_witness() { target.weights(), ); let target_solution = crate::config::config_to_bits(&target_solution); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(is_independent_set( &edges, @@ -114,7 +129,14 @@ fn test_mis_simple_one_to_triangular_all_four_vertex_graphs() { target.weights(), ); let target_solution = crate::config::config_to_bits(&target_solution); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( is_independent_set(&edges, &crate::config::bits_to_config(&source_solution),), @@ -166,3 +188,29 @@ fn test_mis_simple_one_to_triangular_graph_methods() { assert_eq!(positions.len(), n); assert_eq!(graph.num_positions(), n); } + +#[test] +fn test_mis_simple_one_to_triangular_rejects_feasible_target_incumbent() { + use crate::test_unitdiskmapping_algorithms::common::solve_weighted_mis_config; + + let source = MaximumIndependentSet::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), + vec![One; 4], + ); + let reduction = + ReduceTo::>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + let optimum = crate::config::config_to_bits(&solve_weighted_mis_config( + target.graph().num_vertices(), + &target.graph().edges(), + target.weights(), + )); + + // The empty set is independent in the triangular graph but says nothing about the source optimum. + crate::rules::test_helpers::assert_suboptimal_feasible_target_is_insufficient( + &source, + &reduction, + vec![false; target.graph().num_vertices()], + &optimum, + ); +} diff --git a/src/unit_tests/rules/maximumleafspanningtree_ilp.rs b/src/unit_tests/rules/maximumleafspanningtree_ilp.rs index be3068b63..057c2f1e8 100644 --- a/src/unit_tests/rules/maximumleafspanningtree_ilp.rs +++ b/src/unit_tests/rules/maximumleafspanningtree_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MaximumLeafSpanningTree; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -56,7 +57,14 @@ fn test_maximumleafspanningtree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // All brute-force optimal solutions have the same value let bf_value = problem.evaluate(&best_source[0]).unwrap(); @@ -76,7 +84,14 @@ fn test_maximumleafspanningtree_to_ilp_canonical_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&best_source[0]).unwrap(), Max(Some(4))); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(4))); @@ -94,9 +109,23 @@ fn test_solution_extraction_reads_edge_selector_prefix() { target_solution[0] = 1; // edge (0,1) target_solution[1] = 1; // edge (1,2) target_solution[2] = 1; // edge (2,3) + target_solution[4] = 1; // vertex 0 is a leaf + target_solution[7] = 1; // vertex 3 is a leaf + + // Root 0 supplies one unit to each other vertex along the path. + target_solution[8] = 3; // 0 -> 1 + target_solution[10] = 2; // 1 -> 2 + target_solution[12] = 1; // 2 -> 3 assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, true, true, false] ); } @@ -109,7 +138,14 @@ fn test_reduce_and_solve_via_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(4))); assert!(problem.is_valid_solution(&extracted)); } @@ -131,7 +167,14 @@ fn test_maximumleafspanningtree_to_ilp_path_graph() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(2))); } @@ -144,7 +187,14 @@ fn test_maximumleafspanningtree_to_ilp_star_graph() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(3))); assert!(problem.is_valid_solution(&extracted)); } @@ -164,7 +214,14 @@ fn test_maximumleafspanningtree_to_ilp_complete_graph() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_value); assert_eq!(bf_value, Max(Some(3))); diff --git a/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs b/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs index e706c0c43..ca22844b4 100644 --- a/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs +++ b/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -45,7 +46,14 @@ fn test_maximumlikelihoodranking_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -62,7 +70,14 @@ fn test_maximumlikelihoodranking_to_ilp_extraction() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Verify the extracted config is a valid permutation let n = problem.num_items(); @@ -88,7 +103,14 @@ fn test_maximumlikelihoodranking_to_ilp_two_items() { assert_eq!(ilp.num_constraints(), 0); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!(value.is_valid()); @@ -110,7 +132,14 @@ fn test_maximumlikelihoodranking_to_ilp_single_item() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("single-item ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0]); } diff --git a/src/unit_tests/rules/maximummatching_ilp.rs b/src/unit_tests/rules/maximummatching_ilp.rs index 3dda34bfe..7eb431df8 100644 --- a/src/unit_tests/rules/maximummatching_ilp.rs +++ b/src/unit_tests/rules/maximummatching_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -61,7 +62,14 @@ fn test_maximummatching_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Both should find optimal size = 1 (one edge) let bf_size = problem.evaluate(&bf_solutions[0]).unwrap(); @@ -94,7 +102,14 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_size, Max(Some(2))); @@ -122,7 +137,14 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_obj, Max(Some(100))); @@ -141,7 +163,14 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 1]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true]); // Verify this is a valid matching (edges 0-1 and 2-3 are disjoint) @@ -196,7 +225,14 @@ fn test_k4_perfect_matching() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(2))); // Perfect matching has 2 edges @@ -218,7 +254,14 @@ fn test_star_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(1))); @@ -238,7 +281,14 @@ fn test_bipartite_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Max(Some(2))); diff --git a/src/unit_tests/rules/maximummatching_maximumsetpacking.rs b/src/unit_tests/rules/maximummatching_maximumsetpacking.rs index e2a96c257..4081599cf 100644 --- a/src/unit_tests/rules/maximummatching_maximumsetpacking.rs +++ b/src/unit_tests/rules/maximummatching_maximumsetpacking.rs @@ -1,10 +1,11 @@ use super::*; +use crate::solvers::SolveOutcome; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Max; -include!("../jl_helpers.rs"); #[test] fn test_maximummatching_to_maximumsetpacking_closed_loop() { @@ -62,7 +63,14 @@ fn test_matching_to_setpacking_solution_extraction() { // Test solution extraction is 1:1 let sp_solution = vec![true, false, true]; - let matching_solution = reduction.extract_solution(&sp_solution).unwrap(); + let matching_solution = reduction + .recover_result( + &matching, + SolveOutcome::optimal(reduction.target_problem(), sp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(matching_solution, vec![true, false, true]); // Verify the extracted solution is valid for original MaximumMatching diff --git a/src/unit_tests/rules/maximumsetpacking_casts.rs b/src/unit_tests/rules/maximumsetpacking_casts.rs index 5c37d5230..7bb3e324b 100644 --- a/src/unit_tests/rules/maximumsetpacking_casts.rs +++ b/src/unit_tests/rules/maximumsetpacking_casts.rs @@ -2,6 +2,7 @@ use super::*; use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; #[test] @@ -17,7 +18,14 @@ fn test_maximumsetpacking_one_to_i64_cast_closed_loop() { let solver = BruteForce::new(); let target_solution = solver.solve(sp_i64).unwrap().unwrap(); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &sp_one, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = sp_one.evaluate(&source_solution).unwrap(); assert!(metric.is_valid()); @@ -36,7 +44,14 @@ fn test_maximumsetpacking_i64_to_f64_cast_closed_loop() { let solver = BruteForce::new(); let target_solution = solver.solve(sp_f64).unwrap().unwrap(); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &sp_i64, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = sp_i64.evaluate(&source_solution).unwrap(); assert!(metric.is_valid()); diff --git a/src/unit_tests/rules/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index 8a7e838e8..0ed721d9f 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Max; @@ -52,7 +53,14 @@ fn test_maximumsetpacking_to_ilp_closed_loop() { let bf_solutions = bf.find_all_witnesses(&problem).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let bf_size: usize = bf_solutions[0].iter().filter(|&&selected| selected).count(); let ilp_size: usize = extracted.iter().filter(|&&selected| selected).count(); @@ -83,7 +91,14 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_obj, Max(Some(6))); @@ -98,7 +113,14 @@ fn test_solution_extraction() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = vec![1, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true, false]); assert!(problem.evaluate(&extracted).unwrap().is_valid()); } @@ -114,7 +136,14 @@ fn test_disjoint_sets() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, true, true]); assert!(problem.evaluate(&extracted).unwrap().is_valid()); @@ -142,3 +171,102 @@ fn test_maximumsetpacking_to_ilp_bf_vs_ilp() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); crate::rules::test_helpers::assert_bf_vs_ilp(&problem, &reduction); } + +#[test] +fn extraction_maps_feasible_witnesses_through_typed_and_dynamic_paths() { + use crate::rules::{DynReductionResult, ReductionGraph}; + use serde_json::json; + + let source = MaximumSetPacking::with_weights(vec![vec![0]], vec![1i64]).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let graph = ReductionGraph::new(); + let path = graph + .find_all_paths( + MaximumSetPacking::::NAME, + &ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()), + ILP::::NAME, + &ReductionGraph::variant_to_map(&ILP::::variant()), + ) + .into_iter() + .find(|path| path.len() == 1) + .unwrap(); + let chain = graph.reduce_along_path(&path, &source).unwrap().unwrap(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![1].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![true] + ); + let extracted = reduction + .recover_result_dyn( + &source, + crate::solvers::erase_outcome( + SolveOutcome::optimal(reduction.target_problem(), vec![1i64]).unwrap(), + ), + ) + .unwrap(); + assert_eq!( + crate::solvers::downcast_outcome::, crate::types::Max>(extracted) + .unwrap() + .into_solution() + .unwrap(), + vec![true] + ); + // An unselected set is feasible even though it is not optimal. + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::feasible(reduction.target_problem(), vec![0].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![false] + ); + assert_eq!( + chain + .recover_result_json(&source, json!({"status": "feasible", "solution": [0]})) + .unwrap() + .0, + SolveOutcome::Feasible { + solution: json!([false]), + evaluation: "Max(0)".into() + } + ); +} + +#[test] +fn parameter_upper_bounds_cover_single_and_shared_elements() { + use crate::parameters::ParameterRelation; + use crate::rules::registry::ReductionEntry; + let entry = inventory::iter:: + .into_iter() + .find(|entry| { + entry.source_name == MaximumSetPacking::::NAME + && entry.target_name == ILP::::NAME + && (entry.source_variant_fn)() == MaximumSetPacking::::variant() + && (entry.target_variant_fn)() == ILP::::variant() + }) + .unwrap(); + let contract = entry.parameter_contract().unwrap(); + let transform = contract.transform().unwrap(); + assert_eq!(transform.relation(), ParameterRelation::UpperBound); + for (sets, constraints) in [ + (vec![vec![0]], 0), + (vec![vec![0, 1], vec![1, 2], vec![2, 3]], 2), + ] { + let source = MaximumSetPacking::::new(sets); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let actual = reduction.target_problem().parameters(); + let declared = transform.evaluate(&source.parameters()).unwrap(); + assert_eq!(actual.get("num_constraints"), Some(constraints)); + assert_eq!(actual.get("num_vars"), declared.get("num_vars")); + assert!(actual.get("num_constraints").unwrap() <= declared.get("num_constraints").unwrap()); + } +} diff --git a/src/unit_tests/rules/maximumsetpacking_qubo.rs b/src/unit_tests/rules/maximumsetpacking_qubo.rs index c523def04..9de1e7858 100644 --- a/src/unit_tests/rules/maximumsetpacking_qubo.rs +++ b/src/unit_tests/rules/maximumsetpacking_qubo.rs @@ -1,6 +1,7 @@ use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::traits::Problem; #[test] @@ -16,7 +17,14 @@ fn test_setpacking_to_qubo_closed_loop() { let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &sp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(sp.evaluate(&extracted).unwrap().is_valid()); assert_eq!(extracted.iter().filter(|&&x| x).count(), 2); } @@ -33,7 +41,14 @@ fn test_setpacking_to_qubo_disjoint() { let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &sp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(sp.evaluate(&extracted).unwrap().is_valid()); // All 3 sets should be selected assert_eq!(extracted.iter().filter(|&&x| x).count(), 3); @@ -51,7 +66,14 @@ fn test_setpacking_to_qubo_all_overlap() { let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &sp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(sp.evaluate(&extracted).unwrap().is_valid()); assert_eq!(extracted.iter().filter(|&&x| x).count(), 1); } @@ -64,7 +86,7 @@ fn test_setpacking_to_qubo_structure() { let qubo = reduction.target_problem(); // QUBO should have same number of variables as sets - assert_eq!(qubo.num_variables(), 3); + assert_eq!(qubo.num_variables().unwrap(), 3); } #[test] @@ -106,7 +128,7 @@ fn test_setpacking_to_qubo_penalty_strict_at_large_weights() { let source = MaximumSetPacking::with_weights(vec![vec![0], vec![0]], vec![weight, weight]).unwrap(); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); - assert!(reduction.target_problem().matrix()[0][1] > weight); + assert!(reduction.target_problem().matrix()[[0, 1]] > weight); crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target( &source, &reduction, @@ -134,3 +156,21 @@ fn test_setpacking_to_qubo_non_finite_penalty_is_typed_error() { Err(crate::rules::ReductionError::NonFiniteResult { .. }) )); } + +#[test] +fn test_setpacking_to_qubo_rejects_feasible_target_incumbent() { + let sp = MaximumSetPacking::::new(vec![vec![0, 2], vec![1, 2], vec![0, 3]]); + let reduction = ReduceTo::>::reduce_to(&sp).expect("reduction should succeed"); + let optimum = BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .unwrap(); + + // Packing only {0,2} is valid, but {1,2} and {0,3} together pack two sets. + crate::rules::test_helpers::assert_suboptimal_feasible_target_is_insufficient( + &sp, + &reduction, + vec![true, false, false], + &optimum, + ); +} diff --git a/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs b/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs index e3725ece6..4ce6fcb27 100644 --- a/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MinimumCapacitatedSpanningTree; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -64,7 +65,14 @@ fn test_minimumcapacitatedspanningtree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let bf_value = problem.evaluate(&best_source[0]).unwrap(); let ilp_value = problem.evaluate(&extracted).unwrap(); @@ -83,7 +91,14 @@ fn test_minimumcapacitatedspanningtree_to_ilp_canonical_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&best_source[0]).unwrap(), Min(Some(5))); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(5))); @@ -102,8 +117,22 @@ fn test_solution_extraction_reads_edge_selector_prefix() { target_solution[1] = 1; // edge (0,2) target_solution[3] = 1; // edge (1,3) + // Requirement and connectivity flows run toward root 0. + for offset in [5, 15] { + target_solution[offset + 1] = 2; // 1 -> 0 + target_solution[offset + 3] = 1; // 2 -> 0 + target_solution[offset + 7] = 1; // 3 -> 1 + } + assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, true, false, true, false] ); } @@ -131,7 +160,14 @@ fn test_minimumcapacitatedspanningtree_to_ilp_star_tree() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(3))); assert!(problem.is_valid_solution(&extracted).unwrap()); } @@ -151,7 +187,14 @@ fn test_minimumcapacitatedspanningtree_to_ilp_path_graph() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(6))); assert!(problem.is_valid_solution(&extracted).unwrap()); } @@ -167,5 +210,8 @@ fn test_zero_requirement_vertex_still_must_be_connected() { ); let reduction: ReductionMinimumCapacitatedSpanningTreeToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } diff --git a/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs b/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs index 8bdc090ed..ae1cdad3c 100644 --- a/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs +++ b/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::DirectedGraph; use crate::traits::Problem; use crate::types::Min; @@ -82,7 +83,14 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_bottleneck() { // value 1 and cost 1 (the cheaper 1->3 path). let solver = BruteForce::new(); let target_witness = solver.solve(reduction.target_problem()).unwrap().unwrap(); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.flow_value(&extracted).unwrap(), 1); assert_eq!(source.total_cost(&extracted).unwrap(), 1); } @@ -117,7 +125,14 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_parallel_arcs() { // parallel arc has cost 1, so optimal source cost = 1. let solver = BruteForce::new(); let target_witness = solver.solve(target).unwrap().unwrap(); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.flow_value(&extracted).unwrap(), 1); assert_eq!(source.total_cost(&extracted).unwrap(), 1); } @@ -167,7 +182,14 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_zero_capacity_arc() { let solver = BruteForce::new(); let target_witness = solver.solve(target).unwrap().unwrap(); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.flow_value(&extracted).unwrap(), 1); // Zero-capacity arc must be 0 in the extracted flow. assert_eq!(extracted[2], 0); @@ -221,7 +243,14 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_value_priority_over_cos let solver = BruteForce::new(); let target_witness = solver.solve(reduction.target_problem()).unwrap().unwrap(); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.flow_value(&extracted).unwrap(), 2); assert_eq!(source.total_cost(&extracted).unwrap(), 20); @@ -239,14 +268,17 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_extract_solution_length let source = canonical_source(); let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - // Provide a dummy target config of the right length; extract_solution - // must truncate to num_original_arcs. + // A value-3 flow closes through the added sink-to-source return arc. let m = source.num_arcs(); - let mut padded = vec![0_usize; m + 1]; - for (i, v) in padded.iter_mut().enumerate().take(m) { - *v = i % 2; - } - let extracted = reduction.extract_solution(&padded).unwrap(); + let padded = vec![2_usize, 1, 1, 1, 2, 3]; + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), padded.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), m); assert_eq!(extracted, padded[..m].to_vec()); } diff --git a/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs b/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs index 4e322beef..c594952b3 100644 --- a/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs +++ b/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MinimumCoveringByCliques; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -33,7 +34,14 @@ fn test_minimumcoveringbycliques_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(2))); assert_eq!(source.evaluate(&extracted).unwrap(), bf_value); @@ -49,7 +57,14 @@ fn test_minimumcoveringbycliques_to_ilp_empty_graph() { assert_eq!(ilp.num_vars(), 0); assert_eq!(ilp.constraints().len(), 0); assert_eq!( - reduction.extract_solution(&vec![]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); assert_eq!(source.evaluate(&vec![]).unwrap(), Min(Some(0))); diff --git a/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index 0737f7f5d..abdfa01ea 100644 --- a/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -1,5 +1,6 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; +use crate::solvers::SolveOutcome; use crate::topology::Graph; use crate::traits::Problem; use crate::types::Min; @@ -42,7 +43,14 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_issue_example_ assert_eq!(target.evaluate(&target_solution).unwrap(), Min(Some(2))); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 0, 1]); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(2))); @@ -60,14 +68,6 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_invalid_target target.evaluate(&invalid_target_solution).unwrap(), Min(None) ); - - let error = reduction - .extract_solution(&invalid_target_solution) - .unwrap_err(); - assert_eq!( - error.to_string(), - "target configuration is not a valid intersection graph basis" - ); } #[test] @@ -80,7 +80,14 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_empty_graph() let target_solution = vec![vec![], vec![], vec![]]; assert_eq!(target.evaluate(&target_solution).unwrap(), Min(Some(0))); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); assert_eq!(source.evaluate(&vec![]).unwrap(), Min(Some(0))); diff --git a/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs b/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs index 1f31f58a5..c64ae0e96 100644 --- a/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs +++ b/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::ILP; use crate::models::graph::MinimumCutIntoBoundedSets; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -41,7 +42,14 @@ fn test_extract_solution() { let reduction: ReductionMinCutBSToILP = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target_sol = vec![0, 0, 1, 1, 0, 1, 0]; - let extracted = reduction.extract_solution(&target_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false, true, true]); assert!(source.evaluate(&extracted).unwrap().0.is_some()); } diff --git a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs index fe188e749..71725313f 100644 --- a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; use std::f64::consts::{FRAC_PI_2, PI}; @@ -48,7 +49,15 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_single_link() { assert_eq!(reduction.target_problem().num_vars(), 3); assert_eq!(qubo_solutions.len(), 1); assert_eq!( - reduction.extract_solution(&qubo_solutions[0]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), qubo_solutions[0].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![1] ); assert!(matches!(source.evaluate(&vec![1]).unwrap(), Min(Some(v)) if v.abs() < EPS)); @@ -72,7 +81,15 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_single_sample_per_link() assert_eq!(reduction.target_problem().num_vars(), 3); assert_eq!(qubo_solutions, vec![vec![true, true, true]]); assert_eq!( - reduction.extract_solution(&qubo_solutions[0]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), qubo_solutions[0].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![0, 0, 0] ); assert!(matches!(source.evaluate(&vec![0, 0, 0]).unwrap(), Min(Some(v)) if v.abs() < EPS)); @@ -96,8 +113,11 @@ 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)); + let value = reduction + .target_problem() + .evaluate(&target_solution) + .unwrap(); + assert_eq!(reduction.map_value(value), Min(None)); } } @@ -125,3 +145,189 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_canonical_example_spec() serde_json::json!([true, false, false, true]) ); } + +#[test] +fn optimum_energy_recovers_distance_and_infeasibility() { + for source in [ + worked_example(), + MinimumDiscretePlanarInverseKinematics::new( + vec![1.0, 1.0, 1.0], + (0.0, 0.0), + vec![vec![0.0, PI]; 3], + vec![vec![(0, 0)], vec![(1, 0)]], + ) + .unwrap(), + ] { + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let entry = inventory::iter:: + .into_iter() + .find(|entry| { + entry.source_name == "MinimumDiscretePlanarInverseKinematics" + && entry.target_name == "QUBO" + }) + .unwrap(); + let chain = + crate::rules::ReductionChain::execute(&source, &[entry.reduce_fn.unwrap()]).unwrap(); + + let solver = BruteForce::new(); + let expected = solver + .solve(&source) + .unwrap() + .map(|solution| source.evaluate(&solution).unwrap().0.unwrap()); + for solution in solver + .find_all_witnesses(reduction.target_problem()) + .unwrap() + { + let completed = chain + .recover_result_json( + &source, + serde_json::json!({"status": "optimal", "solution": solution}), + ) + .unwrap() + .0; + assert_eq!( + matches!(completed, SolveOutcome::Optimal { .. }), + expected.is_some() + ); + let recovered = reduction + .map_value(reduction.target_problem().evaluate(&solution).unwrap()) + .0; + match (expected, recovered) { + (Some(expected), Some(actual)) => { + assert!((actual - expected).abs() < EPS); + assert_eq!( + source + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + solution.clone() + ) + .unwrap() + ) + .map(|result| result.into_solution().expect( + "qualifying target result must recover a source solution" + )) + .unwrap() + ) + .unwrap(), + Min(Some(expected)) + ); + } + (None, None) => {} + other => panic!("source and recovered outcomes disagree: {other:?}"), + } + } + assert_eq!(reduction.map_value(Min(None)), Min(None)); + } +} + +#[test] +fn nonfinite_energy_relation_is_a_construction_error() { + let source = MinimumDiscretePlanarInverseKinematics::new( + vec![1e200], + (0.0, 0.0), + vec![vec![0.0]], + vec![], + ) + .unwrap(); + assert!(matches!( + ReduceTo::>::reduce_to(&source), + Err(crate::rules::ReductionError::NonFiniteResult { .. }) + )); +} + +/// Decode three two-sample one-hot blocks, or `None` when a block is not one-hot. +fn decode_one_hot_pairs(assignment: &[bool]) -> Option> { + assignment + .chunks(2) + .map(|block| match block { + [true, false] => Some(0), + [false, true] => Some(1), + _ => None, + }) + .collect() +} + +#[test] +fn test_minimumdiscreteplanarinversekinematics_to_qubo_feasible_target_incumbents() { + // Three unit links pointing right or left; the second joint forbids (left, right). + let source = MinimumDiscretePlanarInverseKinematics::new( + vec![1.0, 1.0, 1.0], + (1.0, 0.0), + vec![vec![0.0, PI]; 3], + vec![ + vec![(0, 0), (0, 1), (1, 0), (1, 1)], + vec![(0, 0), (0, 1), (1, 1)], + ], + ) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let qubo = reduction.target_problem(); + assert_eq!(qubo.num_vars(), 6); + + let mut suboptimal = 0; + let mut rejected = 0; + for bits in 0..1usize << 6 { + let candidate: Vec = (0..6).map(|i| bits & (1 << i) != 0).collect(); + let expected = decode_one_hot_pairs(&candidate) + .map(|config| (source.evaluate(&config).unwrap(), config)) + .filter(|(evaluation, _)| evaluation.0.is_some()); + let recovered = + reduction.recover_result(&source, SolveOutcome::feasible(qubo, candidate).unwrap()); + match expected { + Some((evaluation, solution)) => { + // (right, right, right) ends at (3, 0), squared distance 4 from the goal. + suboptimal += usize::from(matches!(evaluation, Min(Some(v)) if v > 1.0)); + assert_eq!( + recovered, + Ok(SolveOutcome::Feasible { + solution, + evaluation, + }) + ); + } + None => { + rejected += 1; + assert_eq!( + recovered, + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); + } + } + } + // Six of the eight one-hot assignments satisfy the transition constraints. + assert_eq!(rejected, 64 - 6); + assert!(suboptimal > 0); +} + +#[test] +fn test_minimumdiscreteplanarinversekinematics_to_qubo_infeasible_source_recovers_infeasible() { + // The middle link would need sample 0 and sample 1 at once. + let source = MinimumDiscretePlanarInverseKinematics::new( + vec![1.0, 1.0, 1.0], + (0.0, 0.0), + vec![vec![0.0, PI]; 3], + vec![vec![(0, 0)], vec![(1, 0)]], + ) + .unwrap(); + assert_eq!(BruteForce::new().solve(&source).unwrap(), None); + + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let qubo = reduction.target_problem(); + let optimum = BruteForce::new().solve(qubo).unwrap().unwrap(); + assert_eq!( + reduction.recover_result( + &source, + SolveOutcome::optimal(qubo, optimum.clone()).unwrap() + ), + Ok(SolveOutcome::Infeasible) + ); + // The same assignment without an optimality proof establishes nothing. + assert_eq!( + reduction.recover_result(&source, SolveOutcome::feasible(qubo, optimum).unwrap()), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); +} diff --git a/src/unit_tests/rules/minimumdominatingset_ilp.rs b/src/unit_tests/rules/minimumdominatingset_ilp.rs index deddfa504..fc73ede17 100644 --- a/src/unit_tests/rules/minimumdominatingset_ilp.rs +++ b/src/unit_tests/rules/minimumdominatingset_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -66,7 +67,14 @@ fn test_minimumdominatingset_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); // Both should find optimal size = 1 (just the center) @@ -100,7 +108,14 @@ fn test_ilp_solution_equals_brute_force_path() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_size, Min(Some(2))); @@ -129,7 +144,14 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_obj, Min(Some(3))); @@ -148,7 +170,14 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true, false]); // Verify this is a valid DS (0 dominates 0,1 and 2 dominates 2,3) @@ -179,7 +208,14 @@ fn test_isolated_vertices() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Vertex 2 must be selected (isolated) assert!(extracted[2]); @@ -200,7 +236,14 @@ fn test_complete_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); @@ -216,7 +259,14 @@ fn test_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true]); @@ -243,7 +293,14 @@ fn test_cycle_graph() { let bf_size = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_size, ilp_size); diff --git a/src/unit_tests/rules/minimumedgecostflow_ilp.rs b/src/unit_tests/rules/minimumedgecostflow_ilp.rs index 683d75059..0738a4fa1 100644 --- a/src/unit_tests/rules/minimumedgecostflow_ilp.rs +++ b/src/unit_tests/rules/minimumedgecostflow_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -78,7 +79,14 @@ fn test_minimumedgecostflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(ilp_value, bf_value); @@ -100,7 +108,14 @@ fn test_minimumedgecostflow_to_ilp_small_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_value); } @@ -109,8 +124,9 @@ fn test_minimumedgecostflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMECFToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible instance should produce infeasible ILP" ); } @@ -141,7 +157,14 @@ fn test_minimumedgecostflow_to_ilp_extract_solution() { target_solution[10] = 1; // y on arc (2,4) target_solution[11] = 1; // y on arc (3,4) - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 6); assert_eq!(extracted, vec![0, 1, 2, 0, 1, 2]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(3))); diff --git a/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs b/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs index 2892f3b53..3dd3982da 100644 --- a/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -14,7 +15,14 @@ fn test_emdc_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!(value.is_valid(), "Extracted solution should be valid"); assert_eq!(value, Min(Some(2))); @@ -33,7 +41,14 @@ fn test_emdc_to_ilp_compression_wins() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!(value.is_valid(), "Extracted solution should be valid"); assert_eq!(value, Min(Some(12))); @@ -78,7 +93,14 @@ fn test_emdc_to_ilp_empty() { assert!(ilp.constraints().is_empty()); // For empty ILP, the solution is empty - let extracted = reduction.extract_solution(&vec![]).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert_eq!(value, Min(Some(0))); } @@ -102,7 +124,14 @@ fn test_emdc_to_ilp_single_char() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!(value.is_valid()); assert_eq!(value, Min(Some(1))); @@ -121,7 +150,14 @@ fn test_emdc_to_ilp_repeated_string() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!(value.is_valid()); assert_eq!(value, Min(Some(3))); diff --git a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs index f2400cdfc..ff3744cac 100644 --- a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs +++ b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs @@ -3,6 +3,7 @@ use crate::models::algebraic::{Comparison, ObjectiveSense}; use crate::models::misc::MinimumFaultDetectionTestSet; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -61,7 +62,14 @@ fn test_minimumfaultdetectiontestset_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![vec![true, false], vec![false, true]]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(2))); @@ -83,7 +91,10 @@ fn test_reduction_is_infeasible_when_an_internal_vertex_has_no_covering_pair() { assert_eq!(problem.evaluate(&vec![vec![false]]).unwrap(), Min(None)); assert_eq!(problem.evaluate(&vec![vec![true]]).unwrap(), Min(None)); - assert!(ILPSolver::new().solve(ilp).is_err()); + assert_eq!( + ILPSolver::new().solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -99,7 +110,14 @@ fn test_reduction_handles_instances_without_internal_vertices() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("ILP should be feasible when there are no internal vertices"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![vec![false]]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(0))); diff --git a/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs b/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs index 09413a71b..0296ef6f9 100644 --- a/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs +++ b/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -44,7 +45,14 @@ fn test_minimumfeedbackarcset_to_ilp_bf_vs_ilp() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); // Both should find optimal value = 1 @@ -62,7 +70,14 @@ fn test_solution_extraction() { // Simulate ILP solution: y_0=0, y_1=0, y_2=1, o_0=0, o_1=1, o_2=2 let ilp_solution = vec![0, 0, 1, 0, 1, 2]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false, true]); // Verify this is a valid FAS (removing arc 2->0 breaks the 3-cycle) @@ -87,7 +102,14 @@ fn test_minimumfeedbackarcset_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert_eq!(value, Min(Some(0)), "DAG needs no arc removal"); diff --git a/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs b/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs index 8b47862ae..75eb03c42 100644 --- a/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs +++ b/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -39,7 +40,14 @@ fn test_minimumfeedbackvertexset_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); // Both should find optimal size = 1 @@ -89,7 +97,14 @@ fn test_cycle_of_triangles() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let size = problem.evaluate(&extracted).unwrap(); assert_eq!(size, Min(Some(3)), "FVS should be 3"); @@ -106,7 +121,14 @@ fn test_dag_no_removal() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let size = problem.evaluate(&extracted).unwrap(); assert_eq!(size, Min(Some(0)), "DAG needs no removal"); @@ -128,7 +150,14 @@ fn test_single_vertex() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(0))); @@ -153,7 +182,14 @@ fn test_weighted() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should remove vertex 1 (cheapest) assert!(extracted[1], "Should remove vertex true (cheapest)"); @@ -176,7 +212,14 @@ fn test_two_disjoint_cycles() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_size, Min(Some(2))); @@ -193,7 +236,14 @@ fn test_solution_extraction() { // Simulate ILP solution: x_0=1, x_1=0, x_2=0, o_0=0, o_1=0, o_2=1 let ilp_solution = vec![1, 0, 0, 0, 0, 1]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, false]); // Verify this is a valid FVS (removing vertex 0 breaks the 3-cycle) diff --git a/src/unit_tests/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs b/src/unit_tests/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs index 3712e5c42..091816d56 100644 --- a/src/unit_tests/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs +++ b/src/unit_tests/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs @@ -2,6 +2,9 @@ use super::{issue_example_source, ReductionFVSToCodeGen}; use crate::models::misc::MinimumCodeGenerationUnlimitedRegisters; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::traits::Problem; #[test] fn test_minimumfeedbackvertexset_to_minimumcodegenerationunlimitedregisters_closed_loop() { @@ -48,7 +51,14 @@ fn test_codegen_start_nodes_cover_self_loops_and_parallel_arcs() { assert_eq!(target.num_internal(), 7); let config = (0..7).collect(); assert_eq!(target.evaluate(&config).unwrap(), Min(Some(9))); - let removed = reduction.extract_solution(&config).unwrap(); + let removed = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(removed, vec![true, false, true]); assert_eq!(source.evaluate(&removed).unwrap(), Min(Some(2))); } @@ -63,14 +73,30 @@ fn test_codegen_empty_graph_and_invalid_orders() { let reduction = ReduceTo::::reduce_to(&empty).unwrap(); assert_eq!(reduction.target_problem().num_vertices(), 1); assert_eq!( - reduction.extract_solution(&vec![]).unwrap(), + reduction + .recover_result( + &empty, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); let source = issue_example_source(); let reduction = ReduceTo::::reduce_to(&source).unwrap(); - for config in [vec![], vec![9; 6], vec![0; 6], vec![1, 0, 2, 3, 4, 5]] { - assert!(reduction.extract_solution(&config).is_err()); + for config in [vec![], vec![9; 6]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&config), + Err(InvalidConfiguration(_)) + )); + } + for config in [vec![0; 6], vec![1, 0, 2, 3, 4, 5]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&config) + .unwrap() + .is_valid()); } } @@ -122,7 +148,18 @@ fn test_codegen_every_small_evaluation_permutation() { &mut |p| { let config = p.to_vec(); if let Min(Some(cost)) = target.evaluate(&config).unwrap() { - let removed = reduction.extract_solution(&config).unwrap(); + let removed = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()) + .unwrap(), + ) + .map(|result| { + result.into_solution().expect( + "qualifying target result must recover a source solution", + ) + }) + .unwrap(); let Min(Some(size)) = source.evaluate(&removed).unwrap() else { panic!("every valid target order must extract an FVS"); }; diff --git a/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs b/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs index 9eec5a8d3..6cd90ea31 100644 --- a/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs +++ b/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -35,7 +36,14 @@ fn test_minimumgraphbandwidth_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!( ilp_value.0.is_some(), @@ -61,7 +69,14 @@ fn test_minimumgraphbandwidth_to_ilp_path() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert_eq!( value, diff --git a/src/unit_tests/rules/minimumhittingset_ilp.rs b/src/unit_tests/rules/minimumhittingset_ilp.rs index aa15bc9d6..ae1cc416f 100644 --- a/src/unit_tests/rules/minimumhittingset_ilp.rs +++ b/src/unit_tests/rules/minimumhittingset_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -24,7 +25,14 @@ fn test_minimumhittingset_to_ilp_bf_vs_ilp() { let bf_solutions = bf.find_all_witnesses(&problem).unwrap(); let bf_value = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); assert!(ilp_value.is_valid()); @@ -36,7 +44,14 @@ fn test_solution_extraction() { let reduction: ReductionHSToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = vec![0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, false]); assert!(problem.evaluate(&extracted).unwrap().is_valid()); } diff --git a/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs index 09908a2b9..473bbf45e 100644 --- a/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs @@ -1,6 +1,7 @@ use crate::models::algebraic::ILP; use crate::models::misc::MinimumInternalMacroDataCompression; use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -15,7 +16,14 @@ fn test_imdc_to_ilp_closed_loop_simple() { let solver = ILPSolver::new(); let target_witness = solver.solve(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let val = source.evaluate(&source_config).unwrap(); assert!(val.0.is_some()); assert_eq!(val.0.unwrap(), 2); @@ -31,7 +39,14 @@ fn test_imdc_to_ilp_closed_loop_repeated() { let solver = ILPSolver::new(); let target_witness = solver.solve(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let val = source.evaluate(&source_config).unwrap(); assert!(val.0.is_some()); assert_eq!(val.0.unwrap(), 4); @@ -48,7 +63,14 @@ fn test_imdc_to_ilp_closed_loop_low_pointer_cost() { let solver = ILPSolver::new(); let target_witness = solver.solve(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let val = source.evaluate(&source_config).unwrap(); assert!(val.0.is_some()); // Verify against brute force @@ -63,7 +85,14 @@ fn test_imdc_to_ilp_empty_string() { let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let target = reduction.target_problem(); assert_eq!(target.num_variables(), 0); - let source_config = reduction.extract_solution(&vec![]).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&source_config).unwrap(), Min(Some(0))); } @@ -77,7 +106,14 @@ fn test_imdc_to_ilp_single_char() { let solver = ILPSolver::new(); let target_witness = solver.solve(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&source_config).unwrap(), Min(Some(1))); } @@ -113,7 +149,14 @@ fn test_imdc_to_ilp_vs_brute_force() { let target_witness = ILPSolver::new() .solve(target) .expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_val = source.evaluate(&source_config).unwrap(); assert_eq!( diff --git a/src/unit_tests/rules/minimummatrixcover_ilp.rs b/src/unit_tests/rules/minimummatrixcover_ilp.rs index da371feef..345b86a2e 100644 --- a/src/unit_tests/rules/minimummatrixcover_ilp.rs +++ b/src/unit_tests/rules/minimummatrixcover_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::MinimumMatrixCover; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -13,7 +14,8 @@ fn test_minimum_matrix_cover_to_ilp_closed_loop() { vec![3, 0, 0, 2], vec![1, 0, 0, 4], vec![0, 2, 4, 0], - ]); + ]) + .unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); assert_bf_vs_ilp(&problem, &reduction); @@ -21,14 +23,21 @@ fn test_minimum_matrix_cover_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert_eq!(value, Min(Some(-20))); } #[test] fn test_minimum_matrix_cover_to_ilp_structure() { - let problem = MinimumMatrixCover::new(vec![vec![0, 3], vec![2, 0]]); + let problem = MinimumMatrixCover::new(vec![vec![0, 3], vec![2, 0]]).unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); @@ -54,7 +63,8 @@ fn test_minimum_matrix_cover_to_ilp_bf_vs_ilp() { vec![3, 0, 0, 2], vec![1, 0, 0, 4], vec![0, 2, 4, 0], - ]); + ]) + .unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let bf_value_solution = BruteForce::new().solve(&problem).unwrap().unwrap(); @@ -64,7 +74,14 @@ fn test_minimum_matrix_cover_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -72,13 +89,20 @@ fn test_minimum_matrix_cover_to_ilp_bf_vs_ilp() { #[test] fn test_minimum_matrix_cover_to_ilp_2x2() { - let problem = MinimumMatrixCover::new(vec![vec![0, 3], vec![2, 0]]); + let problem = MinimumMatrixCover::new(vec![vec![0, 3], vec![2, 0]]).unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); // Optimal: different signs → value = -(3+2) = -5 assert_eq!(value, Min(Some(-5))); @@ -86,7 +110,7 @@ fn test_minimum_matrix_cover_to_ilp_2x2() { #[test] fn test_minimum_matrix_cover_to_ilp_1x1() { - let problem = MinimumMatrixCover::new(vec![vec![5]]); + let problem = MinimumMatrixCover::new(vec![vec![5]]).unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); @@ -100,7 +124,14 @@ fn test_minimum_matrix_cover_to_ilp_1x1() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("1x1 ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(5))); } @@ -108,13 +139,21 @@ fn test_minimum_matrix_cover_to_ilp_1x1() { fn test_minimum_matrix_cover_to_ilp_diagonal_matrix() { // Diagonal matrix: all off-diagonal entries are 0 // Value is always Σ a_ii (constant), since f(i)²=1 - let problem = MinimumMatrixCover::new(vec![vec![2, 0, 0], vec![0, 3, 0], vec![0, 0, 1]]); + let problem = + MinimumMatrixCover::new(vec![vec![2, 0, 0], vec![0, 3, 0], vec![0, 0, 1]]).unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("diagonal ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // All configs give value 2+3+1 = 6 assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(6))); } @@ -122,7 +161,7 @@ fn test_minimum_matrix_cover_to_ilp_diagonal_matrix() { #[test] fn test_minimum_matrix_cover_to_ilp_asymmetric() { // Non-symmetric matrix - let problem = MinimumMatrixCover::new(vec![vec![0, 5], vec![1, 0]]); + let problem = MinimumMatrixCover::new(vec![vec![0, 5], vec![1, 0]]).unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let bf_value_solution = BruteForce::new().solve(&problem).unwrap().unwrap(); @@ -132,7 +171,14 @@ fn test_minimum_matrix_cover_to_ilp_asymmetric() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); diff --git a/src/unit_tests/rules/minimummaximalmatching_ilp.rs b/src/unit_tests/rules/minimummaximalmatching_ilp.rs index 342a422fd..8beed4e5d 100644 --- a/src/unit_tests/rules/minimummaximalmatching_ilp.rs +++ b/src/unit_tests/rules/minimummaximalmatching_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -36,7 +37,14 @@ fn test_minimummaximalmatching_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, Min(Some(1))); @@ -58,7 +66,14 @@ fn test_minimummaximalmatching_to_ilp_path_p6() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(2))); } @@ -74,7 +89,14 @@ fn test_minimummaximalmatching_to_ilp_triangle() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); assert!(problem.evaluate(&extracted).unwrap().is_valid()); diff --git a/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs b/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs index 3a7f521c6..b40c7e47c 100644 --- a/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs +++ b/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs @@ -1,6 +1,7 @@ use crate::models::graph::{MaximumAchromaticNumber, MinimumMaximalMatching}; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{BipartiteGraph, Graph, SimpleGraph}; use crate::traits::Problem; use crate::types::{Max, Min}; @@ -57,7 +58,14 @@ fn test_minimummaximalmatching_to_maximumachromaticnumber_closed_loop() { "complement(T-tree) must admit an achromatic 4-coloring" ); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (witness).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( source.evaluate(&extracted).unwrap(), Min(Some(1)), @@ -94,7 +102,14 @@ fn test_extract_solution_known_coloring() { // The single size-2 class {v2, v1} is the G-edge (v1, v2) = // unified edge (1, 3), source-edge index 1 in the edges list. let coloring = vec![1, 0, 3, 0, 2]; - let extracted = reduction.extract_solution(&coloring).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), coloring.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, false, false]); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(1))); } @@ -115,14 +130,28 @@ fn test_extract_solution_recovers_suboptimal_matchings() { // Source edges in unified order: (0,3), (1,3), (1,4), (2,3). // Edge 0 = (v0, v1) selected; edge 2 = (v2, v3) selected. let coloring_a = vec![0, 1, 2, 0, 1]; - let extracted_a = reduction.extract_solution(&coloring_a).unwrap(); + let extracted_a = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), coloring_a.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted_a, vec![true, false, true, false]); assert_eq!(source.evaluate(&extracted_a).unwrap(), Min(Some(2))); // Suboptimal matching {(v1, v4), (v2, v3)} -> pair v1 with v4 and v2 // with v3; v0 takes a singleton color. Edge 2 = (v2, v3); edge 3 = (v1, v4). let coloring_b = vec![2, 0, 1, 1, 0]; - let extracted_b = reduction.extract_solution(&coloring_b).unwrap(); + let extracted_b = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), coloring_b.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted_b, vec![false, false, true, true]); assert_eq!(source.evaluate(&extracted_b).unwrap(), Min(Some(2))); } diff --git a/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs b/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs index 9d0a6ab52..f4fe1ee03 100644 --- a/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs +++ b/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs @@ -2,6 +2,7 @@ use crate::models::algebraic::MinimumMatrixDomination; use crate::models::graph::MinimumMaximalMatching; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{BipartiteGraph, Graph}; use crate::traits::Problem; use crate::types::Min; @@ -61,7 +62,14 @@ fn test_minimummaximalmatching_to_minimummatrixdomination_closed_loop() { "matrix domination has at least one optimum" ); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (witness).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( source.evaluate(&extracted).unwrap(), Min(Some(2)), @@ -114,7 +122,14 @@ fn test_extract_solution_returns_maximal_matching() { .solve(target) .unwrap() .expect("matrix domination has an optimum"); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // The result must be a valid maximal matching of the source graph and // realize mm(B) = 2. @@ -190,7 +205,14 @@ fn test_extract_solution_yg_transform_on_non_matching_eds() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&target_witness).unwrap(), Min(Some(2))); - let extracted = reduction.extract_solution(&target_witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // The extracted configuration must be a valid maximal matching of B of // size 2 (= mm(B)). Crucially it cannot be {(l0, r1), (l0, r2)} because diff --git a/src/unit_tests/rules/minimummetricdimension_ilp.rs b/src/unit_tests/rules/minimummetricdimension_ilp.rs index 431e64235..3338541bf 100644 --- a/src/unit_tests/rules/minimummetricdimension_ilp.rs +++ b/src/unit_tests/rules/minimummetricdimension_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -23,7 +24,14 @@ fn test_minimummetricdimension_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); // Both should find optimal size = 2 @@ -84,7 +92,14 @@ fn test_minimummetricdimension_to_ilp_path_graph() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); @@ -108,7 +123,14 @@ fn test_minimummetricdimension_to_ilp_complete_graph() { let bf_size = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_size = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_size, Min(Some(3))); @@ -123,7 +145,14 @@ fn test_minimummetricdimension_to_ilp_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 0, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, false]); // Verify this is a valid resolving set @@ -143,7 +172,14 @@ fn test_minimummetricdimension_to_ilp_cycle() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(2))); diff --git a/src/unit_tests/rules/minimummultiwaycut_ilp.rs b/src/unit_tests/rules/minimummultiwaycut_ilp.rs index 95d63b69d..03e44cd25 100644 --- a/src/unit_tests/rules/minimummultiwaycut_ilp.rs +++ b/src/unit_tests/rules/minimummultiwaycut_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::ObjectiveSense; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -44,7 +45,14 @@ fn test_minimummultiwaycut_to_ilp_closed_loop() { // Solve via ILP let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = problem.evaluate(&extracted).unwrap(); // Optimal cut cost is 8 @@ -66,7 +74,14 @@ fn test_triangle_with_3_terminals() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let obj = problem.evaluate(&extracted).unwrap(); assert_eq!(obj, Min(Some(6))); @@ -85,7 +100,14 @@ fn test_two_terminals() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let obj = problem.evaluate(&extracted).unwrap(); assert_eq!(obj, Min(Some(1))); @@ -123,7 +145,14 @@ fn test_solution_extraction() { ilp_solution[15 + 3] = 1; // edge (3,4) cut ilp_solution[15 + 4] = 1; // edge (0,4) cut - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, false, true, true, false]); let obj = problem.evaluate(&extracted).unwrap(); diff --git a/src/unit_tests/rules/minimummultiwaycut_qubo.rs b/src/unit_tests/rules/minimummultiwaycut_qubo.rs index b300fa285..ca70e62ea 100644 --- a/src/unit_tests/rules/minimummultiwaycut_qubo.rs +++ b/src/unit_tests/rules/minimummultiwaycut_qubo.rs @@ -1,9 +1,59 @@ use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; +#[test] +fn signed_cut_weights_preserve_every_target_optimum() { + let solver = BruteForce::new(); + for weights in [ + vec![-1, -1, -1], + vec![-3, 2, 1], + vec![0, -1, 2], + vec![i64::MIN, 0, 0], + ] { + let source = MinimumMultiwayCut::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + vec![0, 1], + weights, + ); + let optimum = (0..8) + .filter_map(|bits| { + source + .evaluate(&(0..3).map(|i| bits & (1 << i) != 0).collect()) + .unwrap() + .0 + }) + .min() + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let solutions = solver + .find_all_witnesses(reduction.target_problem()) + .unwrap(); + assert!(!solutions.is_empty()); + for solution in solutions { + assert_eq!( + source + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) + .unwrap(), + Min(Some(optimum)) + ); + } + } +} + #[test] fn test_minimummultiwaycut_to_qubo_closed_loop() { // 5 vertices, terminals {0,2,4}, 6 edges with weights [2,3,1,2,4,5] @@ -20,7 +70,14 @@ fn test_minimummultiwaycut_to_qubo_closed_loop() { // All QUBO optimal solutions should extract to valid source solutions with cost 8 for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = source.evaluate(&extracted).unwrap(); assert_eq!(metric, Min(Some(8))); } @@ -42,7 +99,14 @@ fn test_minimummultiwaycut_to_qubo_small() { // All solutions should extract to valid cuts for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = source.evaluate(&extracted).unwrap(); // With 2 terminals and path 0-1-2, minimum cut is 1 (cut either edge) assert_eq!(metric, Min(Some(1))); @@ -56,7 +120,7 @@ fn test_minimummultiwaycut_to_qubo_sizes() { let source = MinimumMultiwayCut::new(graph, vec![0, 2, 4], vec![2, 3, 1, 2, 4, 5]); let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); - assert_eq!(reduction.target_problem().num_variables(), 15); + assert_eq!(reduction.target_problem().num_variables().unwrap(), 15); } #[test] @@ -97,3 +161,24 @@ fn test_minimummultiwaycut_to_qubo_terminal_pinning() { } } } + +#[test] +fn test_minimummultiwaycut_to_qubo_rejects_feasible_target_incumbent() { + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (0, 4), (1, 3)]); + let source = MinimumMultiwayCut::new(graph, vec![0, 2, 4], vec![2, 3, 1, 2, 4, 5]); + let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); + let optimum = BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .unwrap(); + + // One-hot assignment 0,1 -> terminal 0; 2 -> terminal 2; 3,4 -> terminal 4. + // It cuts edges (1,2), (2,3), (0,4), (1,3) for cost 13; the optimum is 8. + let mut candidate = vec![false; 15]; + for (vertex, component) in [0, 0, 1, 2, 2].into_iter().enumerate() { + candidate[vertex * 3 + component] = true; + } + crate::rules::test_helpers::assert_suboptimal_feasible_target_is_insufficient( + &source, &reduction, candidate, &optimum, + ); +} diff --git a/src/unit_tests/rules/minimumsetcovering_ilp.rs b/src/unit_tests/rules/minimumsetcovering_ilp.rs index ab5a48864..915c2c57f 100644 --- a/src/unit_tests/rules/minimumsetcovering_ilp.rs +++ b/src/unit_tests/rules/minimumsetcovering_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -58,7 +59,14 @@ fn test_minimumsetcovering_to_ilp_closed_loop() { // Solve via ILP reduction let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Both should find optimal size = 2 let bf_size: usize = bf_solutions[0].iter().filter(|&&selected| selected).count(); @@ -95,7 +103,14 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_obj = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_obj, Min(Some(6))); @@ -113,7 +128,14 @@ fn test_solution_extraction() { // Test that extraction works correctly (1:1 mapping) let ilp_solution = vec![1, 1]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true]); // Verify this is a valid set cover @@ -142,7 +164,14 @@ fn test_single_set_covers_all() { let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // First set alone covers everything with weight 1 assert_eq!(extracted, vec![true, false, false, false]); @@ -162,7 +191,14 @@ fn test_overlapping_sets() { let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Need both sets to cover all elements assert_eq!(extracted, vec![true, true]); diff --git a/src/unit_tests/rules/minimumsummulticenter_ilp.rs b/src/unit_tests/rules/minimumsummulticenter_ilp.rs index a6d254c93..829290368 100644 --- a/src/unit_tests/rules/minimumsummulticenter_ilp.rs +++ b/src/unit_tests/rules/minimumsummulticenter_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MinimumSumMulticenter; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -50,7 +51,14 @@ fn test_minimumsummulticenter_to_ilp_bf_vs_ilp() { let bf_cost = problem.evaluate(&bf_witness).unwrap().unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted.len(), 3, @@ -91,7 +99,14 @@ fn test_minimumsummulticenter_to_ilp_respects_weighted_shortest_paths() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted, bf_witness, @@ -120,7 +135,14 @@ fn test_solution_extraction() { 0, 1, 0, // y_{1,0}, y_{1,1}, y_{1,2} 0, 1, 0, // y_{2,0}, y_{2,1}, y_{2,2} ]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, false]); assert_eq!(problem.evaluate(&extracted).unwrap().unwrap(), 2); } @@ -139,7 +161,14 @@ fn test_minimumsummulticenter_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 1); assert_eq!(extracted, vec![true]); assert_eq!(problem.evaluate(&extracted).unwrap().unwrap(), 0); diff --git a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs index 7f8da1782..5bd5f3670 100644 --- a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs +++ b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::One; @@ -27,7 +28,14 @@ fn test_minimumtardinesssequencing_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -42,7 +50,14 @@ fn test_minimumtardinesssequencing_to_ilp_no_precedences() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); } @@ -54,7 +69,14 @@ fn test_minimumtardinesssequencing_to_ilp_all_tight() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!(value.is_valid()); assert_eq!(value.0, Some(2)); @@ -87,7 +109,14 @@ fn test_minimumtardinesssequencing_weighted_to_ilp_vs_brute_force() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); diff --git a/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs b/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs index 4786f0a13..25501c28c 100644 --- a/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs @@ -1,6 +1,9 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; #[test] @@ -22,7 +25,17 @@ fn test_minimumvertexcover_to_comparativecontainment_closed_loop() { .unwrap(); assert!( source - .evaluate(&reduction.extract_solution(&witness).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); @@ -85,9 +98,27 @@ fn test_signed_containment_all_small_graphs_and_witnesses() { let valid = source.evaluate(&witness).unwrap().0; assert_eq!(target.evaluate(&witness).unwrap().0, valid); if valid { - assert_eq!(reduction.extract_solution(&witness).unwrap(), witness); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + witness.clone() + ) + .unwrap() + ) + .map(|result| result.into_solution().expect( + "qualifying target result must recover a source solution" + )) + .unwrap(), + witness + ); } else { - assert!(reduction.extract_solution(&witness).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() + .is_valid()); } } } @@ -107,9 +138,22 @@ fn test_signed_containment_duplicate_edges_and_invalid_length() { ); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let witness = vec![true, false, false]; - assert_eq!(reduction.extract_solution(&witness).unwrap(), witness); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + witness + ); for bad in [vec![], vec![true; 4]] { - assert!(reduction.extract_solution(&bad).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&bad), + Err(InvalidConfiguration(_)) + )); } } diff --git a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs index b44823425..8a5d8f63d 100644 --- a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs @@ -3,7 +3,9 @@ use crate::models::misc::EnsembleComputation; use crate::rules::traits::ReduceTo; use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::{Min, One}; @@ -41,7 +43,14 @@ fn test_minimumvertexcover_to_ensemblecomputation_closed_loop() { // Every extracted solution must be a valid vertex cover let witnesses = solver.find_all_witnesses(target).unwrap(); for witness in &witnesses { - let source_config = reduction.extract_solution(witness).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (witness).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source_config.len(), 2); assert_eq!(source.evaluate(&source_config).unwrap(), Min(Some(1))); assert!( @@ -105,7 +114,14 @@ fn test_extract_solution_correctness() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&config).unwrap(), Min(Some(2))); - let cover = reduction.extract_solution(&config).unwrap(); + let cover = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(cover, vec![true, false]); assert!(is_valid_cover(&graph, &cover)); } @@ -123,7 +139,14 @@ fn test_extract_from_non_normalized_witness() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&config).unwrap(), Min(Some(2))); - let cover = reduction.extract_solution(&config).unwrap(); + let cover = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(cover, vec![true, false]); assert!(is_valid_cover(&graph, &cover)); } @@ -155,7 +178,15 @@ fn test_minimumvertexcover_to_ensemblecomputation_zero_vertices() { assert_eq!(reduction.target_problem().budget(), 1); // No targets: even these out-of-range suffix operands have no semantics. assert_eq!( - reduction.extract_solution(&vec![usize::MAX; 2]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![usize::MAX; 2].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); } @@ -164,8 +195,15 @@ fn test_minimumvertexcover_to_ensemblecomputation_zero_vertices() { fn test_minimumvertexcover_to_ensemblecomputation_rejects_invalid_programs() { let source = MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![One; 2]); let reduction = ReduceTo::::reduce_to(&source).unwrap(); - for program in [vec![], vec![0; 6], vec![3, 0, 1, 2, 0, 1]] { - assert!(reduction.extract_solution(&program).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); + for program in [vec![0; 6], vec![3, 0, 1, 2, 0, 1]] { + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&program) + .unwrap() + .is_valid()); } } @@ -180,7 +218,14 @@ fn test_minimumvertexcover_to_ensemblecomputation_unused_and_repeated_operations reduction.target_problem().evaluate(&program).unwrap(), Min(Some(6)) ); - let cover = reduction.extract_solution(&program).unwrap(); + let cover = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), program.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(cover, vec![true, true, false, false, false]); assert!(is_valid_cover(source.graph(), &cover)); assert!(cover.iter().filter(|&&v| v).count() <= 6 - 2); @@ -243,7 +288,14 @@ fn test_minimumvertexcover_to_ensemblecomputation_all_small_pair_families() { let Min(Some(length)) = reduction.target_problem().evaluate(&program).unwrap() else { panic!("pair-family program must compute every triple"); }; - let cover = reduction.extract_solution(&program).unwrap(); + let cover = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), program.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let size = cover.iter().filter(|&&v| v).count(); assert!(is_valid_cover(source.graph(), &cover)); assert!(size <= length as usize - edges.len()); @@ -269,7 +321,14 @@ fn test_minimumvertexcover_to_ensemblecomputation_loops_and_parallel_edges() { reduction.target_problem().evaluate(&program).unwrap(), Min(Some(4)) ); - let cover = reduction.extract_solution(&program).unwrap(); + let cover = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), program.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(cover, vec![true, true, false]); assert_eq!(source.evaluate(&cover).unwrap(), Min(Some(2))); @@ -277,8 +336,17 @@ fn test_minimumvertexcover_to_ensemblecomputation_loops_and_parallel_edges() { let reduction = ReduceTo::::reduce_to(&source).unwrap(); assert_eq!( reduction - .extract_solution(&vec![1, 0, usize::MAX, usize::MAX]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![1, 0, usize::MAX, usize::MAX].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true] ); } diff --git a/src/unit_tests/rules/minimumvertexcover_ilp.rs b/src/unit_tests/rules/minimumvertexcover_ilp.rs index 371655368..5e549d62f 100644 --- a/src/unit_tests/rules/minimumvertexcover_ilp.rs +++ b/src/unit_tests/rules/minimumvertexcover_ilp.rs @@ -1,6 +1,7 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MinimumVertexCover; use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -57,7 +58,13 @@ fn test_minimumvertexcover_to_ilp_via_path_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted: Vec = chain.extract_solution(&ilp_solution).unwrap(); + let extracted: Vec = chain + .recover_result::, ILP>( + &problem, + SolveOutcome::optimal(ilp, ilp_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); let ilp_size = extracted.iter().filter(|&&selected| selected).count(); assert_eq!(ilp_size, 2); @@ -73,7 +80,13 @@ fn test_minimumvertexcover_to_ilp_via_path_weighted() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution).unwrap(); + let extracted = chain + .recover_result::, ILP>( + &problem, + SolveOutcome::optimal(ilp, ilp_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); assert_eq!(extracted, vec![false, true, false]); @@ -90,6 +103,12 @@ fn test_minimumvertexcover_to_ilp_bf_vs_ilp() { let bf_solutions = BruteForce::new().find_all_witnesses(&problem).unwrap(); let bf_value = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution).unwrap(); + let extracted = chain + .recover_result::, ILP>( + &problem, + SolveOutcome::optimal(ilp, ilp_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_value); } diff --git a/src/unit_tests/rules/minimumvertexcover_maximumindependentset.rs b/src/unit_tests/rules/minimumvertexcover_maximumindependentset.rs index 53fd77c03..4723ff7dc 100644 --- a/src/unit_tests/rules/minimumvertexcover_maximumindependentset.rs +++ b/src/unit_tests/rules/minimumvertexcover_maximumindependentset.rs @@ -1,7 +1,7 @@ use super::*; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; -include!("../jl_helpers.rs"); #[test] fn test_minimumvertexcover_to_maximumindependentset_closed_loop() { diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs index ef8a2d1fb..319e97060 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -7,6 +7,7 @@ use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; #[cfg(feature = "example-db")] use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; #[cfg(feature = "example-db")] use crate::traits::Problem; @@ -114,7 +115,14 @@ fn test_solution_extraction() { // Target has 9 arcs; first 3 are internal. Extract should take first 3. let target_config = vec![true, true, false, false, false, false, false, false, false]; - let source_config = reduction.extract_solution(&target_config).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source_config, vec![true, true, false]); } @@ -164,3 +172,42 @@ fn test_canonical_rule_example_spec_builds() { assert_eq!(source_metric, source.evaluate(&best_source).unwrap()); assert_eq!(target_metric, target.evaluate(&best_target).unwrap()); } + +#[test] +fn feasible_feedback_arc_set_does_not_establish_a_vertex_cover() { + use crate::rules::{DynReductionResult, ExtractionError}; + use crate::traits::Problem; + use crate::types::Min; + + let source = triangle_source(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + // Removing one internal arc and one crossing arc breaks every cycle, + // but selecting only the corresponding vertex leaves edge (0, 1) uncovered. + let candidate = vec![false, false, true, true, false, false, false, false, false]; + assert!(reduction + .target_problem() + .evaluate(&candidate) + .unwrap() + .is_valid()); + assert_eq!( + source.evaluate(&candidate[..3].to_vec()).unwrap(), + Min(None) + ); + let target = SolveOutcome::feasible(reduction.target_problem(), candidate.clone()).unwrap(); + assert!(matches!( + reduction.recover_result(&source, target), + Err(ExtractionError::InsufficientSolutionQuality), + )); + // The external JSON boundary must report the same rule-level failure, located at this edge. + let target = reduction + .target_result_from_json(serde_json::json!({"status": "feasible", "solution": candidate})) + .unwrap() + .0; + assert!(matches!( + reduction.recover_result_dyn(&source, target), + Err(ExtractionError::InsufficientSolutionQualityAt { + source_problem: "MinimumVertexCover", + target_problem: "MinimumFeedbackArcSet", + }), + )); +} diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs index 9230ebe1c..8757fa584 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs @@ -7,6 +7,7 @@ use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; #[cfg(feature = "example-db")] use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; #[cfg(feature = "example-db")] use crate::traits::Problem; @@ -82,8 +83,17 @@ fn test_identity_solution_extraction() { assert_eq!( reduction - .extract_solution(&vec![true, false, true, false, true]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, false, true, false, true].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, true, false, true] ); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs index e80a1fcd3..5cc662a78 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[test] fn test_minimumvertexcover_to_minimumhittingset_closed_loop() { @@ -129,6 +130,13 @@ fn test_vc_to_hs_solution_extraction() { ReduceTo::::reduce_to(&vc_problem).expect("reduction should succeed"); let target_solution = vec![false, true, false]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &vc_problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, false]); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimummaximalmatching.rs b/src/unit_tests/rules/minimumvertexcover_minimummaximalmatching.rs index 8677416d5..262c05657 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimummaximalmatching.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimummaximalmatching.rs @@ -78,7 +78,7 @@ fn test_minimumvertexcover_to_minimummaximalmatching_has_no_runtime_modes() { assert!(!graph.has_direct_reduction_by_name_mode( "MinimumVertexCover", "MinimumMaximalMatching", - ReductionMode::Aggregate, + ReductionMode::Witness, )); assert!(!graph.has_direct_reduction_by_name_mode( "MinimumVertexCover", diff --git a/src/unit_tests/rules/minimumvertexcover_minimumsetcovering.rs b/src/unit_tests/rules/minimumvertexcover_minimumsetcovering.rs index 11accfa1f..176eb58ae 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumsetcovering.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumsetcovering.rs @@ -1,7 +1,7 @@ use super::*; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; -include!("../jl_helpers.rs"); #[test] fn test_minimumvertexcover_to_minimumsetcovering_closed_loop() { diff --git a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs index 6b93320a5..76a59d017 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -8,6 +8,7 @@ use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; #[cfg(feature = "example-db")] use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -29,6 +30,24 @@ fn test_minimumvertexcover_to_minimumweightandorgraph_closed_loop() { ); } +#[test] +fn negative_vertices_are_selected_even_when_isolated_or_redundant() { + for (n, edges, weights) in [ + (1, vec![], vec![-1]), + (2, vec![(0, 1)], vec![-1, -1]), + (3, vec![(0, 1), (1, 2)], vec![-2, 3, 1]), + (2, vec![(0, 0), (0, 1)], vec![-1, 2]), + ] { + let source = MinimumVertexCover::new(SimpleGraph::new(n, edges), weights); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "signed vertex cover", + ); + } +} + #[test] fn test_reduction_structure() { let source = issue_example_source(); @@ -85,7 +104,14 @@ fn test_weighted_vertices_are_charged_on_sink_arcs() { assert_eq!(target.evaluate(&target_solution).unwrap(), Min(Some(5))); assert_eq!(target.arc_weights(), &[1, 1, 1, 1, 1, 1, 4, 1, 3]); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, true, false] ); } diff --git a/src/unit_tests/rules/minimumvertexcover_qubo.rs b/src/unit_tests/rules/minimumvertexcover_qubo.rs index 98c1d4935..3a8ff90b0 100644 --- a/src/unit_tests/rules/minimumvertexcover_qubo.rs +++ b/src/unit_tests/rules/minimumvertexcover_qubo.rs @@ -3,6 +3,7 @@ use crate::models::graph::MinimumVertexCover; use crate::rules::{ReductionChain, ReductionGraph, ReductionPath}; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -55,12 +56,18 @@ fn test_minimumvertexcover_to_qubo_via_path_closed_loop() { "QUBO", ] ); - assert_eq!(qubo.num_variables(), 4); + assert_eq!(qubo.num_variables().unwrap(), 4); let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &qubo_solutions { - let extracted = chain.extract_solution(sol).unwrap(); + let extracted = chain + .recover_result::, QUBO>( + &problem, + SolveOutcome::optimal(qubo, (sol).clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert!(problem.evaluate(&extracted).unwrap().is_valid()); assert_eq!(extracted.iter().filter(|&&x| x).count(), 2); } @@ -78,7 +85,13 @@ fn test_minimumvertexcover_to_qubo_via_path_weighted() { .solve(qubo) .unwrap() .expect("QUBO should be solvable via path"); - let extracted = chain.extract_solution(&qubo_solution).unwrap(); + let extracted = chain + .recover_result::, QUBO>( + &problem, + SolveOutcome::optimal(qubo, qubo_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); assert_eq!(extracted, vec![false, true, false]); @@ -93,14 +106,20 @@ fn test_minimumvertexcover_to_qubo_via_path_star_graph() { let (_, chain) = reduce_vc_to_qubo(&problem); let qubo: &QUBO = chain.target_problem(); - assert_eq!(qubo.num_variables(), 4); + assert_eq!(qubo.num_variables().unwrap(), 4); let solver = BruteForce::new(); let qubo_solution = solver .solve(qubo) .unwrap() .expect("QUBO should be solvable"); - let extracted = chain.extract_solution(&qubo_solution).unwrap(); + let extracted = chain + .recover_result::, QUBO>( + &problem, + SolveOutcome::optimal(qubo, qubo_solution.clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); assert_eq!(extracted.iter().filter(|&&x| x).count(), 1); diff --git a/src/unit_tests/rules/minimumweightdecoding_ilp.rs b/src/unit_tests/rules/minimumweightdecoding_ilp.rs index 09eb28337..69cffe26e 100644 --- a/src/unit_tests/rules/minimumweightdecoding_ilp.rs +++ b/src/unit_tests/rules/minimumweightdecoding_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -65,7 +66,14 @@ fn test_minimumweightdecoding_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(ilp_value, bf_value); @@ -87,7 +95,14 @@ fn test_minimumweightdecoding_to_ilp_small_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), bf_value); } @@ -96,8 +111,9 @@ fn test_minimumweightdecoding_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMinimumWeightDecodingToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible instance should produce infeasible ILP" ); } @@ -122,7 +138,14 @@ fn test_minimumweightdecoding_to_ilp_extract_solution() { // Row 1: H[1][2]=1 → sum=1, s=1 → 1-1=0 → k_1=0 ✓ // Row 2: H[2][2]=0 → sum=0, s=0 → 0-0=0 → k_2=0 ✓ let target_solution = vec![0, 0, 1, 0, 0, 0, 0]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert_eq!(extracted, vec![false, false, true, false]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); diff --git a/src/unit_tests/rules/minmaxmulticenter_ilp.rs b/src/unit_tests/rules/minmaxmulticenter_ilp.rs index 743658bfb..1c2751867 100644 --- a/src/unit_tests/rules/minmaxmulticenter_ilp.rs +++ b/src/unit_tests/rules/minmaxmulticenter_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MinMaxMulticenter; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -53,7 +54,14 @@ fn test_minmaxmulticenter_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Min(Some(1))); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted.len(), 3, @@ -83,7 +91,14 @@ fn test_solution_extraction() { 0, 1, 0, // y_{2,0}, y_{2,1}, y_{2,2} 1, // z ]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, false]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(1))); } @@ -108,7 +123,14 @@ fn test_minmaxmulticenter_to_ilp_weighted() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(100))); } @@ -124,7 +146,14 @@ fn test_minmaxmulticenter_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(0))); } diff --git a/src/unit_tests/rules/mixedchinesepostman_ilp.rs b/src/unit_tests/rules/mixedchinesepostman_ilp.rs index 46e5817de..19a726299 100644 --- a/src/unit_tests/rules/mixedchinesepostman_ilp.rs +++ b/src/unit_tests/rules/mixedchinesepostman_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::MixedGraph; use crate::traits::Problem; @@ -23,7 +24,14 @@ fn test_mixedchinesepostman_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0.is_some()); } @@ -45,7 +53,14 @@ fn test_mixedchinesepostman_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = source.evaluate(&extracted).unwrap(); assert_eq!( @@ -71,7 +86,14 @@ fn test_mixedchinesepostman_to_ilp_weighted() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = source.evaluate(&extracted).unwrap(); assert_eq!( @@ -93,7 +115,14 @@ fn test_mixedchinesepostman_to_ilp_with_isolated_vertices() { ); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let ilp_solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap().0, Some(69)); } diff --git a/src/unit_tests/rules/monochromatictriangle_ilp.rs b/src/unit_tests/rules/monochromatictriangle_ilp.rs index 0dbd944fd..95fbf1317 100644 --- a/src/unit_tests/rules/monochromatictriangle_ilp.rs +++ b/src/unit_tests/rules/monochromatictriangle_ilp.rs @@ -2,6 +2,7 @@ use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::graph::MonochromaticTriangle; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -46,7 +47,14 @@ fn test_monochromatic_triangle_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("K4 should admit a monochromatic-triangle-free 2-edge-coloring"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted, @@ -69,8 +77,9 @@ fn test_monochromatic_triangle_to_ilp_infeasible_k6() { let problem = MonochromaticTriangle::new(SimpleGraph::new(6, edges)); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "K6 should be infeasible by R(3,3)=6" ); } @@ -81,7 +90,14 @@ fn test_monochromatic_triangle_to_ilp_extract_solution_identity() { let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let coloring = vec![0, 0, 1, 1, 0, 1]; - let extracted = reduction.extract_solution(&coloring).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), coloring.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false, true, true, false, true]); assert!(problem.evaluate(&extracted).unwrap()); diff --git a/src/unit_tests/rules/multiplechoicebranching_ilp.rs b/src/unit_tests/rules/multiplechoicebranching_ilp.rs index 6166a1180..d11313829 100644 --- a/src/unit_tests/rules/multiplechoicebranching_ilp.rs +++ b/src/unit_tests/rules/multiplechoicebranching_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -18,10 +19,20 @@ fn test_multiplechoicebranching_to_ilp_closed_loop() { match expected { Some(_) => { let target = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let actual = reduction.extract_solution(&target).unwrap(); + let actual = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&actual).unwrap().0); } - None => assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()), + None => assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ), } } } @@ -35,7 +46,10 @@ fn test_multiplechoicebranching_to_ilp_rejects_forced_cycle() { 2, ); let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -57,7 +71,14 @@ fn test_multiplechoicebranching_to_ilp_empty_graph() { let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); let target = ILPSolver::new().solve(reduction.target_problem()).unwrap(); assert_eq!( - reduction.extract_solution(&target).unwrap(), + reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), Vec::::new() ); } diff --git a/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs b/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs index 27d7b9ce5..9f05960b2 100644 --- a/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs +++ b/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::graph::MultipleCopyFileAllocation; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -48,7 +49,14 @@ fn test_multiplecopyfileallocation_to_ilp_bf_vs_ilp() { assert!(problem.evaluate(&bf_witness).unwrap().0.is_some()); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted.len(), 3, @@ -76,7 +84,14 @@ fn test_solution_extraction() { 0, 1, 0, // y_{1,0}, y_{1,1}, y_{1,2} 0, 1, 0, // y_{2,0}, y_{2,1}, y_{2,2} ]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true, false]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(7))); } @@ -95,7 +110,14 @@ fn test_multiplecopyfileallocation_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(3))); } @@ -110,7 +132,14 @@ fn test_multiplecopyfileallocation_unreachable_assignments_are_forbidden() { let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); let direct = BruteForce::new().solve(&problem).unwrap().unwrap(); let target = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), diff --git a/src/unit_tests/rules/multiprocessorscheduling_ilp.rs b/src/unit_tests/rules/multiprocessorscheduling_ilp.rs index 25921170e..0441df218 100644 --- a/src/unit_tests/rules/multiprocessorscheduling_ilp.rs +++ b/src/unit_tests/rules/multiprocessorscheduling_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -49,7 +50,14 @@ fn test_multiprocessorscheduling_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -67,7 +75,14 @@ fn test_solution_extraction() { // Manually set: task 0 → proc 0, task 1 → proc 1, task 2 → proc 0 // Variables: x_{0,0}=1, x_{0,1}=0, x_{1,0}=0, x_{1,1}=1, x_{2,0}=1, x_{2,1}=0 let ilp_solution = vec![1, 0, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 0]); // loads: proc 0 = 1+3=4 ≤ 5, proc 1 = 2 ≤ 5 assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); @@ -88,6 +103,13 @@ fn test_multiprocessorscheduling_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/naesatisfiability_ilp.rs b/src/unit_tests/rules/naesatisfiability_ilp.rs index 287d57652..de9eda49f 100644 --- a/src/unit_tests/rules/naesatisfiability_ilp.rs +++ b/src/unit_tests/rules/naesatisfiability_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -50,7 +51,14 @@ fn test_naesatisfiability_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -80,8 +88,9 @@ fn test_naesatisfiability_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); // The ILP should be infeasible: x1 ≥ 1 (at least one true) AND x1 ≤ 0 (at least one false) - assert!( - ilp_solver.solve(ilp).is_err(), + assert_eq!( + ilp_solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible), "ILP should be infeasible for unsatisfiable NAE-SAT" ); } @@ -106,7 +115,14 @@ fn test_naesatisfiability_to_ilp_negative_literals() { let ilp_solution = ilp_solver .solve(ilp) .expect("NAE-SAT with (¬x1 ∨ x2) is feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), diff --git a/src/unit_tests/rules/naesatisfiability_maxcut.rs b/src/unit_tests/rules/naesatisfiability_maxcut.rs index 9f07e13f0..128a3682f 100644 --- a/src/unit_tests/rules/naesatisfiability_maxcut.rs +++ b/src/unit_tests/rules/naesatisfiability_maxcut.rs @@ -1,11 +1,16 @@ 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::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; #[test] fn test_naesatisfiability_to_maxcut_closed_loop() { @@ -19,16 +24,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 reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem(); // 2*3 = 6 vertices - assert_eq!(target.num_vertices(), 6); + assert_eq!(target.inner().num_vertices(), 6); // 3 variable edges + 3 + 3 = 9 clause edges - assert_eq!(target.num_edges(), 9); + assert_eq!(target.inner().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 +44,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 reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem(); // 6 vertices, 3 variable + 3 clause = 6 edges - assert_eq!(target.num_vertices(), 6); - assert_eq!(target.num_edges(), 6); + assert_eq!(target.inner().num_vertices(), 6); + assert_eq!(target.inner().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 +64,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 reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem(); // 4 vertices, 2 variable + 1 clause = 3 edges - assert_eq!(target.num_vertices(), 4); - assert_eq!(target.num_edges(), 3); + assert_eq!(target.inner().num_vertices(), 4); + assert_eq!(target.inner().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 +83,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 reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem(); // One auxiliary variable and two triangles: 10 vertices, 5 + 6 edges. - assert_eq!(target.num_vertices(), 10); - assert_eq!(target.num_edges(), 11); + assert_eq!(target.inner().num_vertices(), 10); + assert_eq!(target.inner().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,15 +108,22 @@ 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 // x2=F -> vertex 2 in set 0, vertex 3 in set 1 // x3=T -> vertex 4 in set 1, vertex 5 in set 0 let target_config = vec![true, false, false, true, true, false]; - let extracted = reduction.extract_solution(&target_config).unwrap(); + let extracted = reduction + .recover_result( + &naesat, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true]); // x1=T, x2=F, x3=T // Verify this is a valid NAE-SAT solution @@ -129,15 +141,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 reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem(); // 6 vertices, 3 variable + (1 + 3 + 1) = 8 edges - assert_eq!(target.num_vertices(), 6); - assert_eq!(target.num_edges(), 8); + assert_eq!(target.inner().num_vertices(), 6); + assert_eq!(target.inner().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,8 +167,8 @@ fn test_naesatisfiability_to_maxcut_optimal_cut_value() { CNFClause::new(vec![-1, -2, 3]), ], ); - let reduction = - ReduceTo::>::reduce_to(&naesat).expect("reduction should succeed"); + let reduction = ReduceTo::>>::reduce_to(&naesat) + .expect("reduction should succeed"); let target = reduction.target_problem(); let solver = BruteForce::new(); @@ -164,42 +176,49 @@ fn test_naesatisfiability_to_maxcut_optimal_cut_value() { assert!(witness.is_some()); let config = witness.unwrap(); - let cut_value = target.cut_size(&config).unwrap(); + let cut_value = target.inner().cut_size(&config).unwrap(); // n=3, m=2, M=3, k1=3, k2=3 // Expected: 3*3 + (3-1) + (3-1) = 9 + 2 + 2 = 13 assert_eq!(cut_value, 13); } 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 = crate::rules::ReductionResult::target_problem(&reduction); let mut decoded = vec![false; 1 << source.num_vars()]; let mut best = i64::MIN; - for mask in 0..(1usize << target.num_vertices()) { - let cut = (0..target.num_vertices()) + for mask in 0..(1usize << target.inner().num_vertices()) { + let cut = (0..target.inner().num_vertices()) .map(|i| mask & (1 << i) != 0) .collect(); - let value = target.evaluate(&cut).unwrap(); + let value = target.inner().evaluate(&cut).unwrap(); best = best.max(value.0.unwrap()); - let certificate = AggregateReductionResult::extract_value(&reduction, value).0; - match reduction.extract_solution(&cut) { - Ok(assignment) => { - assert!(certificate); - assert!(source.evaluate(&assignment).unwrap().0); - assert_eq!( - assignment, - (0..source.num_vars()) - .map(|i| cut[2 * i]) - .collect::>() - ); - let index = assignment - .iter() - .enumerate() - .fold(0, |index, (i, &bit)| index | (usize::from(bit) << i)); - decoded[index] = true; - } - Err(_) => assert!(!certificate), + let certificate = crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + )) + .0; + if certificate { + let assignment = reduction + .recover_result( + source, + SolveOutcome::optimal(reduction.target_problem(), cut.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert!(source.evaluate(&assignment).unwrap().0); + assert_eq!( + assignment, + (0..source.num_vars()) + .map(|i| cut[2 * i]) + .collect::>() + ); + let index = assignment + .iter() + .enumerate() + .fold(0, |index, (i, &bit)| index | (usize::from(bit) << i)); + decoded[index] = true; } } for (mask, &has_extension) in decoded.iter().enumerate() { @@ -209,13 +228,26 @@ fn check_every_cut(source: &NAESatisfiability) { assert_eq!(has_extension, source.evaluate(&assignment).unwrap().0); } assert_eq!( - AggregateReductionResult::extract_value(&reduction, crate::types::Max(Some(best))).0, + crate::types::Or(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!(reduction - .extract_solution(&vec![false; target.num_vertices() + 1]) - .is_err()); + assert!( + !crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Max(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![false; target.inner().num_vertices() + 1]), + Err(InvalidConfiguration(_)) + )); } #[test] @@ -251,8 +283,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!(*ReductionResult::target_problem(&reduction).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..f0430ce53 100644 --- a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -3,6 +3,7 @@ use crate::models::formula::{CNFClause, NAESatisfiability}; use crate::models::graph::PartitionIntoPerfectMatchings; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; @@ -255,7 +256,14 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_constructed_witness_r .evaluate(&target_solution) .unwrap()); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), source_solution ); } @@ -274,20 +282,66 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_two_literal_clause_no assert_eq!(target.num_matchings(), 2); assert!(target.evaluate(&target_solution).unwrap()); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), source_solution ); } #[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 long_nae_clauses_preserve_assignments_with_auxiliary_variables() { + let entry = inventory::iter:: + .into_iter() + .find(|e| { + e.source_name == NAESatisfiability::NAME + && e.target_name == PartitionIntoPerfectMatchings::::NAME + }) + .unwrap(); + let contract = entry.parameter_contract().unwrap(); + let transform = contract.transform().unwrap(); + for literals in [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 reduction = + ReduceTo::>::reduce_to(&source).unwrap(); + let layout = &reduction.layout; + let bound = transform.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 mut extendible = false; + for aux in 0..(1 << (layout.variables.len() - 4)) { + let mut extended = assignment.clone(); + extended.extend((0..layout.variables.len() - 4).map(|i| aux & (1 << i) != 0)); + let satisfies = 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) + }); + extendible |= satisfies; + } + assert_eq!(extendible, source.evaluate(&assignment).unwrap().0); + if extendible { + let target = reduction.construct_target_solution(&assignment); + let recovered = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target).unwrap(), + ) + .unwrap() + .into_solution() + .unwrap(); + assert_eq!(recovered, assignment); + } + } + } } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/rules/naesatisfiability_setsplitting.rs b/src/unit_tests/rules/naesatisfiability_setsplitting.rs index 522d9bf04..c35bb0e97 100644 --- a/src/unit_tests/rules/naesatisfiability_setsplitting.rs +++ b/src/unit_tests/rules/naesatisfiability_setsplitting.rs @@ -3,6 +3,7 @@ use crate::models::set::SetSplitting; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn rule_example_problem() -> NAESatisfiability { @@ -54,9 +55,18 @@ fn test_naesatisfiability_to_setsplitting_extract_solution_uses_positive_literal assert_eq!( reduction - .extract_solution(&vec![true, false, true, false, true, false]) - .unwrap(), - vec![true, false, true] + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, true, false, false, false, true].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![true, true, false] ); } @@ -67,7 +77,14 @@ fn test_naesatisfiability_to_setsplitting_target_witness_extracts_to_satisfying_ let solver = BruteForce::new(); let target_solution = solver.solve(reduction.target_problem()).unwrap().unwrap(); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&source_solution).unwrap()); } diff --git a/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index cf2136f51..7eada88e2 100644 --- a/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::misc::{Numerical3DimensionalMatching, NumericalMatchingWithTargetSums}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn yes_problem() -> Numerical3DimensionalMatching { @@ -54,7 +55,14 @@ fn test_n3dm_to_nmts_extracts_target_witness_into_source_witness() { .0 ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![2, 0, 1, 0, 2, 1]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -74,7 +82,14 @@ fn test_n3dm_to_nmts_handles_repeated_targets() { .0 ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).unwrap().0); } diff --git a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs index 697129347..9b635d849 100644 --- a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; @@ -16,7 +17,14 @@ fn test_numericalmatchingwithtargetsums_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -55,8 +63,9 @@ fn test_numericalmatchingwithtargetsums_to_ilp_unsatisfiable() { let problem = NumericalMatchingWithTargetSums::new(vec![1, 2], vec![3, 4], vec![10, 20]); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let result = ILPSolver::new().solve(reduction.target_problem()); - assert!( - result.is_err(), + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), "Unsatisfiable instance should have no ILP solution" ); } @@ -74,7 +83,14 @@ fn test_numericalmatchingwithtargetsums_to_ilp_single_pair() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("single-pair ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0]); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -94,7 +110,14 @@ fn test_numericalmatchingwithtargetsums_to_ilp_compatible_triples_only() { assert_eq!(ilp.num_vars(), 2); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1]); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/openshopscheduling_ilp.rs b/src/unit_tests/rules/openshopscheduling_ilp.rs index 904f7fbf2..eedf2bf05 100644 --- a/src/unit_tests/rules/openshopscheduling_ilp.rs +++ b/src/unit_tests/rules/openshopscheduling_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::models::misc::OpenShopScheduling; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -63,7 +64,14 @@ fn test_openshopscheduling_to_ilp_closed_loop_small() { .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = p.evaluate(&extracted).unwrap(); assert!( value.0.is_some(), @@ -82,7 +90,14 @@ fn test_openshopscheduling_to_ilp_closed_loop_medium() { .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = p.evaluate(&extracted).unwrap(); assert!( value.0.is_some(), @@ -102,7 +117,14 @@ fn test_openshopscheduling_to_ilp_extract_solution_respects_start_times() { let reduction: ReductionOSSToILP = ReduceTo::>::reduce_to(&p).expect("reduction should succeed"); let target_solution = vec![1, 0, 0, 1, 1, 0, 1, 0, 3]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 1, 0]); assert_eq!(p.evaluate(&extracted).unwrap(), Min(Some(3))); } @@ -118,7 +140,14 @@ fn test_openshopscheduling_to_ilp_single_job() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = p.evaluate(&extracted).unwrap(); assert!(value.0.is_some()); assert_eq!(value, Min(Some(7))); @@ -133,7 +162,14 @@ fn test_openshopscheduling_to_ilp_single_machine() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = p.evaluate(&extracted).unwrap(); assert!(value.0.is_some()); assert_eq!(value, Min(Some(6))); diff --git a/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index a081c4de7..00e074817 100644 --- a/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -2,8 +2,11 @@ use super::*; use crate::models::decision::Decision; use crate::models::graph::OptimalLinearArrangement; use crate::rules::ReduceTo; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Or; @@ -59,7 +62,14 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_closed_loo assert_eq!(target.evaluate(&target_witness).unwrap(), Or(true)); // Reconstructed source arrangement must be a valid arrangement of length <= k. - let arrangement = reduction.extract_solution(&target_witness).unwrap(); + let arrangement = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&arrangement).unwrap(), Or(true)); } @@ -99,10 +109,20 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_edgeless_s assert_eq!(target.evaluate(&witness).unwrap(), Or(true)); // Reconstructed source arrangement covers all 3 vertices and is YES. - let arrangement = reduction.extract_solution(&witness).unwrap(); + let arrangement = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(arrangement.len(), 3); assert_eq!(source.evaluate(&arrangement).unwrap(), Or(true)); - assert!(reduction.extract_solution(&vec![]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); } #[test] @@ -134,7 +154,10 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_negative_b BruteForce::new().solve(&source).unwrap().is_none(), "P_6 has no arrangement of length <= 4" ); - assert!(reduction.extract_solution(&vec![]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); } #[test] @@ -143,19 +166,13 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_extract_in let reduction = ReduceTo::::reduce_to(&source) .expect("reduction should succeed"); + assert!(reduction.target_problem().evaluate(&vec![0, 1, 2]).is_err()); assert_eq!( reduction - .extract_solution(&vec![0, 1, 2]) - .unwrap_err() - .to_string(), - "target evaluation failed during extraction: invalid configuration: column ordering length does not match the matrix" - ); - assert_eq!( - reduction - .extract_solution(&vec![0, 0, 1, 2, 3, 4]) - .unwrap_err() - .to_string(), - "target column order is not a satisfying augmentation certificate" + .target_problem() + .evaluate(&vec![0, 0, 1, 2, 3, 4]) + .unwrap(), + crate::types::Or(false) ); } @@ -186,14 +203,25 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_native_dom if let Some(witness) = witness { assert_eq!( source - .evaluate(&reduction.extract_solution(&witness).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap(), Or(true) ); } else { - assert!(reduction - .extract_solution(&(0..target.num_cols()).collect()) - .is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&(0..target.num_cols()).collect()) + .unwrap() + .is_valid()); } } } @@ -202,9 +230,22 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_native_dom fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_certificate() { let source = decision_ola(SimpleGraph::new(3, vec![(0, 2)]), 1); let reduction = ReduceTo::::reduce_to(&source).unwrap(); - assert!(reduction.extract_solution(&vec![0, 1, 2]).is_err()); - assert!(reduction.extract_solution(&vec![0, 1, 3]).is_err()); - let arrangement = reduction.extract_solution(&vec![2, 0, 1]).unwrap(); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0, 1, 2]) + .unwrap() + .is_valid()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![0, 1, 3]), + Err(InvalidConfiguration(_)) + )); + let arrangement = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![2, 0, 1].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(arrangement, vec![1, 2, 0]); assert_eq!(source.evaluate(&arrangement).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/optimallineararrangement_ilp.rs b/src/unit_tests/rules/optimallineararrangement_ilp.rs index c45f0bf41..7a6fef307 100644 --- a/src/unit_tests/rules/optimallineararrangement_ilp.rs +++ b/src/unit_tests/rules/optimallineararrangement_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -34,7 +35,14 @@ fn test_optimallineararrangement_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&extracted).unwrap().0.is_some(), "ILP solution should produce a valid arrangement" @@ -64,7 +72,14 @@ fn test_optimallineararrangement_to_ilp_with_chords() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } @@ -77,7 +92,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } diff --git a/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs b/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs index 0347eda91..295031185 100644 --- a/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs +++ b/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -98,7 +99,14 @@ fn test_optimallineararrangement_to_sequencingtominimizeweightedcompletiontime_e ) { let (source, reduction) = reduce_path(4); let schedule = vec![3, 2, 6, 1, 5, 0, 4]; - let extracted = reduction.extract_solution(&schedule).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![3, 2, 1, 0]); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(3))); diff --git a/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs b/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs index 4e0f1cf8c..1220733fc 100644 --- a/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs +++ b/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -76,7 +77,14 @@ fn test_ocst_to_ilp_bf_vs_ilp_k3() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -95,7 +103,14 @@ fn test_ocst_to_ilp_bf_vs_ilp_k4() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -111,7 +126,14 @@ fn test_ocst_to_ilp_extraction() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should be a valid config with m=3 entries assert_eq!(extracted.len(), 3); @@ -153,7 +175,14 @@ fn test_ocst_zero_requirement_pairs_still_enforce_spanning_tree() { ); let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); let solution = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(55))); } diff --git a/src/unit_tests/rules/paintshop_ilp.rs b/src/unit_tests/rules/paintshop_ilp.rs index aa721eda1..24ef1b36a 100644 --- a/src/unit_tests/rules/paintshop_ilp.rs +++ b/src/unit_tests/rules/paintshop_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -40,7 +41,14 @@ fn test_paintshop_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -56,7 +64,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 1); // Either 0 or 1 is valid; coloring is [x, 1-x], switches = 1 assert!(problem.evaluate(&extracted).unwrap().is_valid()); diff --git a/src/unit_tests/rules/paintshop_qubo.rs b/src/unit_tests/rules/paintshop_qubo.rs index e5b6343f2..ec52b6d39 100644 --- a/src/unit_tests/rules/paintshop_qubo.rs +++ b/src/unit_tests/rules/paintshop_qubo.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[test] fn test_paintshop_to_qubo_closed_loop() { @@ -47,7 +48,14 @@ fn test_paintshop_to_qubo_optimal_value() { // Extract solutions and verify they are optimal for the source for sol in &best_target { - let source_sol = reduction.extract_solution(sol).unwrap(); + let source_sol = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let switches = source.count_switches(&source_sol).unwrap(); // Optimal is 2 switches assert_eq!(switches, 2, "Expected 2 switches for optimal solution"); @@ -56,27 +64,26 @@ fn test_paintshop_to_qubo_optimal_value() { #[test] fn test_paintshop_to_qubo_matrix_structure() { - // Issue example: verify the Q matrix matches expected values + // Verify the Q matrix matches expected values let source = PaintShop::new(vec!["A", "B", "C", "A", "D", "B", "D", "C"]); let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let qubo = reduction.target_problem(); - let m = qubo.matrix(); - // From the issue: + // Expected coefficients: // Q = [ -1, -2, 2, 2 ] // [ 0, 2, -2, 0 ] // [ 0, 0, 1, -2 ] // [ 0, 0, 0, 0 ] - assert_eq!(m[0][0], -1); - assert_eq!(m[0][1], -2); - assert_eq!(m[0][2], 2); - assert_eq!(m[0][3], 2); - assert_eq!(m[1][1], 2); - assert_eq!(m[1][2], -2); - assert_eq!(m[1][3], 0); - assert_eq!(m[2][2], 1); - assert_eq!(m[2][3], -2); - assert_eq!(m[3][3], 0); + assert_eq!(qubo.get(0, 0).unwrap(), -1); + assert_eq!(qubo.get(0, 1).unwrap(), -2); + assert_eq!(qubo.get(0, 2).unwrap(), 2); + assert_eq!(qubo.get(0, 3).unwrap(), 2); + assert_eq!(qubo.get(1, 1).unwrap(), 2); + assert_eq!(qubo.get(1, 2).unwrap(), -2); + assert_eq!(qubo.get(1, 3).unwrap(), 0); + assert_eq!(qubo.get(2, 2).unwrap(), 1); + assert_eq!(qubo.get(2, 3).unwrap(), -2); + assert_eq!(qubo.get(3, 3).unwrap(), 0); } #[test] diff --git a/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs b/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs index f907bd954..778b7909a 100644 --- a/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs +++ b/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -28,7 +29,14 @@ fn test_partiallyorderedknapsack_to_ilp_bf_vs_ilp() { let bf_value = problem.evaluate(&bf_solutions[0]).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -44,7 +52,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().is_valid()); } diff --git a/src/unit_tests/rules/partition_binpacking.rs b/src/unit_tests/rules/partition_binpacking.rs index 4d0185802..1c4e07e88 100644 --- a/src/unit_tests/rules/partition_binpacking.rs +++ b/src/unit_tests/rules/partition_binpacking.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::misc::{BinPacking, Partition}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -49,6 +50,22 @@ 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(); + let extracted = reduction.map_solution(&best).unwrap(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(target, best.clone()).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(target, best.clone()).unwrap() + ), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); assert!(!source.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/partition_cosineproductintegration.rs b/src/unit_tests/rules/partition_cosineproductintegration.rs index 3d84ffddc..02ae55da3 100644 --- a/src/unit_tests/rules/partition_cosineproductintegration.rs +++ b/src/unit_tests/rules/partition_cosineproductintegration.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::misc::{CosineProductIntegration, Partition}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn reduce_partition(sizes: &[i64]) -> (Partition, ReductionPartitionToCPI) { @@ -70,7 +71,14 @@ fn test_partition_to_cosineproductintegration_solution_extraction() { let target_solutions = solver.find_all_witnesses(target).unwrap(); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), source.num_elements()); let target_valid = target.evaluate(sol).unwrap(); let source_valid = source.evaluate(&extracted).unwrap(); diff --git a/src/unit_tests/rules/partition_integralflowwithmultipliers.rs b/src/unit_tests/rules/partition_integralflowwithmultipliers.rs index cb72e3e25..3d7da8957 100644 --- a/src/unit_tests/rules/partition_integralflowwithmultipliers.rs +++ b/src/unit_tests/rules/partition_integralflowwithmultipliers.rs @@ -3,6 +3,7 @@ use crate::models::graph::IntegralFlowWithMultipliers; use crate::models::misc::Partition; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[test] fn test_partition_to_integralflowwithmultipliers_closed_loop() { @@ -75,10 +76,6 @@ fn test_partition_to_integralflowwithmultipliers_odd_total_is_fixed_no_instance( assert_eq!(target.capacities(), &[1, 1]); assert_eq!(target.requirement(), 1); assert!(BruteForce::new().solve(target).unwrap().is_none()); - assert_eq!( - reduction.extract_solution(&vec![]).unwrap_err().to_string(), - "the fixed infeasible target instance has no extractable witness" - ); } #[test] @@ -89,8 +86,17 @@ fn test_partition_to_integralflowwithmultipliers_extract_solution() { assert_eq!( reduction - .extract_solution(&vec![1, 0, 1, 0, 1, 0, 2, 0, 4, 0, 6, 0, 12]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![1, 0, 1, 0, 1, 0, 2, 0, 4, 0, 6, 0, 12].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, true, false, true, false] ); } diff --git a/src/unit_tests/rules/partition_knapsack.rs b/src/unit_tests/rules/partition_knapsack.rs index c4d2274df..07b436620 100644 --- a/src/unit_tests/rules/partition_knapsack.rs +++ b/src/unit_tests/rules/partition_knapsack.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::misc::Partition; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Max; @@ -41,6 +42,22 @@ 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(); + let extracted = reduction.map_solution(&best).unwrap(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(target, best.clone()).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(target, best.clone()).unwrap() + ), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); assert!(!source.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/partition_multiprocessorscheduling.rs b/src/unit_tests/rules/partition_multiprocessorscheduling.rs index cbec3fcc6..1b72a9212 100644 --- a/src/unit_tests/rules/partition_multiprocessorscheduling.rs +++ b/src/unit_tests/rules/partition_multiprocessorscheduling.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::misc::Partition; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn reduce_partition(sizes: &[i64]) -> (Partition, ReductionPartitionToMPS) { @@ -84,7 +85,14 @@ fn test_partition_to_multiprocessorscheduling_solution_extraction() { let target_solutions = solver.find_all_witnesses(target).unwrap(); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Solution length should match number of elements assert_eq!(extracted.len(), source.num_elements()); // Extracted solution should satisfy source if target is satisfied diff --git a/src/unit_tests/rules/partition_openshopscheduling.rs b/src/unit_tests/rules/partition_openshopscheduling.rs index 40bc779df..16a25f44e 100644 --- a/src/unit_tests/rules/partition_openshopscheduling.rs +++ b/src/unit_tests/rules/partition_openshopscheduling.rs @@ -1,37 +1,56 @@ use super::*; use crate::models::algebraic::ILP; +use crate::models::decision::Decision; use crate::models::misc::{OpenShopScheduling, Partition}; +use crate::rules::ReductionResult; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; fn solve_target(target: &OpenShopScheduling) -> Vec { let reduction = ReduceTo::>::reduce_to(target).expect("ILP reduction should succeed"); let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("open-shop target should be feasible"); - reduction.extract_solution(&ilp_solution).unwrap() + reduction + .recover_result( + target, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") } #[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 extracted = reduction.extract_solution(&target_solution).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target_solution = solve_target(reduction.target_problem().inner()); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } #[test] fn test_partition_to_open_shop_scheduling_structure() { let source = Partition::new(vec![1, 2, 3]).unwrap(); - let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); - assert_eq!(target.num_jobs(), 4); - assert_eq!(target.num_machines(), 3); + assert_eq!(target.inner().num_jobs(), 4); + assert_eq!(target.inner().num_machines(), 3); assert_eq!( - target.processing_times(), + target.inner().processing_times(), &[vec![1, 1, 1], vec![2, 2, 2], vec![3, 3, 3], vec![3, 3, 3]] ); } @@ -39,9 +58,16 @@ 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 extracted = reduction.extract_solution(&target_solution).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target_solution = solve_target(reduction.target_problem().inner()); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).unwrap()); } @@ -49,20 +75,23 @@ 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()); - assert!(reduction.extract_solution(&best).is_err()); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let best = solve_target(reduction.target_problem().inner()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&best) + .unwrap() + .is_valid()); } #[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(_), } )); @@ -70,7 +99,6 @@ fn test_partition_to_open_shop_scheduling_preserves_construction_overflow() { #[test] fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { - use crate::rules::AggregateReductionResult; let permutations = [ [0, 1, 2], [0, 2, 1], @@ -89,10 +117,14 @@ 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 = crate::rules::ReductionResult::target_problem(&reduction); assert!( - !AggregateReductionResult::extract_value(&reduction, crate::types::Min(None)).0 + !crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 ); for mask in 0..(1usize << n) { let assignment: Vec<_> = (0..n).map(|i| mask & (1 << i) != 0).collect(); @@ -116,35 +148,70 @@ fn test_partition_to_open_shop_all_small_partitions_and_machine_orders() { assert_eq!(time, (phase + 1) * half); } } - let value = target.evaluate(&schedule).unwrap(); + let value = target.inner().evaluate(&schedule).unwrap(); assert_eq!(value, crate::types::Min(Some(3 * half as i64))); - assert!(AggregateReductionResult::extract_value(&reduction, value).0); - assert_eq!(reduction.extract_solution(&schedule).unwrap(), assignment); + assert!( + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + assignment + ); let delayed: Vec<_> = schedule.iter().map(|&time| time + 1).collect(); - assert!(target.evaluate(&delayed).unwrap().0.is_some()); - assert!(reduction.extract_solution(&delayed).is_err()); + assert!(target.inner().evaluate(&delayed).unwrap().0.is_some()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&delayed) + .unwrap() + .is_valid()); } } - assert!(reduction.extract_solution(&vec![0; (n + 1) * 3]).is_err()); - assert!(reduction - .extract_solution(&vec![0; (n + 1) * 3 + 1]) - .is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0; (n + 1) * 3]) + .unwrap() + .is_valid()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![0; (n + 1) * 3 + 1]), + Err(InvalidConfiguration(_)) + )); } } } #[test] 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!(reduction.extract_solution(&schedule).is_err()); + assert!( + !crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 + ); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&schedule) + .unwrap() + .is_valid()); } #[test] @@ -152,16 +219,30 @@ 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!( source - .evaluate(&reduction.extract_solution(&schedule).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); diff --git a/src/unit_tests/rules/partition_productionplanning.rs b/src/unit_tests/rules/partition_productionplanning.rs index 83790b2b5..443adbb13 100644 --- a/src/unit_tests/rules/partition_productionplanning.rs +++ b/src/unit_tests/rules/partition_productionplanning.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::misc::{Partition, ProductionPlanning}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[test] fn test_partition_to_productionplanning_closed_loop() { @@ -52,7 +53,15 @@ fn test_partition_to_productionplanning_extract_solution() { ReduceTo::::reduce_to(&source).expect("reduction should succeed"); assert_eq!( - reduction.extract_solution(&vec![0, 0, 0, 4, 6, 0]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![0, 0, 0, 4, 6, 0].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, false, false, true, true] ); } diff --git a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs index 8bb870c3e..d999e5faa 100644 --- a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs @@ -1,20 +1,23 @@ #[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; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; -use crate::types::Min; +use crate::types::OptimizationValue; #[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,24 +27,32 @@ 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(); - assert_eq!(target.lengths(), &[3, 1, 1, 2, 2, 1]); - assert_eq!(target.weights(), &[3, 1, 1, 2, 2, 1]); - assert_eq!(target.deadlines(), &[5, 5, 5, 5, 5, 5]); - assert_eq!(target.num_tasks(), source.num_elements()); + assert_eq!(target.inner().lengths(), &[3, 1, 1, 2, 2, 1]); + assert_eq!(target.inner().weights(), &[3, 1, 1, 2, 2, 1]); + assert_eq!(target.inner().deadlines(), &[5, 5, 5, 5, 5, 5]); + assert_eq!(target.inner().num_tasks(), source.num_elements()); } #[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!( - reduction.extract_solution(&vec![1, 2, 4, 5, 0, 3]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![1, 2, 4, 5, 0, 3].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, false, true, false, false] ); } @@ -49,23 +60,24 @@ 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(); + assert!(BruteForce::new().solve(target).unwrap().is_none()); let best = BruteForce::new() - .solve(target) + .solve(target.inner()) .unwrap() .expect("target should always have an optimal schedule"); - assert_eq!(target.evaluate(&best).unwrap(), Min(Some(6))); - assert!( - !crate::rules::AggregateReductionResult::extract_value( - &reduction, - target.evaluate(&best).unwrap() - ) - .0 + assert_eq!( + target.inner().evaluate(&best).unwrap(), + crate::types::Min(Some(6)) ); - assert!(reduction.extract_solution(&best).is_err()); + assert!(!target.evaluate(&best).unwrap().0); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&best) + .unwrap() + .is_valid()); } #[cfg(feature = "example-db")] @@ -80,18 +92,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 +118,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 +143,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::ReductionResult::target_problem(&reduction); let source_feasible = (0..1usize << n).any(|mask| { let bits = (0..n).map(|i| mask & (1 << i) != 0).collect(); source.evaluate(&bits).unwrap().0 @@ -146,32 +159,54 @@ fn test_partition_to_tardy_weight_all_small_configurations() { task }) .collect(); - let value = target.evaluate(&schedule).unwrap(); + let value = target.inner().evaluate(&schedule).unwrap(); if let Some(weight) = value.0 { optimum = optimum.min(weight); } - let certified = - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0; - let extracted = reduction.extract_solution(&schedule); - assert_eq!(extracted.is_ok(), certified); - if let Ok(bits) = extracted { + let certified = crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound(), + )) + .0; + if certified { + let bits = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&bits).unwrap().0); } } assert_eq!( - crate::rules::AggregateReductionResult::extract_value( - &reduction, - Min(Some(optimum)), - ) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(Some(optimum))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) .0, source_feasible ); - assert!(reduction.extract_solution(&vec![]).is_err()); - assert!(reduction - .extract_solution(&vec![n as usize; n as usize]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![n as usize; n as usize]), + Err(InvalidConfiguration(_)) + )); assert!( - !crate::rules::AggregateReductionResult::extract_value(&reduction, Min(None),).0 + !crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0 ); } } @@ -188,16 +223,30 @@ 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(); - assert_eq!(value, Min(Some(expected))); + ReduceTo::>::reduce_to(&source).unwrap(); + let value = reduction + .target_problem() + .inner() + .evaluate(&schedule) + .unwrap(); + assert_eq!(value, crate::types::Min(Some(expected))); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value).0, + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0, balanced ); - let extracted = reduction.extract_solution(&schedule); - assert_eq!(extracted.is_ok(), balanced); - if let Ok(bits) = extracted { + if balanced { + let bits = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), schedule.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&bits).unwrap().0); } } diff --git a/src/unit_tests/rules/partition_subsetsum.rs b/src/unit_tests/rules/partition_subsetsum.rs index 2e87577e8..60f253066 100644 --- a/src/unit_tests/rules/partition_subsetsum.rs +++ b/src/unit_tests/rules/partition_subsetsum.rs @@ -1,6 +1,9 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::traits::Problem; #[test] fn test_partition_to_subsetsum_closed_loop() { @@ -46,12 +49,6 @@ fn test_partition_to_subsetsum_odd_total() { // No witness should exist for the target 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" - ); } #[test] @@ -72,7 +69,8 @@ fn test_partition_to_subsetsum_rejects_wrong_solution_length() { let source = Partition::new(vec![1, 1, 2, 2]).unwrap(); let reduction = ReduceTo::::reduce_to(&source).expect("reduction should succeed"); - assert!(reduction - .extract_solution(&vec![false, true, false]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![false, true, false]), + Err(InvalidConfiguration(_)) + )); } diff --git a/src/unit_tests/rules/partition_sumofsquarespartition.rs b/src/unit_tests/rules/partition_sumofsquarespartition.rs index 5f5a46fc6..2ee928494 100644 --- a/src/unit_tests/rules/partition_sumofsquarespartition.rs +++ b/src/unit_tests/rules/partition_sumofsquarespartition.rs @@ -1,7 +1,10 @@ use super::*; use crate::models::misc::{Partition, SumOfSquaresPartition}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Min; @@ -31,12 +34,13 @@ fn test_partition_to_sumofsquarespartition_closed_loop() { 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" - ); + let recovered = reduction_no_even + .recover_result( + &source_no_even, + SolveOutcome::optimal(target_no_even, witness.clone()).unwrap(), + ) + .unwrap(); + assert_eq!(recovered, SolveOutcome::Infeasible); } // Confirm the source is genuinely NO via direct solve. let direct_witness = solver.solve(&source_no_even).unwrap(); @@ -48,11 +52,13 @@ 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" - ); + let recovered = reduction_no_odd + .recover_result( + &source_no_odd, + SolveOutcome::optimal(target_no_odd, witness.clone()).unwrap(), + ) + .unwrap(); + assert_eq!(recovered, SolveOutcome::Infeasible); } assert!(solver.solve(&source_no_odd).unwrap().is_none()); } @@ -107,18 +113,24 @@ 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()); + let mapped = reduction.map_solution(witness).unwrap(); + assert_eq!(mapped.len(), source.num_elements()); assert_eq!( - extracted, + mapped, witness[..source.num_elements()] .iter() .map(|&value| value != 0) .collect::>() ); - assert!( - !source.evaluate(&extracted).unwrap().0, - "singleton Partition: extracted witness must yield Or(false)" + assert!(!source.evaluate(&mapped).unwrap().0); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(target, witness.clone()).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible ); } @@ -142,7 +154,14 @@ fn test_partition_to_sumofsquarespartition_solution_extraction_identity() { .collect(); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (witness).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted, witness.iter().map(|&value| value != 0).collect::>() @@ -153,5 +172,34 @@ fn test_partition_to_sumofsquarespartition_solution_extraction_identity() { ); } - assert!(reduction.extract_solution(&vec![0]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![0]), + Err(InvalidConfiguration(_)) + )); +} + +#[test] +fn test_partition_to_sumofsquarespartition_feasible_target_incumbents() { + // sizes [3, 1, 1, 2, 2, 1], S = 10. + let (source, reduction) = reduce_partition(&[3, 1, 1, 2, 2, 1]); + let target = reduction.target_problem(); + + // {3, 2} | {1, 1, 2, 1}: 5^2 + 5^2 = 50 = S^2 / 2, already a balanced partition. + let balanced = vec![0, 1, 1, 0, 1, 1]; + assert_eq!(target.evaluate(&balanced).unwrap(), Min(Some(50))); + assert_eq!( + reduction.recover_result(&source, SolveOutcome::feasible(target, balanced).unwrap()), + Ok(SolveOutcome::Feasible { + solution: vec![false, true, true, false, true, true], + evaluation: crate::types::Or(true), + }) + ); + + // {3, 1, 1, 2} | {2, 1}: 7^2 + 3^2 = 58 > 50 proves nothing about the source. + let unbalanced = vec![0, 0, 0, 0, 1, 1]; + assert_eq!(target.evaluate(&unbalanced).unwrap(), Min(Some(58))); + assert_eq!( + reduction.recover_result(&source, SolveOutcome::feasible(target, unbalanced).unwrap()), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); } diff --git a/src/unit_tests/rules/partitionintocliques_ilp.rs b/src/unit_tests/rules/partitionintocliques_ilp.rs index 564c1af27..c69615749 100644 --- a/src/unit_tests/rules/partitionintocliques_ilp.rs +++ b/src/unit_tests/rules/partitionintocliques_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; @@ -19,7 +20,14 @@ fn test_partitionintocliques_to_ilp_closed_loop() { let target_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("two disjoint edges form two cliques"); - let source_solution = reduction.extract_solution(&target_solution).unwrap(); + let source_solution = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&source_solution).unwrap(), Or(true)); } @@ -29,5 +37,8 @@ fn test_partitionintocliques_to_ilp_preserves_infeasibility() { let problem = PartitionIntoCliques::new(SimpleGraph::new(3, vec![]), 2); let reduction = ReduceTo::>::reduce_to(&problem).unwrap(); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } diff --git a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs index c3e5fb245..bed7d9089 100644 --- a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -1,8 +1,12 @@ 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::rules::ReductionResult; +use crate::solvers::SolveOutcome; use crate::topology::Graph; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; -use crate::types::Min; +use crate::types::OptimizationValue; #[test] fn test_partitionintocliques_target_bound_rejects_overflow() { @@ -18,16 +22,20 @@ 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), - (Min(Some(5)), true), - (Min(Some(6)), true), - (Min(Some(7)), false), + (crate::types::Min(None), false), + (crate::types::Min(Some(5)), true), + (crate::types::Min(Some(6)), true), + (crate::types::Min(Some(7)), false), ] { assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), + crate::types::Or(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,38 +57,38 @@ 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 layout = OrlinLayout::new(source.graph()); - 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); // Left clique on x_0, x_1, x_2, a_(0,1), a_(1,0) - assert!(target.graph().has_edge(0, 1)); - assert!(target.graph().has_edge(0, 2)); - assert!(target.graph().has_edge(1, 2)); - assert!(target.graph().has_edge(0, 6)); - assert!(target.graph().has_edge(1, 7)); + assert!(target.inner().graph().has_edge(0, 1)); + assert!(target.inner().graph().has_edge(0, 2)); + assert!(target.inner().graph().has_edge(1, 2)); + assert!(target.inner().graph().has_edge(0, 6)); + assert!(target.inner().graph().has_edge(1, 7)); // Right clique on y_0, y_1, y_2, b_(0,1), b_(1,0) - assert!(target.graph().has_edge(3, 4)); - assert!(target.graph().has_edge(3, 5)); - assert!(target.graph().has_edge(4, 5)); - assert!(target.graph().has_edge(3, 8)); - assert!(target.graph().has_edge(4, 9)); + assert!(target.inner().graph().has_edge(3, 4)); + assert!(target.inner().graph().has_edge(3, 5)); + assert!(target.inner().graph().has_edge(4, 5)); + assert!(target.inner().graph().has_edge(3, 8)); + assert!(target.inner().graph().has_edge(4, 9)); // Matching and gadget cross edges from the issue body - assert!(target.graph().has_edge(0, 3)); - assert!(target.graph().has_edge(1, 4)); - assert!(target.graph().has_edge(0, 4)); - assert!(target.graph().has_edge(0, 8)); - assert!(target.graph().has_edge(6, 4)); - assert!(target.graph().has_edge(6, 8)); + assert!(target.inner().graph().has_edge(0, 3)); + assert!(target.inner().graph().has_edge(1, 4)); + assert!(target.inner().graph().has_edge(0, 4)); + assert!(target.inner().graph().has_edge(0, 8)); + assert!(target.inner().graph().has_edge(6, 4)); + assert!(target.inner().graph().has_edge(6, 8)); let target_solution = edge_labels_from_clique_cover( - target.graph(), + target.inner().graph(), &[ vec![layout.x(0), layout.x(1), layout.y(0), layout.y(1)], vec![layout.x(2), layout.y(2)], @@ -101,9 +106,19 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_orlin_example_structure }, ], ); - assert_eq!(target.evaluate(&target_solution).unwrap(), Min(Some(6))); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + target.inner().evaluate(&target_solution).unwrap(), + crate::types::Min(Some(6)) + ); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![0, 0, 1] ); } @@ -111,13 +126,13 @@ 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 layout = OrlinLayout::new(source.graph()); let target_solution = edge_labels_from_clique_cover( - target.graph(), + target.inner().graph(), &[ { let mut clique = layout.left_vertices(); @@ -133,20 +148,16 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_unsat_extracts_invalid_ vec![layout.x(1), layout.y(1)], ], ); - assert_eq!(target.evaluate(&target_solution).unwrap(), Min(Some(4))); - assert_eq!( - reduction - .extract_solution(&target_solution) - .unwrap_err() - .to_string(), - "target cover does not certify the source clique bound" + target.inner().evaluate(&target_solution).unwrap(), + crate::types::Min(Some(4)) ); + + assert!(!target.evaluate(&target_solution).unwrap().0); } #[test] fn test_partitionintocliques_native_bounds_and_adjacency_semantics() { - use crate::rules::AggregateReductionResult; for (n, edges) in [ (0, vec![]), (1, vec![(0, 0)]), @@ -154,13 +165,18 @@ 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(); + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = ReductionResult::target_problem(&reduction); let layout = OrlinLayout::new(source.graph()); let mut cliques: Vec> = @@ -174,32 +190,55 @@ fn test_partitionintocliques_native_bounds_and_adjacency_semantics() { let mut right = layout.right_vertices(); right.push(layout.z_right()); cliques.push(right); - let witness = edge_labels_from_clique_cover(target.graph(), &cliques); - let value = target.evaluate(&witness).unwrap(); + let witness = edge_labels_from_clique_cover(target.inner().graph(), &cliques); + let value = target.inner().evaluate(&witness).unwrap(); assert_eq!( value, - Min(Some((n + layout.num_directed_pairs() + 2) as i64)) + crate::types::Min(Some((n + layout.num_directed_pairs() + 2) as i64)) ); assert_eq!( - AggregateReductionResult::extract_value(&reduction, value).0, + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )) + .0, n <= bound ); if n <= bound { - let decoded = reduction.extract_solution(&witness).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(decoded, (0..n).collect::>()); if bound <= n + 1 { assert!(source.evaluate(&decoded).unwrap().0); } } else { - assert!(reduction.extract_solution(&witness).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&witness) + .unwrap() + .is_valid()); } - assert!(reduction.extract_solution(&vec![0; witness.len()]).is_err()); - assert!(reduction - .extract_solution(&vec![0; witness.len() + 1]) - .is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0; witness.len()]) + .unwrap() + .is_valid()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![0; witness.len() + 1]), + Err(InvalidConfiguration(_)) + )); let q = layout.num_directed_pairs(); - assert_eq!(target.num_vertices(), 2 * n + 2 * q + 4); - assert_eq!(target.num_edges(), (n + q) * (n + q) + 4 * n + 7 * q + 2); + assert_eq!(target.inner().num_vertices(), 2 * n + 2 * q + 4); + assert_eq!( + target.inner().num_edges(), + (n + q) * (n + q) + 4 * n + 7 * q + 2 + ); } } } diff --git a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index 81b97ac33..e7e7b3011 100644 --- a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -3,6 +3,7 @@ use crate::models::graph::{BoundedComponentSpanningForest, PartitionIntoPathsOfL use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -92,7 +93,14 @@ fn test_partitionintopathsoflength2_to_boundedcomponentspanningforest_extract_so .expect("reduction should succeed"); let target_config = vec![0, 0, 0, 1, 1, 1]; - let extracted = result.extract_solution(&target_config).unwrap(); + let extracted = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); // Verify the extracted solution is valid in the source diff --git a/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs b/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs index 35544e23d..f4e89f5fd 100644 --- a/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs +++ b/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -44,7 +45,14 @@ fn test_partitionintopathsoflength2_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -69,7 +77,14 @@ fn test_solution_extraction() { 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, // x vars 1, 0, 1, 0, 0, 1, 0, 1, // y vars ]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -85,7 +100,14 @@ fn test_partitionintopathsoflength2_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), diff --git a/src/unit_tests/rules/partitionintotriangles_ilp.rs b/src/unit_tests/rules/partitionintotriangles_ilp.rs index 0173f42b3..6211c80ac 100644 --- a/src/unit_tests/rules/partitionintotriangles_ilp.rs +++ b/src/unit_tests/rules/partitionintotriangles_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -44,7 +45,14 @@ fn test_partitionintotriangles_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -63,7 +71,14 @@ fn test_solution_extraction() { // x_{v,g}: v0g0=1,v0g1=0, v1g0=1,v1g1=0, v2g0=1,v2g1=0, // v3g0=0,v3g1=1, v4g0=0,v4g1=1, v5g0=0,v5g1=1 let ilp_solution = vec![1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -79,6 +94,13 @@ fn test_partitionintotriangles_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs b/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs index ac4111f81..effad9d02 100644 --- a/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -26,7 +27,14 @@ fn test_pathconstrainednetworkflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } diff --git a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs index 3f54767c4..7fbff0786 100644 --- a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -47,7 +48,14 @@ fn test_precedenceconstrainedscheduling_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible for feasible instance"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&extracted).unwrap().0, @@ -60,8 +68,9 @@ fn test_precedenceconstrainedscheduling_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionPCSToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible scheduling instance should produce infeasible ILP" ); } @@ -75,7 +84,14 @@ fn test_precedenceconstrainedscheduling_to_ilp_extract_solution() { // Manually: task 0 at slot 0, task 1 at slot 0, task 2 at slot 1 // x_{0,0}=1, x_{0,1}=0, x_{1,0}=1, x_{1,1}=0, x_{2,0}=0, x_{2,1}=1 let ilp_solution = vec![1, 0, 1, 0, 0, 1]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 1]); assert!( problem.evaluate(&extracted).unwrap().0, diff --git a/src/unit_tests/rules/preemptivescheduling_ilp.rs b/src/unit_tests/rules/preemptivescheduling_ilp.rs index cd0a78b23..66f3d25fc 100644 --- a/src/unit_tests/rules/preemptivescheduling_ilp.rs +++ b/src/unit_tests/rules/preemptivescheduling_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -49,7 +50,14 @@ fn test_preemptivescheduling_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = p.evaluate(&extracted).unwrap(); assert!( value.0.is_some(), @@ -75,7 +83,14 @@ fn test_preemptivescheduling_to_ilp_medium_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = p.evaluate(&extracted).unwrap(); assert!( value.0.is_some(), @@ -114,7 +129,14 @@ fn test_preemptivescheduling_to_ilp_extract_solution() { let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p).expect("reduction should succeed"); let ilp_solution = vec![1, 0, 0, 1, 2]; // last element is M - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &p, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![vec![true, false], vec![false, true]]); assert_eq!(p.evaluate(&extracted).unwrap(), Min(Some(2))); } diff --git a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs index 252a965c7..59e203800 100644 --- a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::Min; @@ -33,14 +34,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. + // Three gadget terminals each add M = 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] @@ -72,7 +73,14 @@ fn test_prizecollectingsteinerforest_to_steinertree_extract_witness_canonical() .solve(target) .unwrap() .expect("target SteinerTree must be feasible"); - let source_witness = reduction.extract_solution(&target_witness).unwrap(); + let source_witness = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source_witness.0.len(), source.num_vertices()); assert_eq!(source_witness.1.len(), source.num_edges()); @@ -128,17 +136,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. @@ -189,3 +189,112 @@ fn test_prizecollectingsteinerforest_to_steinertree_path_with_omission() { "PCSF -> SteinerTree path-with-omission case", ); } + +#[test] +fn test_zero_prize_forest_through_steiner_tree_and_ilp() { + use crate::models::algebraic::ILP; + use crate::solvers::ILPSolver; + for (n, edges, costs, expected) in [(0, vec![], vec![], 0), (2, vec![(0, 1)], vec![5], 0)] { + let source = + PrizeCollectingSteinerForest::new(SimpleGraph::new(n, edges), vec![0; n], costs, 1, 1) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + assert_eq!(reduction.target_problem().num_terminals(), 1); + let ilp = ReduceTo::>::reduce_to(reduction.target_problem()).unwrap(); + let raw = ILPSolver::new().solve(ilp.target_problem()).unwrap(); + let tree = ilp + .recover_result( + reduction.target_problem(), + SolveOutcome::optimal(ilp.target_problem(), raw.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + let forest = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), tree.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert_eq!(source.evaluate(&forest).unwrap(), Min(Some(expected))); + assert_optimization_round_trip_from_optimization_target( + &source, + &reduction, + "zero-prize forest", + ); + } +} + +#[test] +fn 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::new(2, vec![(0, 1)]), + 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 + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), tree.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert_eq!(source.evaluate(&forest).unwrap(), Min(Some(expected))); + } + } +} + +#[test] +fn gadget_coefficients_report_native_integer_overflow() { + for (prize, beta, omega) in [(1, 1, i64::MAX), (i64::MAX, 2, 0), (i64::MAX, 1, 0)] { + let source = PrizeCollectingSteinerForest::new( + SimpleGraph::new(1, vec![]), + vec![prize], + vec![], + beta, + omega, + ) + .unwrap(); + assert!(matches!( + ReduceTo::>::reduce_to(&source), + Err(crate::rules::ReductionError::IntegerOverflow { .. }) + )); + } + // No prize gadget is constructed, so its inclusion cost is not needed. + let source = PrizeCollectingSteinerForest::new( + SimpleGraph::new(1, vec![]), + vec![0], + vec![], + 1, + i64::MAX, + ) + .unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + assert_eq!( + reduction.target_problem().evaluate(&vec![false]).unwrap(), + Min(Some(0)) + ); +} diff --git a/src/unit_tests/rules/quadraticassignment_ilp.rs b/src/unit_tests/rules/quadraticassignment_ilp.rs index c68fd73e1..8540933fc 100644 --- a/src/unit_tests/rules/quadraticassignment_ilp.rs +++ b/src/unit_tests/rules/quadraticassignment_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -35,7 +36,14 @@ fn test_quadraticassignment_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!( @@ -64,7 +72,14 @@ fn test_quadraticassignment_to_ilp_2x2() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!(ilp_value.is_valid()); @@ -80,7 +95,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = problem.evaluate(&extracted).unwrap(); assert!(metric.is_valid()); } @@ -104,7 +126,14 @@ fn test_quadraticassignment_to_ilp_rectangular() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!(ilp_value.is_valid()); diff --git a/src/unit_tests/rules/qubo_casts.rs b/src/unit_tests/rules/qubo_casts.rs index 9ac1eb48a..b27a26911 100644 --- a/src/unit_tests/rules/qubo_casts.rs +++ b/src/unit_tests/rules/qubo_casts.rs @@ -1,5 +1,6 @@ use super::*; use crate::rules::{ReduceTo, ReductionError, ReductionGraph, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::types::MAX_EXACT_F64_INTEGER; #[test] @@ -9,10 +10,20 @@ fn test_qubo_i64_to_f64_closed_loop() { assert_eq!( reduction.target_problem().matrix(), - &[vec![1.0, -2.0], vec![0.0, 3.0]] + QUBO::from_matrix(vec![vec![1.0, -2.0], vec![0.0, 3.0]]) + .unwrap() + .matrix() ); assert_eq!( - reduction.extract_solution(&vec![true, false]).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![true, false].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false] ); } diff --git a/src/unit_tests/rules/qubo_ilp.rs b/src/unit_tests/rules/qubo_ilp.rs index db5628fd4..7d333ae4a 100644 --- a/src/unit_tests/rules/qubo_ilp.rs +++ b/src/unit_tests/rules/qubo_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -37,7 +38,14 @@ fn test_qubo_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &qubo, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = qubo.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -56,7 +64,14 @@ fn test_qubo_to_ilp_diagonal_only() { assert!(ilp.constraints().is_empty()); let best = ILPSolver::new().solve(ilp).unwrap(); - let extracted = reduction.extract_solution(&best).unwrap(); + let extracted = reduction + .recover_result( + &qubo, + SolveOutcome::optimal(reduction.target_problem(), best.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, true]); } @@ -79,6 +94,13 @@ fn test_qubo_to_ilp_3var() { assert_eq!(ilp.constraints().len(), 6); let best = ILPSolver::new().solve(ilp).unwrap(); - let extracted = reduction.extract_solution(&best).unwrap(); + let extracted = reduction + .recover_result( + &qubo, + SolveOutcome::optimal(reduction.target_problem(), best.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true]); } diff --git a/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs b/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs index 056c224de..82b347a16 100644 --- a/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs +++ b/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -28,7 +29,14 @@ fn test_rectilinearpicturecompression_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness).unwrap(), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -41,7 +49,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/reduction_path_parity.rs b/src/unit_tests/rules/reduction_path_parity.rs index d2011bcd4..3a27b5e88 100644 --- a/src/unit_tests/rules/reduction_path_parity.rs +++ b/src/unit_tests/rules/reduction_path_parity.rs @@ -9,6 +9,7 @@ use crate::rules::test_helpers::assert_optimization_round_trip_chain; use crate::rules::ReductionGraph; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -55,7 +56,13 @@ fn test_jl_parity_maxcut_to_spinglass_path() { let solver = BruteForce::new(); let target_solution = solver.solve(target).unwrap().unwrap(); - let source_solution = chain.extract_solution(&target_solution).unwrap(); + let source_solution = chain + .recover_result::, SpinGlass>( + &source, + SolveOutcome::optimal(target, target_solution).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); // Source solution should be valid let metric = source.evaluate(&source_solution).unwrap(); @@ -118,7 +125,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. @@ -131,7 +140,7 @@ fn test_jl_parity_factoring_to_spinglass_path() { // Verify reduction produces a valid SpinGlass problem assert!( - target.num_variables() > 0, + target.num_variables().unwrap() > 0, "SpinGlass should have variables" ); @@ -144,7 +153,14 @@ fn test_jl_parity_factoring_to_spinglass_path() { let ilp_solution = ilp_solver .solve(ilp) .expect("ILP solver should find factoring solution"); - let factoring_solution = reduction.extract_solution(&ilp_solution).unwrap(); + let factoring_solution = reduction + .recover_result( + &factoring, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = factoring.evaluate(&factoring_solution).unwrap(); assert!(metric.unwrap(), "Factoring->ILP solution must be valid"); } diff --git a/src/unit_tests/rules/registersufficiency_ilp.rs b/src/unit_tests/rules/registersufficiency_ilp.rs index beaef71d7..163b1adfe 100644 --- a/src/unit_tests/rules/registersufficiency_ilp.rs +++ b/src/unit_tests/rules/registersufficiency_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::misc::RegisterSufficiency; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Or; @@ -50,7 +51,14 @@ fn test_register_sufficiency_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("feasible register-sufficiency instance should yield a feasible ILP"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); let mut sorted = extracted.clone(); @@ -63,8 +71,9 @@ fn test_register_sufficiency_to_ilp_infeasible() { let source = infeasible_example(); let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "register-sufficiency instance with bound one should be infeasible" ); } @@ -113,7 +122,14 @@ fn test_register_sufficiency_to_ilp_canonical_example_spec() { let target_config: Vec = serde_json::from_value(solution.target_config.clone()).unwrap(); assert_eq!(source.evaluate(&source_config).unwrap(), Or(true)); assert_eq!( - reduction.extract_solution(&target_config).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), source_config ); } diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index b05d19fa9..ed7a6cef5 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -10,7 +10,6 @@ fn entry_with(declarations: fn() -> ReductionParameterDeclarations) -> Reduction parameter_declarations_fn: declarations, module_path: module_path!(), reduce_fn: None, - reduce_aggregate_fn: None, turing: false, } } diff --git a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs index 9e811bbd4..12b3b76b1 100644 --- a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -39,7 +40,14 @@ fn test_resourceconstrainedscheduling_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -50,8 +58,9 @@ fn test_resourceconstrainedscheduling_to_ilp_infeasible() { let problem = ResourceConstrainedScheduling::new(1, vec![5], vec![vec![6], vec![6], vec![6]], 1).unwrap(); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible RCS should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs index da6346190..064a95e05 100644 --- a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -1,6 +1,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[test] fn test_rootedtreearrangement_to_rootedtreestorageassignment_closed_loop() { @@ -88,7 +89,14 @@ fn test_rootedtreearrangement_to_rootedtreestorageassignment_solution_extraction // Target solution: parent array [0, 0] means tree rooted at 0 with 1->0 let target_config = vec![0, 0]; - let source_config = reduction.extract_solution(&target_config).unwrap(); + let source_config = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Source config should be [parent_array | identity_mapping] = [0, 0, 0, 1] assert_eq!(source_config, vec![0, 0, 0, 1]); diff --git a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs index 737aec834..eb16c787c 100644 --- a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs +++ b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -37,14 +38,23 @@ fn test_rootedtreestorageassignment_to_ilp_bf_vs_ilp() { match ilp_result { Ok(ilp_solution) => { - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!(ilp_value.0, "ILP solution should be feasible"); assert!(bf_value.0, "BF should also find feasible solution"); } - Err(_) => { + Err(crate::solvers::ILPSolveError::Infeasible) => { assert!(!bf_value.0, "both should agree on infeasibility"); } + Err(error) => panic!("ILP execution failed: {error}"), } } @@ -66,7 +76,11 @@ fn test_rootedtreestorageassignment_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); let ilp_result = ilp_solver.solve(reduction.target_problem()); assert!(bf_witness.is_none(), "source should be infeasible"); - assert!(ilp_result.is_err(), "reduced ILP should also be infeasible"); + assert_eq!( + ilp_result, + Err(crate::solvers::ILPSolveError::Infeasible), + "reduced ILP should also be infeasible" + ); } #[test] @@ -78,7 +92,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 3); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/ruralpostman_ilp.rs b/src/unit_tests/rules/ruralpostman_ilp.rs index ac981b878..d8ef4538b 100644 --- a/src/unit_tests/rules/ruralpostman_ilp.rs +++ b/src/unit_tests/rules/ruralpostman_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -23,7 +24,14 @@ fn test_ruralpostman_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0.is_some()); } @@ -49,7 +57,14 @@ fn test_ruralpostman_to_ilp_optimization() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = source.evaluate(&extracted).unwrap(); assert!(ilp_value.0.is_some(), "ILP solution must be valid"); @@ -79,7 +94,14 @@ fn test_ruralpostman_empty_required_set_extracts_zero_multiplicities() { ); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0]); assert_eq!(source.evaluate(&extracted).unwrap().0, Some(0)); diff --git a/src/unit_tests/rules/sat_circuitsat.rs b/src/unit_tests/rules/sat_circuitsat.rs index 259064674..c93b6166b 100644 --- a/src/unit_tests/rules/sat_circuitsat.rs +++ b/src/unit_tests/rules/sat_circuitsat.rs @@ -3,6 +3,7 @@ use crate::models::formula::{CNFClause, CircuitSAT, Satisfiability}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[test] fn test_sat_to_circuitsat_closed_loop() { @@ -62,7 +63,14 @@ fn test_sat_to_circuitsat_single_literal_clause() { .solve(result.target_problem()) .unwrap() .expect("CircuitSAT should have a satisfying solution"); - let extracted = result.extract_solution(&target_solution).unwrap(); + let extracted = result + .recover_result( + &sat, + SolveOutcome::optimal(result.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true]); } diff --git a/src/unit_tests/rules/sat_coloring.rs b/src/unit_tests/rules/sat_coloring.rs index 42fc5e598..d61a824d1 100644 --- a/src/unit_tests/rules/sat_coloring.rs +++ b/src/unit_tests/rules/sat_coloring.rs @@ -1,10 +1,11 @@ use super::*; +use crate::solvers::SolveOutcome; +include!("../jl_helpers.rs"); use crate::models::formula::CNFClause; use crate::solvers::BruteForce; use crate::topology::Graph; use crate::traits::Problem; use crate::variant::K3; -include!("../jl_helpers.rs"); #[test] fn test_constructor_basic_structure() { @@ -84,7 +85,14 @@ fn test_unsatisfiable_formula() { // OR no valid coloring exists that extracts to a satisfying SAT assignment let mut found_satisfying = false; for sol in &solutions { - let sat_sol = reduction.extract_solution(sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); if sat.is_satisfying(&sat_sol) { found_satisfying = true; break; @@ -201,7 +209,14 @@ fn test_single_literal_clauses() { let mut found_correct = false; for sol in &solutions { - let sat_sol = reduction.extract_solution(sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); if sat_sol == vec![true, true] { found_correct = true; break; @@ -282,7 +297,14 @@ fn test_manual_coloring_extraction() { let valid_coloring = vec![0, 1, 2, 0, 1]; assert_eq!(coloring.graph().num_vertices(), 5); - let extracted = reduction.extract_solution(&valid_coloring).unwrap(); + let extracted = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), valid_coloring.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // x1 should be true (1) because vertex 3 has color 0 which equals TRUE vertex's color assert_eq!(extracted, vec![true]); } @@ -298,14 +320,28 @@ fn test_extraction_with_different_color_assignment() { // Different valid coloring: TRUE=2, FALSE=0, AUX=1 // x1 must have color 2 (TRUE), NOT_x1 must have color 0 (FALSE) let coloring_permuted = vec![2, 0, 1, 2, 0]; - let extracted = reduction.extract_solution(&coloring_permuted).unwrap(); + let extracted = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), coloring_permuted.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // x1 should still be true because its color equals TRUE vertex's color assert_eq!(extracted, vec![true]); // Another permutation: TRUE=1, FALSE=2, AUX=0 // x1 has color 1 (TRUE), NOT_x1 has color 2 (FALSE) let coloring_permuted2 = vec![1, 2, 0, 1, 2]; - let extracted2 = reduction.extract_solution(&coloring_permuted2).unwrap(); + let extracted2 = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), coloring_permuted2.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted2, vec![true]); } @@ -335,7 +371,14 @@ fn test_jl_parity_sat_to_coloring() { let target_sol = ilp_solver .solve(target) .expect("ILP should find a coloring"); - let extracted = result.extract_solution(&target_sol).unwrap(); + let extracted = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), target_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let best_source: HashSet> = BruteForce::new() .find_all_witnesses(&source) .unwrap() diff --git a/src/unit_tests/rules/sat_ksat.rs b/src/unit_tests/rules/sat_ksat.rs index 4e4cfa7c5..ecc0f9ab4 100644 --- a/src/unit_tests/rules/sat_ksat.rs +++ b/src/unit_tests/rules/sat_ksat.rs @@ -1,9 +1,10 @@ use super::*; +use crate::solvers::SolveOutcome; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; use crate::traits::Problem; use crate::variant::K3; -include!("../jl_helpers.rs"); #[test] fn test_sat_to_3sat_exact_size() { @@ -159,7 +160,14 @@ fn test_sat_to_3sat_solution_extraction() { // Extract and verify solutions for ksat_sol in &ksat_solutions { - let sat_sol = reduction.extract_solution(ksat_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), (ksat_sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should only have original 2 variables assert_eq!(sat_sol.len(), 2); // Should satisfy original problem @@ -195,7 +203,14 @@ fn test_3sat_to_sat_solution_extraction() { let reduction = ReduceTo::::reduce_to(&ksat).expect("reduction should succeed"); let sol = vec![true, false, true]; - let extracted = reduction.extract_solution(&sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true]); } diff --git a/src/unit_tests/rules/sat_maximumindependentset.rs b/src/unit_tests/rules/sat_maximumindependentset.rs index 47b23dd0d..ba947f90f 100644 --- a/src/unit_tests/rules/sat_maximumindependentset.rs +++ b/src/unit_tests/rules/sat_maximumindependentset.rs @@ -1,10 +1,15 @@ use super::*; +use crate::models::decision::Decision; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::types::OptimizationValue; +include!("../jl_helpers.rs"); 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; -include!("../jl_helpers.rs"); #[test] fn test_boolvar_creation() { @@ -46,14 +51,14 @@ 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(); // Should have 1 vertex (one literal) - assert_eq!(is_problem.graph().num_vertices(), 1); + assert_eq!(is_problem.inner().graph().num_vertices(), 1); // No edges (single vertex can't form a clique) - assert_eq!(is_problem.graph().num_edges(), 0); + assert_eq!(is_problem.inner().graph().num_edges(), 0); } #[test] @@ -61,14 +66,14 @@ 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(); // Should have 2 vertices - assert_eq!(is_problem.graph().num_vertices(), 2); + assert_eq!(is_problem.inner().graph().num_vertices(), 2); // Should have 1 edge (between x1 and NOT x1) - assert_eq!(is_problem.graph().num_edges(), 1); + assert_eq!(is_problem.inner().graph().num_edges(), 1); // Maximum IS should have size 1 (can't select both) let solver = BruteForce::new(); @@ -82,17 +87,31 @@ 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) let is_sol = vec![true, false]; - let sat_sol = reduction.extract_solution(&is_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), is_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sat_sol, vec![true, false]); // x1=true, x2=false // Select vertex 1 (literal x2) let is_sol = vec![false, true]; - let sat_sol = reduction.extract_solution(&is_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), is_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sat_sol, vec![false, true]); // x1=false, x2=true } @@ -100,11 +119,18 @@ 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]; - let sat_sol = reduction.extract_solution(&is_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), is_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sat_sol, vec![false]); // x1=false (so NOT x1 is true) } @@ -112,13 +138,13 @@ 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(); // 3 vertices, 3 edges (complete graph K3) - assert_eq!(is_problem.graph().num_vertices(), 3); - assert_eq!(is_problem.graph().num_edges(), 3); + assert_eq!(is_problem.inner().graph().num_vertices(), 3); + assert_eq!(is_problem.inner().graph().num_edges(), 3); } #[test] @@ -134,12 +160,12 @@ 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(); - assert_eq!(is_problem.graph().num_vertices(), 3); - assert_eq!(is_problem.graph().num_edges(), 1); // Only the complement edge + assert_eq!(is_problem.inner().graph().num_vertices(), 3); + assert_eq!(is_problem.inner().graph().num_edges(), 1); // Only the complement edge } #[test] @@ -148,31 +174,31 @@ 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(); // IS should have vertices for literals in clauses - assert_eq!(is_problem.graph().num_vertices(), 4); // 2 + 2 literals + assert_eq!(is_problem.inner().graph().num_vertices(), 4); // 2 + 2 literals } #[test] 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(); - assert_eq!(is_problem.graph().num_vertices(), 0); - assert_eq!(is_problem.graph().num_edges(), 0); + assert_eq!(is_problem.inner().graph().num_vertices(), 0); + assert_eq!(is_problem.inner().graph().num_edges(), 0); assert_eq!(reduction.num_clauses(), 0); } #[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 +242,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) @@ -226,20 +253,12 @@ fn test_jl_parity_sat_to_independentset() { .collect(); for case in data["cases"].as_array().unwrap() { if sat_solutions.is_empty() { - let target_solution = BruteForce::new() + assert!(BruteForce::new() .solve(result.target_problem()) .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(), - ), - Or(false), - ); + .is_none()); } else { - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, &format!("SAT->IS [{label}]"), @@ -275,52 +294,78 @@ fn test_sat_to_independentset_all_certificates() { ], ); let reduction = - ReduceTo::>::reduce_to(&source).unwrap(); + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = reduction.target_problem(); assert!(std::ptr::eq( target, - crate::rules::AggregateReductionResult::target_problem(&reduction) + crate::rules::ReductionResult::target_problem(&reduction) )); let mut accepted = false; for mask in 0..(1usize << target.num_vertices()) { let config: Vec = (0..target.num_vertices()) .map(|i| mask & (1 << i) != 0) .collect(); - let value = target.evaluate(&config).unwrap(); - let certificate = value == Max(Some(2)); + let value = target.inner().evaluate(&config).unwrap(); + let certificate = value == crate::types::Max(Some(2)); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), - Or(certificate) + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(certificate) ); - match reduction.extract_solution(&config) { - Ok(assignment) => { - assert!(certificate); - assert_eq!(source.evaluate(&assignment).unwrap(), Or(true)); - accepted = true; - } - Err(_) => assert!(!certificate), + if certificate { + let assignment = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), config.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert_eq!( + source.evaluate(&assignment).unwrap(), + crate::types::Or(true) + ); + accepted = true; } } assert_eq!( accepted, BruteForce::new().solve(&source).unwrap().is_some() ); - assert!(reduction - .extract_solution(&vec![false; target.num_vertices() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![false; target.num_vertices() + 1]), + Err(InvalidConfiguration(_)) + )); } } 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(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), vec![].clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false; num_vars] ); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Max(None)), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Max(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(false) ); } } diff --git a/src/unit_tests/rules/sat_minimumdominatingset.rs b/src/unit_tests/rules/sat_minimumdominatingset.rs index c6db9615d..b28c9ef8d 100644 --- a/src/unit_tests/rules/sat_minimumdominatingset.rs +++ b/src/unit_tests/rules/sat_minimumdominatingset.rs @@ -1,56 +1,69 @@ use super::*; +use crate::models::decision::Decision; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; +use crate::traits::Problem; +use crate::types::OptimizationValue; +include!("../jl_helpers.rs"); 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; -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(); // Should have 3 vertices (variable gadget) + 1 clause vertex = 4 vertices - assert_eq!(ds_problem.graph().num_vertices(), 4); + assert_eq!(ds_problem.inner().graph().num_vertices(), 4); // Edges: 3 for triangle + 1 from positive literal to clause = 4 // Triangle edges: (0,1), (0,2), (1,2) // Clause edge: (0, 3) since x1 positive connects to clause vertex - assert_eq!(ds_problem.graph().num_edges(), 4); + assert_eq!(ds_problem.inner().graph().num_edges(), 4); } #[test] 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(); // 2 variables * 3 = 6 gadget vertices + 1 clause vertex = 7 - assert_eq!(ds_problem.graph().num_vertices(), 7); + assert_eq!(ds_problem.inner().graph().num_vertices(), 7); // Edges: // - 3 edges for first triangle: (0,1), (0,2), (1,2) // - 3 edges for second triangle: (3,4), (3,5), (4,5) // - 2 edges from literals to clause: (0,6), (3,6) - assert_eq!(ds_problem.graph().num_edges(), 8); + assert_eq!(ds_problem.inner().graph().num_edges(), 8); } #[test] 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) // This dominates vertices 1, 2 (gadget) and vertex 3 (clause) let ds_sol = vec![true, false, false, false]; - let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), ds_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sat_sol, vec![true]); // x1 = true } @@ -58,13 +71,20 @@ 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) // This dominates vertices 0, 2 (gadget) and vertex 3 (clause) let ds_sol = vec![false, true, false, false]; - let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), ds_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sat_sol, vec![false]); // x1 = false } @@ -72,13 +92,20 @@ 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 // remains false in the extracted source assignment. let ds_sol = vec![true, false, false, false]; - let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), ds_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sat_sol, vec![true, false]); // x1 = true, x2 = false (from dummy) } @@ -88,24 +115,24 @@ 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(); // 3 vars * 3 = 9 gadget vertices + 2 clause vertices = 11 - assert_eq!(ds_problem.graph().num_vertices(), 11); + assert_eq!(ds_problem.inner().graph().num_vertices(), 11); } #[test] 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(); - assert_eq!(ds_problem.graph().num_vertices(), 0); - assert_eq!(ds_problem.graph().num_edges(), 0); + assert_eq!(ds_problem.inner().graph().num_vertices(), 0); + assert_eq!(ds_problem.inner().graph().num_edges(), 0); assert_eq!(reduction.num_clauses(), 0); assert_eq!(reduction.num_literals(), 0); } @@ -114,23 +141,23 @@ 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(); // 3 gadget vertices + 1 clause vertex = 4 - assert_eq!(ds_problem.graph().num_vertices(), 4); + assert_eq!(ds_problem.inner().graph().num_vertices(), 4); // Edges: // - 3 for triangle // - 2 from literals to clause (both positive and negative literals connect) - assert_eq!(ds_problem.graph().num_edges(), 5); + assert_eq!(ds_problem.inner().graph().num_edges(), 5); } #[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,43 +167,40 @@ 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]; - assert_eq!( - reduction.extract_solution(&ds_sol).unwrap_err().to_string(), - "target dominating set does not certify satisfiability" - ); + assert!(!reduction.target_problem().evaluate(&ds_sol).unwrap().0); } #[test] -fn test_extract_solution_rejects_unselected_variable_gadget() { +fn test_value_mapping_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!( - reduction - .extract_solution(&vec![false, false, false, false]) - .unwrap_err() - .to_string(), - "target dominating set does not certify satisfiability" + assert!( + !reduction + .target_problem() + .evaluate(&vec![false, false, false, false]) + .unwrap() + .0 ); } #[test] -fn test_extract_solution_rejects_selected_clause_vertex() { +fn test_value_mapping_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!( - reduction - .extract_solution(&vec![true, false, false, true]) - .unwrap_err() - .to_string(), - "target dominating set does not certify satisfiability" + assert!( + !reduction + .target_problem() + .evaluate(&vec![true, false, false, true]) + .unwrap() + .0 ); } @@ -184,18 +208,18 @@ 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(); // 2 * 3 = 6 gadget vertices + 1 clause = 7 - assert_eq!(ds_problem.graph().num_vertices(), 7); + assert_eq!(ds_problem.inner().graph().num_vertices(), 7); // Edges: // - 3 for first triangle: (0,1), (0,2), (1,2) // - 3 for second triangle: (3,4), (3,5), (4,5) // - 2 from negated literals to clause: (1,6), (4,6) - assert_eq!(ds_problem.graph().num_edges(), 8); + assert_eq!(ds_problem.inner().graph().num_edges(), 8); } #[test] @@ -233,8 +257,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) @@ -243,13 +268,12 @@ fn test_jl_parity_sat_to_dominatingset() { .collect(); for case in data["cases"].as_array().unwrap() { if sat_solutions.is_empty() { - let target_solution = BruteForce::new() + assert!(BruteForce::new() .solve(result.target_problem()) .unwrap() - .expect("SAT->DS: target should have an optimal solution"); - assert!(result.extract_solution(&target_solution).is_err()); + .is_none()); } else { - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &result, &format!("SAT->DS [{label}]"), @@ -279,39 +303,50 @@ 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(); + ReduceTo::>>::reduce_to(&source) + .unwrap(); let target = result.target_problem(); assert!(std::ptr::eq( target, - crate::rules::AggregateReductionResult::target_problem(&result) + crate::rules::ReductionResult::target_problem(&result) )); let mut accepted = false; for mask in 0..(1usize << target.num_vertices()) { let config: Vec<_> = (0..target.num_vertices()) .map(|i| mask & (1 << i) != 0) .collect(); - let value = target.evaluate(&config).unwrap(); - let certificate = value == Min(Some(result.target_size)); + let value = target.inner().evaluate(&config).unwrap(); + let certificate = value == crate::types::Min(Some(*result.target_problem().bound())); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&result, value), - Or(certificate) + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&result).bound() + )), + crate::types::Or(certificate) ); - match result.extract_solution(&config) { - Ok(x) => { - assert!(certificate); - assert_eq!(source.evaluate(&x).unwrap(), Or(true)); - accepted = true; - } - Err(_) => assert!(!certificate), + if certificate { + let x = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert_eq!(source.evaluate(&x).unwrap(), crate::types::Or(true)); + accepted = true; } } assert_eq!( accepted, BruteForce::new().solve(&source).unwrap().is_some() ); - assert!(result - .extract_solution(&vec![false; target.num_vertices() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&result) + .inner() + .evaluate(&vec![false; target.num_vertices() + 1]), + Err(InvalidConfiguration(_)) + )); } } @@ -320,7 +355,8 @@ 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); // Construction is compact. Extracting an i64::MAX-length source vector diff --git a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs index 28a7a9df2..8c9a50eb8 100644 --- a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs @@ -7,6 +7,7 @@ use crate::models::graph::IntegralFlowHomologousArcs; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn issue_example() -> Satisfiability { @@ -65,7 +66,14 @@ fn test_satisfiability_to_integralflowhomologousarcs_issue_example_assignment_en let satisfying_flow = reduction.encode_assignment(&satisfying_assignment); assert!(target.evaluate(&satisfying_flow).unwrap().0); assert_eq!( - reduction.extract_solution(&satisfying_flow).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), satisfying_flow.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), satisfying_assignment ); diff --git a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs index 3db2ebc6d..8e6552669 100644 --- a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs +++ b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs @@ -1,9 +1,14 @@ 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::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; #[test] fn test_satisfiability_to_maximum2satisfiability_structure() { @@ -12,19 +17,19 @@ fn test_satisfiability_to_maximum2satisfiability_structure() { vec![CNFClause::new(vec![1, -2, 3]), CNFClause::new(vec![-1, 2])], ); - let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); - let aggregate_target = crate::rules::AggregateReductionResult::target_problem(&reduction); + let aggregate_target = crate::rules::ReductionResult::target_problem(&reduction); assert!(std::ptr::eq(target, aggregate_target)); - assert_eq!(aggregate_target.num_clauses(), 30); - assert_eq!(target.num_vars(), 7); - assert_eq!(target.num_clauses(), 30); - assert_eq!(target.clauses()[0].literals, vec![1, 1]); - assert_eq!(target.clauses()[4].literals, vec![-1, 2]); - assert_eq!(target.clauses()[10].literals, vec![-1, -1]); - assert_eq!(target.clauses()[20].literals, vec![-1, -1]); + assert_eq!(aggregate_target.inner().num_clauses(), 30); + assert_eq!(target.inner().num_vars(), 7); + assert_eq!(target.inner().num_clauses(), 30); + assert_eq!(target.inner().clauses()[0].literals, vec![1, 1]); + assert_eq!(target.inner().clauses()[4].literals, vec![-1, 2]); + assert_eq!(target.inner().clauses()[10].literals, vec![-1, -1]); + assert_eq!(target.inner().clauses()[20].literals, vec![-1, -1]); } #[test] @@ -34,11 +39,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 reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); - assert_satisfaction_round_trip_from_optimization_target( + assert_satisfaction_round_trip_from_satisfaction_target( &source, &reduction, "SAT -> Maximum2Satisfiability closed loop", @@ -46,6 +51,7 @@ fn test_satisfiability_to_maximum2satisfiability_closed_loop() { assert_eq!( target + .inner() .evaluate(&BruteForce::new().solve(target).unwrap().unwrap()) .unwrap() .0, @@ -57,26 +63,34 @@ fn test_satisfiability_to_maximum2satisfiability_closed_loop() { fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { let source = Satisfiability::new(1, vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])]); - let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); + assert!(BruteForce::new().solve(target).unwrap().is_none()); assert_eq!( target - .evaluate(&BruteForce::new().solve(target).unwrap().unwrap()) + .inner() + .evaluate(&BruteForce::new().solve(target.inner()).unwrap().unwrap()) .unwrap() .0, Some(55) ); let target_solution = BruteForce::new() - .solve(target) + .solve(target.inner()) .unwrap() .expect("MAX-2-SAT target should always have a witness"); - assert!(reduction.extract_solution(&target_solution).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&target_solution) + .unwrap() + .is_valid()); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Max(Some(55))), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Max(Some(55))), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(false) ); } @@ -84,15 +98,17 @@ fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { fn test_satisfiability_to_maximum2satisfiability_empty_clause() { let source = Satisfiability::new(1, vec![CNFClause::new(vec![])]); - let reduction = - ReduceTo::::reduce_to(&source).expect("reduction should succeed"); + let reduction = ReduceTo::>::reduce_to(&source) + .expect("reduction should succeed"); let target = reduction.target_problem(); + assert!(BruteForce::new().solve(target).unwrap().is_none()); - assert_eq!(target.num_vars(), 4); - assert_eq!(target.num_clauses(), 20); + assert_eq!(target.inner().num_vars(), 4); + assert_eq!(target.inner().num_clauses(), 20); assert_eq!( target - .evaluate(&BruteForce::new().solve(target).unwrap().unwrap()) + .inner() + .evaluate(&BruteForce::new().solve(target.inner()).unwrap().unwrap()) .unwrap() .0, Some(13) @@ -143,36 +159,56 @@ fn test_satisfiability_to_maximum2satisfiability_every_target_witness() { } } for source in sources { - let reduction = ReduceTo::::reduce_to(&source).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem(); - let threshold = (target.num_clauses() / 10 * 7) as i64; + let threshold = (target.inner().num_clauses() / 10 * 7) as i64; let mut best = 0; - for bits in 0usize..(1 << target.num_vars()) { - let assignment = (0..target.num_vars()) + for bits in 0usize..(1 << target.inner().num_vars()) { + let assignment = (0..target.inner().num_vars()) .map(|i| bits & (1 << i) != 0) .collect(); - let value = target.evaluate(&assignment).unwrap(); + let value = target.inner().evaluate(&assignment).unwrap(); best = best.max(value.0.unwrap()); - let expected = value == Max(Some(threshold)); + let expected = value == crate::types::Max(Some(threshold)); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, value), - Or(expected) + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(expected) ); if expected { - let decoded = reduction.extract_solution(&assignment).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), assignment.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&decoded).unwrap().0); } else { - assert!(reduction.extract_solution(&assignment).is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&assignment) + .unwrap() + .is_valid()); } } let source_yes = BruteForce::new().solve(&source).unwrap().is_some(); assert_eq!(best == threshold, source_yes); - assert!(reduction - .extract_solution(&vec![false; target.num_vars() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction) + .inner() + .evaluate(&vec![false; target.inner().num_vars() + 1]), + Err(InvalidConfiguration(_)) + )); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&reduction, Max(None)), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Max(None)), + crate::rules::ReductionResult::target_problem(&reduction).bound() + )), + crate::types::Or(false) ); } } diff --git a/src/unit_tests/rules/satisfiability_naesatisfiability.rs b/src/unit_tests/rules/satisfiability_naesatisfiability.rs index e1ee71120..efd72e406 100644 --- a/src/unit_tests/rules/satisfiability_naesatisfiability.rs +++ b/src/unit_tests/rules/satisfiability_naesatisfiability.rs @@ -1,6 +1,9 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; #[test] @@ -68,8 +71,17 @@ fn test_solution_extraction_sentinel_false() { // target_solution: [1, 0, 1, 0] means x1=true, x2=false, x3=true, sentinel=false let extracted = reduction - .extract_solution(&vec![true, false, true, false]) - .unwrap(); + .recover_result( + &sat, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, false, true, false].clone(), + ) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true]); } @@ -81,22 +93,31 @@ fn test_solution_extraction_distinguishes_zero_assignment_from_malformed_input() assert_eq!( reduction - .extract_solution(&vec![false, false, false]) - .unwrap(), + .recover_result( + &sat, + SolveOutcome::optimal( + reduction.target_problem(), + vec![false, false, false].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![false, false] ); - let error = reduction.extract_solution(&vec![false, false]).unwrap_err(); - assert_eq!( - error.to_string(), - "target evaluation failed during extraction: invalid configuration: assignment length does not match the formula variables" - ); assert!(reduction - .extract_solution(&vec![false, false, false, false]) + .target_problem() + .evaluate(&vec![false, false]) .is_err()); - assert!(crate::rules::DynReductionResult::target_solution_from_json( + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![false, false, false, false]), + Err(InvalidConfiguration(_)) + )); + assert!(crate::rules::DynReductionResult::target_result_from_json( &reduction, - serde_json::json!([false, 2, false]) + serde_json::json!({"status": "optimal", "solution": [false, 2, false]}) ) .is_err()); } @@ -112,8 +133,17 @@ fn test_solution_extraction_sentinel_true() { // target_solution: [0, 1, 0, 1] means x1=false, x2=true, x3=false, sentinel=true // Complement: x1=true, x2=false, x3=true let extracted = reduction - .extract_solution(&vec![false, true, false, true]) - .unwrap(); + .recover_result( + &sat, + SolveOutcome::optimal( + reduction.target_problem(), + vec![false, true, false, true].clone(), + ) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, false, true]); } @@ -211,7 +241,14 @@ fn test_all_satisfying_assignments_map_back() { let nae_solutions = solver.find_all_witnesses(naesat).unwrap(); for nae_sol in &nae_solutions { - let sat_sol = reduction.extract_solution(nae_sol).unwrap(); + let sat_sol = reduction + .recover_result( + &sat, + SolveOutcome::optimal(reduction.target_problem(), (nae_sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sat_sol.len(), 2); assert!( sat.evaluate(&sat_sol).unwrap().0, diff --git a/src/unit_tests/rules/satisfiability_nontautology.rs b/src/unit_tests/rules/satisfiability_nontautology.rs index ab9608124..bb0b7abb5 100644 --- a/src/unit_tests/rules/satisfiability_nontautology.rs +++ b/src/unit_tests/rules/satisfiability_nontautology.rs @@ -2,6 +2,7 @@ use crate::models::formula::{CNFClause, NonTautology, Satisfiability}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[test] fn test_satisfiability_to_non_tautology_structure() { @@ -58,7 +59,14 @@ fn test_satisfiability_to_non_tautology_extract_solution_is_identity() { .expect("target should have a witness"); assert_eq!( - reduction.extract_solution(&target_solution).unwrap(), + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), target_solution ); } diff --git a/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs index eaab98e84..526714ed2 100644 --- a/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::misc::SchedulingToMinimizeWeightedCompletionTime; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -55,7 +56,14 @@ fn test_solution_extraction() { // y vars: index 6 sol[6] = 1; // y_{0,1} = 1 - let extracted = reduction.extract_solution(&sol).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1]); // Each on separate processor: C(0)=1, C(1)=2, WCT = 1*3 + 2*1 = 5 assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(5))); @@ -77,7 +85,14 @@ fn test_ilp_matches_bruteforce_small() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(ilp_value, bf_value); @@ -96,7 +111,14 @@ fn test_issue_example_closed_loop() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(47))); } @@ -109,7 +131,14 @@ fn test_single_task_single_processor() { let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(15))); } @@ -130,7 +159,14 @@ fn test_equal_tasks_multiple_processors() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(ilp_value, bf_value); diff --git a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs index 8838a3843..bbf4a3c81 100644 --- a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -50,7 +51,14 @@ fn test_schedulingwithindividualdeadlines_to_ilp_fixes_unused_slots() { .collect(); assert_eq!(witnesses.len(), 2); for witness in witnesses { - let source_solution = reduction.extract_solution(&witness).unwrap(); + let source_solution = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&source_solution).unwrap()); } } @@ -73,7 +81,14 @@ fn test_schedulingwithindividualdeadlines_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&extracted).unwrap().0, @@ -86,8 +101,9 @@ fn test_schedulingwithindividualdeadlines_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIDToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible instance should yield infeasible ILP" ); } @@ -102,7 +118,14 @@ fn test_schedulingwithindividualdeadlines_to_ilp_extract_solution() { // max_deadline=3: x_{j,t} at j*3+t // x_{0,0}=1, x_{0,1}=0, x_{0,2}=0, x_{1,0}=1, x_{1,1}=0, x_{1,2}=0, x_{2,0}=0, x_{2,1}=1, x_{2,2}=0 let ilp_solution = vec![1, 0, 0, 1, 0, 0, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0, 1]); assert!( problem.evaluate(&extracted).unwrap().0, diff --git a/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs b/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs index 617788d35..799972299 100644 --- a/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -18,7 +19,14 @@ fn test_sequencingtominimizemaximumcumulativecost_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert!( @@ -45,7 +53,14 @@ fn test_sequencingtominimizemaximumcumulativecost_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } @@ -56,6 +71,13 @@ fn test_sequencingtominimizemaximumcumulativecost_to_ilp_no_precedences() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } diff --git a/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs index baac86613..b889d0716 100644 --- a/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -1,7 +1,10 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; #[test] @@ -29,7 +32,14 @@ fn test_sequencingtominimizetardytaskweight_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_value, ilp_value); @@ -45,7 +55,14 @@ fn test_sequencingtominimizetardytaskweight_to_ilp_all_on_time() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); assert!(value.is_valid()); assert_eq!(value.0, Some(0)); @@ -69,7 +86,14 @@ fn test_sequencingtominimizetardytaskweight_to_ilp_optimal_ordering() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); let bf = BruteForce::new(); @@ -134,24 +158,40 @@ fn test_tardy_ilp_signed_permutations_and_all_indicators() { (0..count).all(|job| (bits[count * count + job] == 1) == expected[job]); let value = target.evaluate(&bits).unwrap(); assert_eq!(value.is_valid(), exact); - let extracted = reduction.extract_solution(&bits); - assert_eq!(extracted.is_ok(), exact); if exact { + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), bits.clone()) + .unwrap(), + ) + .map(|result| { + result.into_solution().expect( + "qualifying target result must recover a source solution", + ) + }); assert_eq!(value.value, source_value.0); assert_eq!(extracted.unwrap(), schedule); } } } - assert!(reduction - .extract_solution(&vec![0; target.num_vars() + 1]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![ + 0; + target.num_vars() + + 1 + ]), + Err(InvalidConfiguration(_)) + )); if count > 0 { - assert!(reduction - .extract_solution(&vec![0; target.num_vars()]) - .is_err()); - assert!(reduction - .extract_solution(&vec![2; target.num_vars()]) - .is_err()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0; target.num_vars()]) + .unwrap() + .is_valid()); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![2; target.num_vars()]) + .unwrap() + .is_valid()); } } } @@ -170,9 +210,15 @@ fn test_tardy_ilp_complete_small_binary_target_space() { .map(|i| i64::from(mask & (1 << i) != 0)) .collect(); let value = target.evaluate(&bits).unwrap(); - let extracted = reduction.extract_solution(&bits); - assert_eq!(extracted.is_ok(), value.is_valid()); - if let Ok(schedule) = extracted { + if value.is_valid() { + let schedule = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), bits.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&schedule).unwrap().0, value.value); feasible += 1; } @@ -199,7 +245,17 @@ fn test_tardy_ilp_numeric_boundaries_and_representability_errors() { reduction.target_problem().evaluate(&bits).unwrap().value, source.evaluate(&vec![0]).unwrap().0 ); - assert_eq!(reduction.extract_solution(&bits).unwrap(), vec![0]); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), bits.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![0] + ); } for (lengths, weights, deadlines) in [ (vec![i64::MAX, i64::MAX], vec![1, 1], vec![0, 0]), diff --git a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index 62b7503de..efc543d9d 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::models::misc::SequencingToMinimizeWeightedCompletionTime; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -45,7 +46,14 @@ fn test_extract_solution_encodes_schedule_as_lehmer_code() { // Completion times C0 = 3, C1 = 1 imply schedule [1, 0]. // y_{0,1} = 0 means task 1 before task 0. - let extracted = reduction.extract_solution(&vec![3, 1, 0]).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), vec![3, 1, 0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![1, 0]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(14))); } @@ -62,7 +70,14 @@ fn test_issue_example_closed_loop() { let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![1, 3, 0, 4, 2]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(46))); @@ -87,7 +102,14 @@ fn test_ilp_matches_bruteforce_optimum() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_metric = problem.evaluate(&extracted).unwrap(); assert_eq!(ilp_metric, brute_force_metric); @@ -104,8 +126,9 @@ fn test_cyclic_precedence_instance_is_infeasible() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); - assert!( - ILPSolver::new().solve(ilp).is_err(), + assert_eq!( + ILPSolver::new().solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible), "cyclic precedences should make the ILP infeasible" ); } @@ -154,7 +177,14 @@ fn test_ilp_pipeline_matches_source_optimum() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let source_solution = reduction.extract_solution(&ilp_solution).unwrap(); + let source_solution = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source_solution, vec![1, 3, 0, 4, 2]); assert_eq!(problem.evaluate(&source_solution).unwrap(), Min(Some(46))); diff --git a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs index 3768789a1..a7429692c 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::ILP; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -14,7 +15,14 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -33,7 +41,14 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -43,8 +58,9 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_infeasible() { let problem = SequencingToMinimizeWeightedTardiness::new(vec![10, 10], vec![1, 1], vec![1, 1], 0); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible STMWT should produce infeasible ILP" ); } @@ -62,6 +78,13 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_no_tardiness() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 30a4bcccc..035df68ef 100644 --- a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -39,7 +40,14 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_feasible_paper_example() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -49,8 +57,9 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_infeasible() { let problem = SequencingWithDeadlinesAndSetUpTimes::new(vec![2, 2], vec![1, 1], vec![0, 0], vec![0]); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible instance should produce infeasible ILP" ); } @@ -67,7 +76,14 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_setup_time_respected() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -94,7 +110,14 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_bf_vs_ilp_small() { "BF and ILP should agree on feasibility" ); if let Ok(ilp_solution) = ilp_result { - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } } @@ -113,6 +136,13 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_no_setup_same_compiler() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("should be feasible with no switches"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs index 4f4a6c7e6..7b78dbef1 100644 --- a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs +++ b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -59,7 +60,14 @@ fn test_sequencingwithinintervals_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&extracted).unwrap().0, @@ -72,8 +80,9 @@ fn test_sequencingwithinintervals_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible instance (forced overlap) should yield infeasible ILP" ); } @@ -105,7 +114,14 @@ fn test_sequencingwithinintervals_to_ilp_extract_solution() { // task 0 at offset 0, task 1 at offset 0 // vars: x_{0,0}=1, x_{0,1}=0, x_{1,0}=1, x_{1,1}=0 let ilp_solution = vec![1, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 0]); assert!( problem.evaluate(&extracted).unwrap().0, diff --git a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 2598ef274..9c983dcf3 100644 --- a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -29,7 +30,14 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -38,8 +46,9 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_infeasible() { // Two tasks that can't both fit: both need time 0-1, but overlap let problem = SequencingWithReleaseTimesAndDeadlines::new(vec![2, 2], vec![0, 0], vec![2, 2]); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible SWRTD should produce infeasible ILP" ); } @@ -51,8 +60,9 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_rejects_empty_start_window let problem = SequencingWithReleaseTimesAndDeadlines::new(vec![14], vec![0], vec![13]); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "a task longer than its release-deadline window must make the ILP infeasible" ); } @@ -64,6 +74,13 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_single_task() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("single-task ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/setsplitting_betweenness.rs b/src/unit_tests/rules/setsplitting_betweenness.rs index b43fa7fb1..9d9b23030 100644 --- a/src/unit_tests/rules/setsplitting_betweenness.rs +++ b/src/unit_tests/rules/setsplitting_betweenness.rs @@ -3,6 +3,7 @@ use crate::models::set::SetSplitting; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::rules::{ReduceTo, ReductionResult}; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; fn small_yes_instance() -> SetSplitting { SetSplitting::new(3, vec![vec![0, 1, 2]]) @@ -31,6 +32,31 @@ fn test_setsplitting_to_betweenness_closed_loop() { ); } +#[test] +fn repeated_elements_obey_set_semantics_including_singletons() { + for subset in [ + vec![0, 0, 1], + vec![1, 0, 1, 0, 1], + vec![0, 0], + vec![1, 1, 1, 1], + ] { + let source = SetSplitting::new(2, vec![subset]); + let reduction = ReduceTo::::reduce_to(&source).unwrap(); + if BruteForce::new().solve(&source).unwrap().is_some() { + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "set multiplicity", + ); + } else { + assert!(BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .is_none()); + } + } +} + #[test] fn test_setsplitting_to_betweenness_issue_yes_instance_structure() { let source = issue_yes_instance(); @@ -53,8 +79,17 @@ fn test_setsplitting_to_betweenness_issue_yes_instance_structure() { ); assert_eq!( reduction - .extract_solution(&vec![8, 2, 9, 0, 1, 4, 3, 6, 7, 5]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![8, 2, 9, 0, 1, 4, 3, 6, 7, 5].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, true, false, false] ); } diff --git a/src/unit_tests/rules/setsplitting_ilp.rs b/src/unit_tests/rules/setsplitting_ilp.rs index c4cb8d97c..29669eec0 100644 --- a/src/unit_tests/rules/setsplitting_ilp.rs +++ b/src/unit_tests/rules/setsplitting_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -52,7 +53,14 @@ fn test_setsplitting_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), @@ -70,8 +78,9 @@ fn test_setsplitting_to_ilp_infeasible() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); - assert!( - ilp_solver.solve(ilp).is_err(), + assert_eq!( + ilp_solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible), "ILP should be infeasible for unsplittable instance" ); } @@ -91,7 +100,14 @@ fn test_setsplitting_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_result = problem.evaluate(&extracted).unwrap(); assert_eq!(bf_result, ilp_result, "BruteForce and ILP must agree"); diff --git a/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs b/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs index 80ed3daef..d7c482a3f 100644 --- a/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs +++ b/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; @@ -29,7 +30,14 @@ fn test_shortestcommonsupersequence_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!( bf_value, ilp_value, @@ -50,7 +58,14 @@ fn test_shortestcommonsupersequence_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } @@ -64,7 +79,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), problem.max_length()); assert!(problem.evaluate(&extracted).unwrap().0.is_some()); } diff --git a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs index 8fe434aab..bd0ca8eae 100644 --- a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -56,15 +57,24 @@ fn test_shortestweightconstrainedpath_to_ilp_bf_vs_ilp() { match ilp_result { Ok(ilp_solution) => { - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); // Both should agree on the optimal length assert_eq!(ilp_value, bf_value); } - Err(_) => { + Err(crate::solvers::ILPSolveError::Infeasible) => { // ILP found no feasible solution; brute force should agree assert_eq!(bf_value, Min(None)); } + Err(error) => panic!("ILP execution failed: {error}"), } } @@ -77,7 +87,14 @@ fn test_solution_extraction() { // Handcrafted ILP solution: path 0->1->2 // a_{0,fwd}=1, a_{0,rev}=0, a_{1,fwd}=1, a_{1,rev}=0, o_0=0, o_1=1, o_2=2 let target_solution = vec![1, 0, 1, 0, 0, 1, 2]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true]); // length = 2 + 3 = 5 @@ -101,7 +118,14 @@ fn test_shortestweightconstrainedpath_to_ilp_trivial() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should solve the trivial s==t case"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![false, false]); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(0))); diff --git a/src/unit_tests/rules/sparsematrixcompression_ilp.rs b/src/unit_tests/rules/sparsematrixcompression_ilp.rs index ebc2628b0..866848ca6 100644 --- a/src/unit_tests/rules/sparsematrixcompression_ilp.rs +++ b/src/unit_tests/rules/sparsematrixcompression_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -63,7 +64,14 @@ fn test_smc_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/spinglass_maxcut.rs b/src/unit_tests/rules/spinglass_maxcut.rs index 75faf6fd8..1c51b2876 100644 --- a/src/unit_tests/rules/spinglass_maxcut.rs +++ b/src/unit_tests/rules/spinglass_maxcut.rs @@ -1,7 +1,8 @@ use super::*; +use crate::solvers::SolveOutcome; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; -include!("../jl_helpers.rs"); #[test] fn test_spinglass_to_maxcut_closed_loop() { @@ -35,7 +36,14 @@ fn test_solution_extraction_no_ancilla() { ReduceTo::>::reduce_to(&sg).expect("reduction should succeed"); let mc_sol = vec![false, true]; - let extracted = reduction.extract_solution(&mc_sol).unwrap(); + let extracted = reduction + .recover_result( + &sg, + SolveOutcome::optimal(reduction.target_problem(), mc_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![-1, 1]); } @@ -47,12 +55,26 @@ fn test_solution_extraction_with_ancilla() { // A false ancilla represents spin -1, so flip to normalize it to +1. let mc_sol = vec![false, true, false]; - let extracted = reduction.extract_solution(&mc_sol).unwrap(); + let extracted = reduction + .recover_result( + &sg, + SolveOutcome::optimal(reduction.target_problem(), mc_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![1, -1]); // A true ancilla already represents spin +1. let mc_sol = vec![false, true, true]; - let extracted = reduction.extract_solution(&mc_sol).unwrap(); + let extracted = reduction + .recover_result( + &sg, + SolveOutcome::optimal(reduction.target_problem(), mc_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![-1, 1]); } diff --git a/src/unit_tests/rules/spinglass_qubo.rs b/src/unit_tests/rules/spinglass_qubo.rs index 7894a553b..526de061b 100644 --- a/src/unit_tests/rules/spinglass_qubo.rs +++ b/src/unit_tests/rules/spinglass_qubo.rs @@ -1,8 +1,10 @@ use super::*; +use crate::solvers::SolveOutcome; +include!("../jl_helpers.rs"); use crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; -include!("../jl_helpers.rs"); +use crate::traits::Problem; #[test] fn test_spinglass_to_qubo_closed_loop() { @@ -68,7 +70,7 @@ fn test_reduction_structure() { let reduction2 = ReduceTo::>::reduce_to(&sg2).expect("reduction should succeed"); let qubo2 = reduction2.target_problem(); - assert_eq!(qubo2.num_variables(), 3); + assert_eq!(qubo2.num_variables().unwrap(), 3); } #[test] @@ -207,3 +209,38 @@ fn test_jl_parity_rule_qubo_to_spinglass() { assert_eq!(best_source, jl_parse_bool_configs_set(&case["best_source"])); } } + +#[test] +fn test_qubo_to_spinglass_preserves_small_nonzero_coefficients() { + // Exact powers of two distinguish algebraic coefficient preservation from + // backend tolerances. The two scales expose both former pruning branches: + // q < 1e-10, and q > 1e-10 but q/4 < 1e-10. + for magnitude in [2.0_f64.powi(-40), 2.0_f64.powi(-32)] { + for sign in [-1.0, 1.0] { + let q = sign * magnitude; + let source = QUBO::::from_matrix(vec![vec![q, q], vec![0.0, 0.0]]).unwrap(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + assert_eq!(target.fields(), &[3.0 * q / 4.0, q / 4.0]); + assert_eq!(target.interactions(), vec![((0, 1), q / 4.0)]); + let offset = 3.0 * q / 4.0; + for left in [-1, 1] { + for right in [-1, 1] { + let spins = vec![left, right]; + let bits = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), spins.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + let source_value = source.evaluate(&bits).unwrap().0.unwrap(); + let target_value = target.evaluate(&spins).unwrap().0.unwrap(); + assert_eq!(source_value, target_value + offset); + } + } + } + } +} diff --git a/src/unit_tests/rules/stackercrane_ilp.rs b/src/unit_tests/rules/stackercrane_ilp.rs index 849e3339c..8925af865 100644 --- a/src/unit_tests/rules/stackercrane_ilp.rs +++ b/src/unit_tests/rules/stackercrane_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; #[test] fn test_stackercrane_to_ilp_closed_loop() { @@ -60,7 +61,15 @@ fn test_stackercrane_to_ilp_all_binary_assignments() { for bits in 0..(1 << 12) { let solution = (0..12).map(|i| i64::from(bits & (1 << i) != 0)).collect(); if let Some(cost) = target.evaluate(&solution).unwrap().value { - let permutation = reduction.extract_solution(&solution).unwrap(); + let permutation = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( source.evaluate(&permutation).unwrap(), crate::types::Min(Some(cost + 1)) diff --git a/src/unit_tests/rules/steinertree_ilp.rs b/src/unit_tests/rules/steinertree_ilp.rs index 6b98b506c..58b7df86c 100644 --- a/src/unit_tests/rules/steinertree_ilp.rs +++ b/src/unit_tests/rules/steinertree_ilp.rs @@ -1,5 +1,8 @@ use super::*; +use crate::rules::ReductionResult; use crate::solvers::ILPSolver; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Min; @@ -10,6 +13,7 @@ fn lift(source: &SteinerTree, chosen: &[bool]) -> Vec { let root = source.terminals()[0]; let edges = source.graph().edges(); let mut witness = vec![0; tree_ilp_sizes(n, m, source.terminals().len()).unwrap().0]; + witness[m + root] = 1; let mut adj = vec![vec![]; n]; for (e, &(u, v)) in edges.iter().enumerate() { if chosen[e] { @@ -65,11 +69,21 @@ fn test_steinertree_to_ilp_closed_loop() { (4, vec![(0, 1), (2, 3)], vec![1, -10], vec![0, 1], 1), (3, vec![(0, 1), (1, 2)], vec![1, -5], vec![0, 1], -4), (3, vec![(0, 1), (1, 2)], vec![2, 3], vec![2, 0], 5), + (1, vec![], vec![], vec![0], 0), + (2, vec![(0, 1)], vec![5], vec![0], 0), + (2, vec![(0, 1)], vec![-5], vec![0], -5), ] { let source = SteinerTree::new(SimpleGraph::new(n, edges), weights, terminals); let reduction = ReduceTo::>::reduce_to(&source).unwrap(); let witness = ILPSolver::new().solve(reduction.target_problem()).unwrap(); - let decoded = reduction.extract_solution(&witness).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&decoded).unwrap(), Min(Some(optimum))); assert_eq!( reduction.target_problem().evaluate(&witness).unwrap().value, @@ -103,7 +117,17 @@ fn test_steiner_all_source_trees_lift_and_preserve_objective() { target.evaluate(&witness).unwrap().value, source.evaluate(&selected).unwrap().0 ); - assert_eq!(reduction.extract_solution(&witness).unwrap(), selected); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + selected + ); } } } @@ -118,19 +142,27 @@ fn test_steiner_every_small_raw_target_and_malformed_witness() { let witness: Vec<_> = (0..target.num_vars()).map(|v| (mask >> v) & 1).collect(); if target.evaluate(&witness).unwrap().is_valid() { feasible_count += 1; - let decoded = reduction.extract_solution(&witness).unwrap(); + let decoded = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&decoded).unwrap(), Min(Some(-3))); - } else { - assert!(reduction.extract_solution(&witness).is_err()); } } - for bad in [ - vec![], - vec![1; target.num_vars() + 1], - vec![2; target.num_vars()], - ] { - assert!(reduction.extract_solution(&bad).is_err()); + for bad in [vec![], vec![1; target.num_vars() + 1]] { + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&bad), + Err(InvalidConfiguration(_)) + )); } + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![2; target.num_vars()]) + .unwrap() + .is_valid()); assert_eq!(feasible_count, 1); } @@ -151,3 +183,27 @@ fn test_steiner_count_boundaries() { )); } } + +#[test] +fn test_single_terminal_tree_lifts_include_empty_tree() { + let source = SteinerTree::new(SimpleGraph::new(2, vec![(0, 1)]), vec![-5], vec![1]); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + for selected in [vec![false], vec![true]] { + let witness = lift(&source, &selected); + assert_eq!( + reduction.target_problem().evaluate(&witness).unwrap().value, + source.evaluate(&selected).unwrap().0 + ); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + selected + ); + } +} diff --git a/src/unit_tests/rules/steinertreeingraphs_ilp.rs b/src/unit_tests/rules/steinertreeingraphs_ilp.rs deleted file mode 100644 index d09885b41..000000000 --- a/src/unit_tests/rules/steinertreeingraphs_ilp.rs +++ /dev/null @@ -1,30 +0,0 @@ -use super::*; -use crate::models::algebraic::ILP; -use crate::rules::test_helpers::assert_bf_vs_ilp; -use crate::rules::ReduceTo; -use crate::topology::SimpleGraph; - -#[test] -fn test_steinertreeingraphs_to_ilp_closed_loop() { - // Path graph: 0 - 1 - 2, terminals {0, 2}, weights [1, 1] - // Optimal Steiner tree: use both edges (cost 2) - // ILP variables: 2 + 2*2*1 = 6 binary = 64 configs - let source = SteinerTreeInGraphs::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2)]), - vec![0, 2], - vec![1, 1], - ); - let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); - assert_bf_vs_ilp(&source, &reduction); -} - -#[test] -fn test_steinertreeingraphs_to_ilp_bf_vs_ilp() { - let source = SteinerTreeInGraphs::new( - SimpleGraph::new(3, vec![(0, 1), (1, 2)]), - vec![0, 2], - vec![1, 1], - ); - let reduction = ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); - crate::rules::test_helpers::assert_bf_vs_ilp(&source, &reduction); -} diff --git a/src/unit_tests/rules/stringtostringcorrection_ilp.rs b/src/unit_tests/rules/stringtostringcorrection_ilp.rs index 83354d82f..d32edca59 100644 --- a/src/unit_tests/rules/stringtostringcorrection_ilp.rs +++ b/src/unit_tests/rules/stringtostringcorrection_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -32,7 +33,14 @@ fn test_stringtostringcorrection_to_ilp_bf_vs_ilp() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -46,7 +54,14 @@ fn test_solution_extraction_delete() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -65,8 +80,9 @@ fn test_stringtostringcorrection_to_ilp_infeasible() { let reduction: ReductionSTSCToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solver = ILPSolver::new(); - assert!( - ilp_solver.solve(reduction.target_problem()).is_err(), + assert_eq!( + ilp_solver.solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "reduced ILP should also be infeasible" ); } @@ -86,6 +102,13 @@ fn test_stringtostringcorrection_to_ilp_swap() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs index 0fd893b32..bda1ec085 100644 --- a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::models::graph::StrongConnectivityAugmentation; use crate::rules::ReduceTo; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::DirectedGraph; use crate::traits::Problem; @@ -30,7 +31,14 @@ fn test_strongconnectivityaugmentation_to_ilp_closed_loop() { // Solve ILP let ilp_solver = ILPSolver::new(); let ilp_sol = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( source.evaluate(&extracted).unwrap().0, @@ -46,7 +54,14 @@ fn test_extract_solution() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), 2); assert!(source.evaluate(&extracted).unwrap().0); } @@ -59,7 +74,14 @@ fn test_trivial_single_vertex() { let ilp = reduction.target_problem(); let solver = ILPSolver::new(); let ilp_sol = solver.solve(ilp).expect("trivial should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap().0); } @@ -95,7 +117,10 @@ fn test_infeasible_budget() { ReduceTo::>::reduce_to(&source).expect("reduction should succeed"); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_err()); + assert_eq!( + solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] diff --git a/src/unit_tests/rules/subgraphisomorphism_ilp.rs b/src/unit_tests/rules/subgraphisomorphism_ilp.rs index cd7fe8f07..2a7f60929 100644 --- a/src/unit_tests/rules/subgraphisomorphism_ilp.rs +++ b/src/unit_tests/rules/subgraphisomorphism_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -40,7 +41,14 @@ fn test_subgraphisomorphism_to_ilp_closed_loop() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( problem.evaluate(&extracted).unwrap(), Or(true), @@ -70,7 +78,14 @@ fn test_subgraphisomorphism_to_ilp_path_in_cycle() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -84,7 +99,11 @@ fn test_subgraphisomorphism_to_ilp_infeasible() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); - assert!(result.is_err(), "K3 in path should be infeasible"); + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), + "K3 in path should be infeasible" + ); } #[test] @@ -98,7 +117,14 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } diff --git a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs index f6f4b2f9a..3e49fe70c 100644 --- a/src/unit_tests/rules/subsetsum_closestvectorproblem.rs +++ b/src/unit_tests/rules/subsetsum_closestvectorproblem.rs @@ -1,52 +1,77 @@ use super::*; use crate::models::algebraic::ClosestVectorProblem; +use crate::models::decision::Decision; +use crate::rules::ReductionResult; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; +use crate::types::OptimizationValue; #[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 source_solution = reduction.extract_solution(&target_solution).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 + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); 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 reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem(); - let expected: serde_json::Value = serde_json::json!({"basis": [[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1], [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1], [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, -2]], "target": [0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1]}); - assert_eq!(serde_json::to_value(target).unwrap(), expected); + assert_eq!(target.inner().num_basis_vectors(), 7); + assert_eq!(target.inner().ambient_dimension(), 12); + assert_eq!(&target.inner().target()[..8], &[0, 0, 0, 0, 1, 1, 1, 1]); 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 reduction = ReduceTo::>::reduce_to(&source).unwrap(); let target = reduction.target_problem(); for solution in [vec![1, 0, 0, 1, 0, 0, 0], vec![1, 1, 1, 0, 1, 1, 1]] { - assert_eq!(target.evaluate(&solution).unwrap().0, Some(2.0)); + assert_eq!(target.inner().evaluate(&solution).unwrap().0, Some(4)); assert!( source - .evaluate(&reduction.extract_solution(&solution).unwrap()) + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), solution.clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); @@ -56,48 +81,70 @@ 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() ); } #[test] -fn test_subsetsum_to_closestvectorproblem_large_integers_and_unit_pivots() { +fn test_subsetsum_to_closestvectorproblem_binary_carries_preserve_large_inputs() { 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(), + crate::types::Min(Some(1)) + ); + assert_eq!( + result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![true] ); - 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!( source - .evaluate(&result.extract_solution(&witness).unwrap()) + .evaluate( + &result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), witness.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") + ) .unwrap() .0 ); @@ -114,13 +161,13 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { (vec![2, 4], 5), ] { let source = SubsetSum::new(sizes, target_sum); - let result = ReduceTo::>::reduce_to(&source).unwrap(); + let result = ReduceTo::>::reduce_to(&source).unwrap(); let target = result.target_problem(); assert!(std::ptr::eq( target, - crate::rules::AggregateReductionResult::target_problem(&result) + crate::rules::ReductionResult::target_problem(&result) )); - let dimensions = target.num_basis_vectors(); + let dimensions = target.inner().num_basis_vectors(); let mut accepted = false; for index in 0..4usize.pow(dimensions as u32) { let mut index = index; @@ -131,19 +178,27 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { x }) .collect(); - let value = target.evaluate(&config).unwrap(); - let certificate = value == Min(Some(result.target_distance)); + let value = target.inner().evaluate(&config).unwrap(); + let certificate = + value == crate::types::Min(Some(i64::try_from(source.num_elements()).unwrap())); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&result, value), - Or(certificate) + crate::types::Or(OptimizationValue::meets_bound( + &(value), + crate::rules::ReductionResult::target_problem(&result).bound() + )), + crate::types::Or(certificate) ); - match result.extract_solution(&config) { - Ok(x) => { - assert!(certificate); - assert!(source.evaluate(&x).unwrap().0); - accepted = true; - } - Err(_) => assert!(!certificate), + if certificate { + let x = result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert!(source.evaluate(&x).unwrap().0); + accepted = true; } } assert_eq!( @@ -153,10 +208,18 @@ fn test_subsetsum_to_closestvectorproblem_all_small_coefficients() { .unwrap() .is_some() ); - assert!(result.extract_solution(&vec![0; dimensions + 1]).is_err()); + assert!(matches!( + ReductionResult::target_problem(&result) + .inner() + .evaluate(&vec![0; dimensions + 1]), + Err(InvalidConfiguration(_)) + )); assert_eq!( - crate::rules::AggregateReductionResult::extract_value(&result, Min(None)), - Or(false) + crate::types::Or(OptimizationValue::meets_bound( + &(crate::types::Min(None)), + crate::rules::ReductionResult::target_problem(&result).bound() + )), + crate::types::Or(false) ); } } @@ -175,3 +238,20 @@ fn test_subsetsum_to_closestvectorproblem_dimension_boundaries() { assert!(R::dimensions((1usize << 30) - 1, 1).is_ok()); } } + +#[test] +fn test_subset_sum_to_cvp_recovers_selected_items() { + let source = SubsetSum::new(vec![3u32, 5, 7], 8u32); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + let solution = + crate::solvers::customized::closest_vector_problem::solve(target.inner()).unwrap(); + assert_eq!(target.inner().evaluate(&solution).unwrap().0, Some(3)); + let recovered = reduction + .recover_result(&source, SolveOutcome::optimal(target, solution).unwrap()) + .unwrap() + .into_solution() + .unwrap(); + assert_eq!(recovered, vec![true, true, false]); + assert!(source.evaluate(&recovered).unwrap().0); +} diff --git a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs index 9caba9ab6..6078cce0b 100644 --- a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs +++ b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs @@ -6,7 +6,7 @@ use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; use crate::solvers::BruteForce; -#[cfg(feature = "example-db")] +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn issue_example_source() -> SubsetSum { @@ -48,16 +48,24 @@ fn test_subsetsum_to_integerexpressionmembership_extract_solution_matches_choice assert_eq!( reduction - .extract_solution(&issue_example_target_config()) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + issue_example_target_config().clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), issue_example_source_config() ); - assert_eq!( - reduction - .extract_solution(&vec![true, false, false, true]) - .unwrap(), - vec![true, false, false, true] - ); + // Selecting 1 and 8 does not reach the source target 11. + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![true, false, false, true]) + .unwrap() + .is_valid()); } #[test] diff --git a/src/unit_tests/rules/subsetsum_partition.rs b/src/unit_tests/rules/subsetsum_partition.rs index 663dbd54e..0b173f0e4 100644 --- a/src/unit_tests/rules/subsetsum_partition.rs +++ b/src/unit_tests/rules/subsetsum_partition.rs @@ -5,6 +5,7 @@ use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction use crate::rules::traits::ReductionResult; use crate::rules::ReduceTo; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; #[cfg(feature = "example-db")] use crate::traits::Problem; @@ -32,14 +33,32 @@ fn test_subsetsum_to_partition_sigma_greater_than_two_t_extraction() { assert_eq!(reduction.target_problem().sizes(), &[10, 20, 30, 40]); assert_eq!( reduction - .extract_solution(&vec![true, false, false, true]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, false, false, true].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, false] ); assert_eq!( reduction - .extract_solution(&vec![false, true, true, false]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![false, true, true, false].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, false, false] ); } @@ -52,8 +71,17 @@ fn test_subsetsum_to_partition_sigma_equals_two_t_extraction() { assert_eq!(reduction.target_problem().sizes(), &[3, 5, 2, 6]); assert_eq!( reduction - .extract_solution(&vec![true, true, false, false]) - .unwrap(), + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + vec![true, true, false, false].clone() + ) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), vec![true, true, false, false] ); } diff --git a/src/unit_tests/rules/sumofsquarespartition_ilp.rs b/src/unit_tests/rules/sumofsquarespartition_ilp.rs index 5827569bb..9a4cae80e 100644 --- a/src/unit_tests/rules/sumofsquarespartition_ilp.rs +++ b/src/unit_tests/rules/sumofsquarespartition_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Min; @@ -41,7 +42,14 @@ fn test_sumofsquarespartition_to_ilp_bf_vs_ilp() { ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let ilp_value = problem.evaluate(&extracted).unwrap(); assert_eq!( ilp_value, bf_value, @@ -58,13 +66,27 @@ fn test_solution_extraction() { // element 0→g0, element 1→g1, element 2→g1, element 3→g0 // x_{0,0}=1,x_{0,1}=0, x_{1,0}=0,x_{1,1}=1, x_{2,0}=0,x_{2,1}=1, x_{3,0}=1,x_{3,1}=0 - // Set x vars, leave z vars as 0 for extraction test + // Set assignment variables and their within-group products. let mut ilp_solution = vec![0_i64; 4 * 2 + 4 * 4 * 2]; ilp_solution[0] = 1; // x_{0,0} ilp_solution[3] = 1; // x_{1,1} ilp_solution[5] = 1; // x_{2,1} ilp_solution[6] = 1; // x_{3,0} - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + for (group, members) in [(0, [0, 3]), (1, [1, 2])] { + for i in members { + for j in members { + ilp_solution[8 + (i * 4 + j) * 2 + group] = 1; + } + } + } + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![0, 1, 1, 0]); } @@ -81,7 +103,14 @@ fn test_sumofsquarespartition_to_ilp_trivial() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let value = problem.evaluate(&extracted).unwrap(); // Optimal: {1},{2} -> 1+4=5 assert_eq!(value, Min(Some(5))); @@ -95,7 +124,14 @@ fn test_sumofsquarespartition_to_ilp_requires_exact_mip_optimality() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should prove the exact optimum"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Min(Some(74129))); } diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index aaec26d1e..8b3d5da9e 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -3,7 +3,8 @@ use crate::models::algebraic::{Comparison, ObjectiveSense, ILP}; use crate::models::misc::{ResourceConstrainedScheduling, ThreePartition}; use crate::models::set::ThreeDimensionalMatching; use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; -use crate::solvers::{BruteForce, ILPSolveError, ILPSolver}; +use crate::solvers::SolveOutcome; +use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -96,7 +97,14 @@ fn test_threedimensionalmatching_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("direct ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true, true, false, false]); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); @@ -112,8 +120,9 @@ fn test_threedimensionalmatching_to_ilp_infeasible_instance() { BruteForce::new().solve(&problem).unwrap().is_none(), "source instance should be infeasible" ); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "reduced ILP should be infeasible" ); } @@ -135,14 +144,16 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { let direct_solution = solver .solve(direct.target_problem()) .expect("direct ILP should solve"); - let direct_source = direct.extract_solution(&direct_solution).unwrap(); + let direct_source = direct + .recover_result( + &problem, + SolveOutcome::optimal(direct.target_problem(), direct_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&direct_source).unwrap(), Or(true)); - let indirect_solution = solver.solve(indirect.target_problem()); - assert!( - matches!(indirect_solution, Err(ILPSolveError::Extraction(_))), - "the numerically unstable indirect ILP should be rejected: {indirect_solution:?}" - ); assert!(direct.target_problem().num_vars() < indirect.target_problem().num_vars()); assert!( direct.target_problem().constraints().len() < indirect.target_problem().constraints().len() diff --git a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs index 9b3edb61b..92c4db023 100644 --- a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -2,7 +2,10 @@ use super::*; use crate::models::algebraic::MinimumWeightDecoding; use crate::models::set::ThreeDimensionalMatching; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; use crate::types::Min; @@ -117,7 +120,14 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_sentinel_q_zero() { for witness in &target_witnesses { // Sentinel codeword is the all-zero vector of length 1. assert_eq!(witness, &vec![false]); - let extracted = reduction.extract_solution(witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (witness).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Source has 0 triples → extracted vector has length 0. assert_eq!(extracted.len(), source.num_triples()); assert_eq!(extracted, Vec::::new()); @@ -139,7 +149,23 @@ 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(); + let extracted = reduction.map_solution(witness).unwrap(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(target, (witness).clone()).unwrap() + ) + .unwrap(), + SolveOutcome::Infeasible + ); + assert!(matches!( + reduction.recover_result( + &source, + SolveOutcome::feasible(target, (witness).clone()).unwrap() + ), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + )); assert_eq!(extracted.len(), source.num_triples()); // Empty triple set cannot cover non-empty universe. assert!( @@ -167,7 +193,14 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_solution_extraction_id assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (witness).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, *witness); assert!( source_witnesses.contains(&extracted), @@ -175,7 +208,8 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_solution_extraction_id ); } - assert!(reduction - .extract_solution(&vec![false, true, false]) - .is_err()); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![false, true, false]), + Err(InvalidConfiguration(_)) + )); } diff --git a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs index f9cb2913b..e254be13a 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs @@ -2,7 +2,10 @@ use super::*; use crate::models::misc::ThreePartition; use crate::models::set::ThreeDimensionalMatching; use crate::rules::ReduceTo; +use crate::rules::ReductionResult; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; +use crate::traits::EvaluationError::InvalidConfiguration; use crate::traits::Problem; fn reduce( @@ -36,6 +39,48 @@ fn test_threedimensionalmatching_to_threepartition_q1_overhead_and_bounds() { .all(|&size| 4 * i128::from(size) > bound && 2 * i128::from(size) < bound)); } +#[test] +fn empty_triple_sets_preserve_empty_and_nonempty_universe_truth() { + let entry = inventory::iter:: + .into_iter() + .find(|e| { + e.source_name == ThreeDimensionalMatching::NAME && e.target_name == ThreePartition::NAME + }) + .unwrap(); + let contract = entry.parameter_contract().unwrap(); + let transform = contract.transform().unwrap(); + for q in [0, 1] { + let (source, reduction) = reduce(q, &[]); + let bound = transform.evaluate(&source.parameters()).unwrap(); + assert!( + reduction.target_problem().num_elements() as u64 <= bound.get("num_elements").unwrap() + ); + assert!(reduction.target_problem().num_groups() as u64 <= bound.get("num_groups").unwrap()); + if q == 0 { + crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "empty matching domain", + ); + } else { + assert!(BruteForce::new().solve(&source).unwrap().is_none()); + assert!(BruteForce::new() + .solve(reduction.target_problem()) + .unwrap() + .is_none()); + } + } +} + +#[test] +fn target_sum_overflow_is_a_construction_error() { + let source = ThreeDimensionalMatching::new(20, (0..20).map(|i| (i, i, i)).collect()); + assert!(matches!( + ReduceTo::::reduce_to(&source), + Err(crate::rules::ReductionError::Construction { .. }) + )); +} + #[test] fn test_threedimensionalmatching_to_threepartition_q2_overhead_matches_vector() { let (_source, reduction) = reduce(2, &[(0, 0, 0), (1, 1, 1)]); @@ -64,7 +109,14 @@ fn test_threedimensionalmatching_to_threepartition_extracts_manual_q1_witness() .0 ); - let extracted = reduction.extract_solution(&target_config).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_config.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -81,7 +133,14 @@ fn test_threedimensionalmatching_to_threepartition_closed_loop_from_known_matchi .unwrap() .0 ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -99,7 +158,14 @@ fn test_threedimensionalmatching_to_threepartition_round_trip_q2_minimal_matchin .0 ); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted, vec![true, true]); assert!(source.evaluate(&extracted).unwrap().0); } @@ -123,3 +189,97 @@ fn test_threedimensionalmatching_to_threepartition_uncovered_coordinate_maps_to_ "target instance should be infeasible" ); } + +#[test] +fn test_threedimensionalmatching_to_threepartition_extracts_noncanonical_partition() { + let (source, reduction) = reduce(1, &[(0, 0, 0)]); + // A mathematically valid witness found independently by HiGHS. Both regular + // triples initially contain UPrime elements; filler triples mix pair IDs. + // Keep the witness fixed so this regression does not depend on the backend. + let witness = vec![ + 4, 0, 0, 4, 2, 1, 3, 3, 6, 0, 6, 4, 5, 5, 2, 1, 3, 1, 6, 2, 5, + ]; + assert!(reduction.target_problem().evaluate(&witness).unwrap().0); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert_eq!(extracted, vec![true]); + assert!(source.evaluate(&extracted).unwrap().0); + // Group labels have no mathematical significance. + let relabeled: Vec<_> = witness.iter().map(|group| 6 - group).collect(); + assert_eq!( + reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), relabeled.clone()).unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + extracted + ); +} + +#[test] +fn test_threedimensionalmatching_to_threepartition_equal_size_permutations() { + let (source, reduction) = reduce(2, &[(0, 0, 0), (0, 1, 1), (1, 0, 0), (1, 1, 1)]); + let target = reduction.target_problem(); + for matching in [[1, 0, 0, 1], [0, 1, 1, 0]] { + let mut witness = reduction.build_target_witness(&matching); + let mut exchanges = 0; + // Cumulative equal-size exchanges preserve a valid partition while + // exercising regular-item identities, mixed fillers, and dummy groups. + for left in 0..target.num_elements() { + for right in left + 1..target.num_elements() { + if target.sizes()[left] == target.sizes()[right] && witness[left] != witness[right] + { + witness.swap(left, right); + assert!(target.evaluate(&witness).unwrap().0); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), witness.clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + assert!(source.evaluate(&extracted).unwrap().0); + exchanges += 1; + } + } + } + assert!(exchanges > 0); + } +} + +#[test] +fn test_threedimensionalmatching_to_threepartition_rejects_invalid_partitions() { + let (_, reduction) = reduce(1, &[(0, 0, 0)]); + let valid = reduction.build_target_witness(&[1]); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&vec![]), + Err(InvalidConfiguration(_)) + )); + let mut invalid = valid.clone(); + invalid[0] = reduction.target_problem().num_groups(); + assert!(matches!( + ReductionResult::target_problem(&reduction).evaluate(&invalid), + Err(InvalidConfiguration(_)) + )); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&vec![0; valid.len()]) + .unwrap() + .is_valid()); + let mut wrong_sum = valid; + wrong_sum.swap(0, 2); + assert!(!ReductionResult::target_problem(&reduction) + .evaluate(&wrong_sum) + .unwrap() + .is_valid()); +} diff --git a/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs b/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs index 33bac8549..f17bfcb27 100644 --- a/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs @@ -2,6 +2,7 @@ use super::*; use crate::models::misc::{ResourceConstrainedScheduling, ThreePartition}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn reduce_three_partition( @@ -65,7 +66,14 @@ fn test_threepartition_to_resourceconstrainedscheduling_solution_extraction() { let target_solutions = solver.find_all_witnesses(target).unwrap(); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), source.num_elements()); let target_valid = target.evaluate(sol).unwrap(); let source_valid = source.evaluate(&extracted).unwrap(); diff --git a/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index c7b85f5c7..f130dc31b 100644 --- a/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -2,7 +2,7 @@ use super::*; use crate::models::misc::{SequencingWithReleaseTimesAndDeadlines, ThreePartition}; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; -use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::traits::Problem; fn reduce(sizes: Vec, bound: i64) -> (ThreePartition, ReductionThreePartitionToSRTD) { @@ -75,7 +75,14 @@ fn test_threepartition_to_sequencingwithreleasetimesanddeadlines_solution_extrac let target_solutions = solver.find_all_witnesses(target).unwrap(); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(extracted.len(), source.num_elements()); let source_valid = source.evaluate(&extracted).unwrap(); assert!( @@ -91,6 +98,6 @@ fn test_threepartition_to_sequencingwithreleasetimesanddeadlines_dims() { let target = reduction.target_problem(); // 7 tasks -> Lehmer dims [7,6,5,4,3,2,1] - let dims = target.dimensions(); + let dims = crate::solvers::cartesian_dimensions(target).unwrap(); assert_eq!(dims, vec![7, 6, 5, 4, 3, 2, 1]); } diff --git a/src/unit_tests/rules/timetabledesign_ilp.rs b/src/unit_tests/rules/timetabledesign_ilp.rs index 14fd854ac..efdf77e6d 100644 --- a/src/unit_tests/rules/timetabledesign_ilp.rs +++ b/src/unit_tests/rules/timetabledesign_ilp.rs @@ -1,6 +1,7 @@ use super::*; use crate::models::algebraic::ILP; use crate::rules::test_helpers::assert_bf_vs_ilp; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::traits::Problem; use crate::types::Or; @@ -42,7 +43,14 @@ fn test_timetabledesign_to_ilp_bf_vs_ilp() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(problem.evaluate(&extracted).unwrap(), Or(true)); } @@ -51,8 +59,9 @@ fn test_timetabledesign_to_ilp_infeasible() { // Craftsman 0 available only in period 0, but needs 2 periods of work with task 0 let problem = TimetableDesign::new(1, 1, 1, vec![vec![true]], vec![vec![true]], vec![vec![2]]); let reduction = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible TD should produce infeasible ILP" ); } @@ -72,7 +81,14 @@ fn test_timetabledesign_to_ilp_identity_extraction() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( extracted diff --git a/src/unit_tests/rules/traits.rs b/src/unit_tests/rules/traits.rs index bfc64c583..e82d3d9ff 100644 --- a/src/unit_tests/rules/traits.rs +++ b/src/unit_tests/rules/traits.rs @@ -1,15 +1,159 @@ +use crate::rules::traits::{DynReductionResult, ReduceTo, ReductionResult}; +use crate::solvers::{downcast_outcome, erase_outcome, SolveOutcome}; +use crate::traits::Problem; +use crate::types::Min; +use serde_json::json; + +#[test] +fn recovery_preserves_status_and_evaluates_the_mapped_solution() { + use crate::rules::traits::recover_preserving_status; + + let source = SourceProblem; + let target = TargetProblem; + let complement = + |solution: &Vec| Ok(solution.iter().map(|value| 1 - value).collect::>()); + assert_eq!( + recover_preserving_status( + &source, + SolveOutcome::optimal(&target, vec![1, 1]).unwrap(), + complement, + ) + .unwrap(), + SolveOutcome::Optimal { + solution: vec![0, 0], + evaluation: Min(Some(0)), + } + ); + assert_eq!( + recover_preserving_status( + &source, + SolveOutcome::feasible(&target, vec![0, 0]).unwrap(), + complement, + ) + .unwrap(), + SolveOutcome::Feasible { + solution: vec![1, 1], + evaluation: Min(Some(2)), + } + ); + assert_eq!( + recover_preserving_status( + &source, + crate::solvers::ProblemOutcome::::Infeasible, + |_| panic!("infeasibility has no witness to map"), + ) + .unwrap(), + SolveOutcome::Infeasible + ); +} + #[test] -fn test_traits_compile() { - // Traits should compile - actual tests in reduction implementations +fn recovery_propagates_mapping_and_evaluation_failures() { + use crate::rules::traits::recover_preserving_status; + use crate::rules::ExtractionError; + use crate::traits::EvaluationError; + + for outcome in [ + SolveOutcome::optimal(&TargetProblem, vec![1, 1]).unwrap(), + SolveOutcome::feasible(&TargetProblem, vec![1, 1]).unwrap(), + ] { + assert!(matches!( + recover_preserving_status(&SourceProblem, outcome.clone(), |_| { + Err(ExtractionError::InsufficientSolutionQuality) + }), + Err(ExtractionError::InsufficientSolutionQuality) + )); + assert!(matches!( + recover_preserving_status(&SourceProblem, outcome, |_| Ok(vec![2, 0])), + Err(ExtractionError::Evaluation( + EvaluationError::InvalidConfiguration(_) + )) + )); + } } -use crate::rules::traits::{ - validate_target_solution, AggregateReductionResult, DynAggregateReductionResult, ReduceTo, - ReduceToAggregate, ReductionResult, -}; -use crate::traits::Problem; -use crate::types::Sum; -use serde_json::json; +#[test] +fn recovery_by_source_evaluation_separates_a_proved_no_from_a_weak_incumbent() { + use crate::models::misc::Partition; + use crate::rules::traits::recover_by_source_evaluation; + use crate::rules::ExtractionError; + use crate::traits::EvaluationError; + use crate::types::Or; + + let source = Partition::new(vec![1, 1]).unwrap(); + let decode = |solution: &Vec| Ok(solution.iter().map(|&value| value == 1).collect()); + let balanced = vec![true, false]; + assert_eq!( + recover_by_source_evaluation( + &source, + SolveOutcome::optimal(&TargetProblem, vec![1, 0]).unwrap(), + decode, + ) + .unwrap(), + SolveOutcome::Optimal { + solution: balanced.clone(), + evaluation: Or(true), + } + ); + assert_eq!( + recover_by_source_evaluation( + &source, + SolveOutcome::feasible(&TargetProblem, vec![1, 0]).unwrap(), + decode, + ) + .unwrap(), + SolveOutcome::Feasible { + solution: balanced, + evaluation: Or(true), + } + ); + // Only an optimum whose decoding is source-invalid proves NO. + assert_eq!( + recover_by_source_evaluation( + &source, + SolveOutcome::optimal(&TargetProblem, vec![1, 1]).unwrap(), + decode, + ) + .unwrap(), + SolveOutcome::Infeasible + ); + assert!(matches!( + recover_by_source_evaluation( + &source, + SolveOutcome::feasible(&TargetProblem, vec![1, 1]).unwrap(), + decode, + ), + Err(ExtractionError::InsufficientSolutionQuality) + )); + assert_eq!( + recover_by_source_evaluation( + &source, + crate::solvers::ProblemOutcome::::Infeasible, + |_| panic!("infeasibility has no witness to map"), + ) + .unwrap(), + SolveOutcome::Infeasible + ); + + // Mapping and evaluation failures are errors under either status, never NO. + for outcome in [ + SolveOutcome::optimal(&TargetProblem, vec![1, 1]).unwrap(), + SolveOutcome::feasible(&TargetProblem, vec![1, 1]).unwrap(), + ] { + assert!(matches!( + recover_by_source_evaluation(&source, outcome.clone(), |_| { + Err(ExtractionError::invalid("undecodable")) + }), + Err(ExtractionError::InvalidTargetSolution(_)) + )); + assert!(matches!( + recover_by_source_evaluation(&source, outcome, |_| Ok(vec![true])), + Err(ExtractionError::Evaluation( + EvaluationError::InvalidConfiguration(_) + )) + )); + } +} #[derive(Clone)] struct SourceProblem; @@ -31,53 +175,47 @@ impl TargetProblem { impl Problem for SourceProblem { const NAME: &'static str = "Source"; type Solution = Vec; - type Value = i64; + type Value = Min; crate::problem_parameters![("num_variables", num_variables)]; - fn evaluate(&self, config: &Self::Solution) -> Result { + fn evaluate( + &self, + config: &Self::Solution, + ) -> Result { if config.len() != 2 || config.iter().any(|&value| value >= 2) { return Err(crate::traits::EvaluationError::InvalidConfiguration( "expected two binary target values".to_string(), )); } - Ok((config[0] + config[1]) as i64) + Ok(Min(Some((config[0] + config[1]) as i64))) } fn variant() -> Vec<(&'static str, &'static str)> { vec![("graph", "SimpleGraph"), ("weight", "i64")] } } -impl crate::solvers::BruteForceProblem for SourceProblem { - fn dimensions(&self) -> Vec { - vec![2, 2] - } -} - impl Problem for TargetProblem { const NAME: &'static str = "Target"; type Solution = Vec; - type Value = i64; + type Value = crate::types::Max; crate::problem_parameters![("num_variables", num_variables)]; - fn evaluate(&self, config: &Self::Solution) -> Result { + fn evaluate( + &self, + config: &Self::Solution, + ) -> Result { if config.len() != 2 || config.iter().any(|&value| value >= 2) { return Err(crate::traits::EvaluationError::InvalidConfiguration( "expected two binary target values".to_string(), )); } - Ok((config[0] + config[1]) as i64) + Ok(crate::types::Max(Some((config[0] + config[1]) as i64))) } fn variant() -> Vec<(&'static str, &'static str)> { vec![("graph", "SimpleGraph"), ("weight", "i64")] } } -impl crate::solvers::BruteForceProblem for TargetProblem { - fn dimensions(&self) -> Vec { - vec![2, 2] - } -} - #[derive(Clone)] struct TestReduction { target: TargetProblem, @@ -89,10 +227,33 @@ impl ReductionResult for TestReduction { fn target_problem(&self) -> &TargetProblem { &self.target } - fn extract_solution( + fn recover_result( + &self, + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + crate::solvers::SolveOutcome::Infeasible => { + Ok(crate::solvers::SolveOutcome::Infeasible) + } + crate::solvers::SolveOutcome::Optimal { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::optimal(source, solution)?) + } + crate::solvers::SolveOutcome::Feasible { solution, .. } => { + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::feasible(source, solution)?) + } + } + } +} + +impl TestReduction { + fn map_solution( &self, - target_config: &::Solution, - ) -> crate::rules::ExtractionResult<::Solution> { + target_config: &<::Target as Problem>::Solution, + ) -> crate::rules::ExtractionResult<<::Source as Problem>::Solution> + { Ok(target_config.to_vec()) } } @@ -112,69 +273,97 @@ fn test_reduction() { let result = >::reduce_to(&source) .expect("reduction should succeed"); let target = result.target_problem(); - assert_eq!(target.evaluate(&vec![1, 1]).unwrap(), 2); - assert_eq!(result.extract_solution(&vec![1, 0]).unwrap(), vec![1, 0]); -} - -#[test] -fn target_solution_validation_rejects_shape_and_domain_errors() { - let target = TargetProblem; - - assert_eq!(validate_target_solution(&target, &vec![1, 0]).unwrap(), 1); - assert!(validate_target_solution(&target, &vec![1]).is_err()); - assert!(validate_target_solution(&target, &vec![1, 0, 0]).is_err()); - assert!(validate_target_solution(&target, &vec![1, 2]).is_err()); + assert_eq!( + target.evaluate(&vec![1, 1]).unwrap(), + crate::types::Max(Some(2)) + ); + assert_eq!( + result + .recover_result( + &source, + crate::solvers::SolveOutcome::optimal(result.target_problem(), vec![1, 0].clone()) + .unwrap() + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"), + vec![1, 0] + ); } #[test] -fn aggregate_value_from_solution_keeps_evaluation_errors_distinct_from_false() { +fn decision_recovery_keeps_evaluation_errors_distinct_from_infeasible() { use crate::models::decision::Decision; 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]), 0, ); - let reduction = source.reduce_to_aggregate().unwrap(); - let value = reduction - .extract_value_from_solution_dyn(&vec![true, false]) + let edge = crate::rules::registry::reduction_entries() + .into_iter() + .find(|edge| { + edge.source_name == "DecisionMinimumVertexCover" + && edge.target_name == "MinimumVertexCover" + && (edge.source_variant_fn)() + == > as Problem>::variant() + }) .unwrap(); - assert_eq!(value.downcast_ref::(), Some(&Or(false))); + let step = (edge.reduce_fn.unwrap())(&source).unwrap(); + let target = step + .witness + .target_result_from_json(json!({"status": "optimal", "solution": [true, false]})) + .unwrap() + .0; + assert!(matches!( + step.witness.recover_result_dyn(&source, target).unwrap(), + SolveOutcome::Infeasible + )); assert!(matches!( - reduction.extract_value_from_solution_dyn(&vec![true]), + step.witness + .target_result_from_json(json!({"status": "optimal", "solution": [true]})), Err(ExtractionError::Evaluation(_)) )); assert!(matches!( - reduction.extract_value_from_solution_dyn(&vec![1i64, 0]), + step.witness.recover_result_dyn( + &source, + erase_outcome(SolveOutcome::Optimal { + solution: vec![1i64, 0], + evaluation: crate::types::Min(Some(1i64)), + }) + ), Err(ExtractionError::InvalidTargetSolution(_)) )); } #[derive(Clone)] -struct AggregateSourceProblem; +struct OffsetSourceProblem; #[derive(Clone)] -struct AggregateTargetProblem; +struct OffsetTargetProblem; + +thread_local! { + static TARGET_EVALUATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} -impl AggregateSourceProblem { +impl OffsetSourceProblem { fn num_variables(&self) -> usize { 1 } } -impl AggregateTargetProblem { +impl OffsetTargetProblem { fn num_variables(&self) -> usize { 1 } } -impl Problem for AggregateSourceProblem { - const NAME: &'static str = "AggregateSource"; +impl Problem for OffsetSourceProblem { + const NAME: &'static str = "OffsetSource"; type Solution = Vec; - type Value = Sum; + type Value = Min; crate::problem_parameters![("num_variables", num_variables)]; @@ -182,7 +371,7 @@ impl Problem for AggregateSourceProblem { &self, config: &Self::Solution, ) -> Result { - Ok(Sum(config.iter().sum::() as u64)) + Ok(Min(Some(config.iter().sum::() as u64))) } fn variant() -> Vec<(&'static str, &'static str)> { @@ -190,16 +379,10 @@ impl Problem for AggregateSourceProblem { } } -impl crate::solvers::BruteForceProblem for AggregateSourceProblem { - fn dimensions(&self) -> Vec { - vec![2] - } -} - -impl Problem for AggregateTargetProblem { - const NAME: &'static str = "AggregateTarget"; +impl Problem for OffsetTargetProblem { + const NAME: &'static str = "OffsetTarget"; type Solution = Vec; - type Value = Sum; + type Value = Min; crate::problem_parameters![("num_variables", num_variables)]; @@ -207,7 +390,8 @@ impl Problem for AggregateTargetProblem { &self, config: &Self::Solution, ) -> Result { - Ok(Sum(config.iter().sum::() as u64)) + TARGET_EVALUATIONS.with(|count| count.set(count.get() + 1)); + Ok(Min(Some(config.iter().sum::() as u64))) } fn variant() -> Vec<(&'static str, &'static str)> { @@ -215,64 +399,162 @@ impl Problem for AggregateTargetProblem { } } -impl crate::solvers::BruteForceProblem for AggregateTargetProblem { - fn dimensions(&self) -> Vec { - vec![2] - } -} - -struct TestAggregateReduction { - target: AggregateTargetProblem, +struct TestOffsetReduction { + target: OffsetTargetProblem, offset: u64, } -impl AggregateReductionResult for TestAggregateReduction { - type Source = AggregateSourceProblem; - type Target = AggregateTargetProblem; +impl ReductionResult for TestOffsetReduction { + type Source = OffsetSourceProblem; + type Target = OffsetTargetProblem; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_value(&self, target_value: Sum) -> Sum { - Sum(target_value.0 + self.offset) + fn recover_result( + &self, + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + Ok(match target { + SolveOutcome::Optimal { mut solution, .. } => { + solution[0] += self.offset as usize; + SolveOutcome::optimal(source, solution)? + } + SolveOutcome::Feasible { mut solution, .. } => { + solution[0] += self.offset as usize; + SolveOutcome::feasible(source, solution)? + } + SolveOutcome::Infeasible => SolveOutcome::Infeasible, + }) } } -impl ReduceToAggregate for AggregateSourceProblem { - type Result = TestAggregateReduction; +impl ReduceTo for OffsetSourceProblem { + type Result = TestOffsetReduction; - fn reduce_to_aggregate(&self) -> Result { - Ok(TestAggregateReduction { - target: AggregateTargetProblem, + fn reduce_to(&self) -> Result { + Ok(TestOffsetReduction { + target: OffsetTargetProblem, offset: 3, }) } } #[test] -fn test_aggregate_reduction_extracts_value() { - let source = AggregateSourceProblem; - let result = - >::reduce_to_aggregate( - &source, - ) +fn test_offset_reduction_recovers_shifted_result() { + let source = OffsetSourceProblem; + let result = >::reduce_to(&source) .expect("reduction should succeed"); - assert_eq!(result.extract_value(Sum(7)), Sum(10)); + assert_eq!( + result + .recover_result( + &source, + SolveOutcome::optimal(result.target_problem(), vec![7]).unwrap() + ) + .unwrap(), + SolveOutcome::Optimal { + solution: vec![10], + evaluation: Min(Some(10)) + } + ); } #[test] -fn test_dyn_aggregate_reduction_result_extracts_value() { - let result = TestAggregateReduction { - target: AggregateTargetProblem, +fn test_dyn_reduction_result_recovers_shifted_result() { + let result = TestOffsetReduction { + target: OffsetTargetProblem, offset: 2, }; - let dyn_result: &dyn DynAggregateReductionResult = &result; + let dyn_result: &dyn DynReductionResult = &result; assert!(dyn_result .target_problem_any() - .downcast_ref::() + .downcast_ref::() .is_some()); - assert_eq!(dyn_result.extract_value_dyn(json!(7)), json!(9)); + TARGET_EVALUATIONS.with(|count| count.set(0)); + let (target, target_json) = dyn_result + .target_result_from_json( + json!({"status": "optimal", "solution": [7], "evaluation": "Min(7)"}), + ) + .unwrap(); + assert_eq!( + target_json, + SolveOutcome::Optimal { + solution: json!([7]), + evaluation: "Min(7)".into(), + } + ); + assert_eq!(TARGET_EVALUATIONS.with(|count| count.get()), 1); + let recovered = dyn_result + .recover_result_dyn(&OffsetSourceProblem, target) + .unwrap(); + assert_eq!(TARGET_EVALUATIONS.with(|count| count.get()), 1); + assert_eq!( + downcast_outcome::, Min>(recovered).unwrap(), + SolveOutcome::Optimal { + solution: vec![9], + evaluation: Min(Some(9)) + } + ); +} + +#[test] +fn external_evaluation_is_optional_but_must_match_when_present() { + let result = TestOffsetReduction { + target: OffsetTargetProblem, + offset: 2, + }; + for status in ["optimal", "feasible"] { + let input = json!({"status": status, "solution": [7]}); + for evaluation in [None, Some(json!("Min(7)")), Some(json!("Min(+007)"))] { + let mut input = input.clone(); + if let Some(evaluation) = evaluation { + input["evaluation"] = evaluation; + } + TARGET_EVALUATIONS.with(|count| count.set(0)); + let (_, output) = result.target_result_from_json(input).unwrap(); + assert_eq!(TARGET_EVALUATIONS.with(|count| count.get()), 1); + assert_eq!( + serde_json::to_value(output).unwrap(), + json!({"status": status, "solution": [7], "evaluation": "Min(7)"}) + ); + } + for evaluation in [ + json!("Min(8)"), + json!("Max(7)"), + json!("garbage"), + json!(""), + json!(null), + json!(7), + json!({"Min": 7}), + ] { + let mut input = input.clone(); + input["evaluation"] = evaluation; + let error = result + .target_result_from_json(input) + .err() + .unwrap() + .to_string(); + assert!(error.contains("evaluation"), "{error}"); + assert!(error.contains("Min(7)"), "{error}"); + } + } + assert!(matches!( + result + .target_result_from_json(json!({"status": "infeasible"})) + .unwrap() + .1, + SolveOutcome::Infeasible + )); + for evaluation in [json!(null), json!("Min(7)")] { + let error = result + .target_result_from_json(json!({"status": "infeasible", "evaluation": evaluation})) + .err() + .unwrap() + .to_string(); + assert!(error.contains("must not contain evaluation"), "{error}"); + } } diff --git a/src/unit_tests/rules/travelingsalesman_ilp.rs b/src/unit_tests/rules/travelingsalesman_ilp.rs index d7c630715..1faa38fe2 100644 --- a/src/unit_tests/rules/travelingsalesman_ilp.rs +++ b/src/unit_tests/rules/travelingsalesman_ilp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -40,7 +41,14 @@ fn test_reduction_c4_closed_loop() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Verify extracted solution is valid on source problem let metric = problem.evaluate(&extracted).unwrap(); @@ -59,7 +67,14 @@ fn test_reduction_k4_weighted_closed_loop() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Solve via brute force for cross-check let bf = BruteForce::new(); @@ -87,7 +102,14 @@ fn test_reduction_c5_unweighted_closed_loop() { let ilp = reduction.target_problem(); let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = problem.evaluate(&extracted).unwrap(); assert!(metric.is_valid()); @@ -108,8 +130,9 @@ fn test_no_hamiltonian_cycle_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!( - result.is_err(), + assert_eq!( + result, + Err(crate::solvers::ILPSolveError::Infeasible), "Path graph should have no Hamiltonian cycle (infeasible ILP)" ); } @@ -127,7 +150,14 @@ fn test_solution_extraction_structure() { let ilp_solver = ILPSolver::new(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should have one value per edge assert_eq!(extracted.len(), 4); diff --git a/src/unit_tests/rules/travelingsalesman_qubo.rs b/src/unit_tests/rules/travelingsalesman_qubo.rs index 77199d7e3..f7e21b5fe 100644 --- a/src/unit_tests/rules/travelingsalesman_qubo.rs +++ b/src/unit_tests/rules/travelingsalesman_qubo.rs @@ -1,6 +1,7 @@ use super::*; use crate::solvers::BruteForce; use crate::solvers::BruteForceProblem as _; +use crate::solvers::SolveOutcome; use crate::traits::Problem; use crate::types::Min; @@ -17,7 +18,14 @@ fn test_travelingsalesman_to_qubo_closed_loop() { // All QUBO solutions should extract to valid TSP solutions for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &tsp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = tsp.evaluate(&extracted).unwrap(); assert!(metric.is_valid(), "Extracted solution should be valid"); // K3 has only one Hamiltonian cycle (all 3 edges), cost = 1+2+3 = 6 @@ -45,7 +53,14 @@ fn test_travelingsalesman_to_qubo_k4() { // Every Hamiltonian cycle in K4 uses exactly 4 edges, so cost = 4 for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &tsp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let metric = tsp.evaluate(&extracted).unwrap(); assert!(metric.is_valid(), "Extracted solution should be valid"); assert_eq!(metric, Min(Some(4))); @@ -66,13 +81,13 @@ fn test_travelingsalesman_to_qubo_sizes() { let graph3 = SimpleGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); let tsp3 = TravelingSalesman::new(graph3, vec![1i64; 3]); let reduction3 = ReduceTo::>::reduce_to(&tsp3).expect("reduction should succeed"); - assert_eq!(reduction3.target_problem().num_variables(), 9); + assert_eq!(reduction3.target_problem().num_variables().unwrap(), 9); // K4: n=4, QUBO should have n^2 = 16 variables let graph4 = SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]); let tsp4 = TravelingSalesman::new(graph4, vec![1i64; 6]); let reduction4 = ReduceTo::>::reduce_to(&tsp4).expect("reduction should succeed"); - assert_eq!(reduction4.target_problem().num_variables(), 16); + assert_eq!(reduction4.target_problem().num_variables().unwrap(), 16); } #[test] @@ -86,3 +101,182 @@ fn test_travelingsalesman_to_qubo_weighted_corpus_regression() { "weighted TSP position encoding", ); } + +#[test] +fn signed_and_small_tours_recover_all_optima_or_infeasibility() { + let cases = [ + (0, vec![], vec![]), + (1, vec![], vec![]), + (1, vec![(0, 0), (0, 0)], vec![4, -2]), + (2, vec![(0, 1)], vec![1]), + (2, vec![(0, 1), (0, 1), (0, 1)], vec![4, -2, 1]), + (3, vec![(0, 1), (1, 2)], vec![-5, 2]), + (3, vec![(0, 1), (1, 2), (0, 2)], vec![-5, 2, 1]), + ( + 3, + vec![(0, 1), (0, 1), (1, 2), (0, 2), (1, 1)], + vec![4, -5, 2, 1, -100], + ), + ( + 4, + vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)], + vec![-9, 1, 2, 3, 4, -8], + ), + ]; + for (n, edges, weights) in cases { + let m = edges.len(); + let source = TravelingSalesman::new(SimpleGraph::new(n, edges), weights); + let expected = (0..1usize << m) + .filter_map(|bits| { + source + .evaluate(&(0..m).map(|i| bits & (1 << i) != 0).collect()) + .unwrap() + .0 + }) + .min(); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let entry = inventory::iter:: + .into_iter() + .find(|entry| entry.source_name == "TravelingSalesman" && entry.target_name == "QUBO") + .unwrap(); + let chain = + crate::rules::ReductionChain::execute(&source, &[entry.reduce_fn.unwrap()]).unwrap(); + + let solutions = BruteForce::new() + .find_all_witnesses(reduction.target_problem()) + .unwrap(); + assert!(!solutions.is_empty()); + for solution in solutions { + let completed = chain + .recover_result_json( + &source, + serde_json::json!({"status": "optimal", "solution": solution}), + ) + .unwrap() + .0; + assert_eq!( + matches!(completed, SolveOutcome::Optimal { .. }), + expected.is_some() + ); + assert_eq!( + reduction.map_value(reduction.target_problem().evaluate(&solution).unwrap()), + Min(expected) + ); + if expected.is_some() { + assert_eq!( + source + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + solution.clone() + ) + .unwrap() + ) + .map(|result| result.into_solution().expect( + "qualifying target result must recover a source solution" + )) + .unwrap() + ) + .unwrap(), + Min(expected) + ); + } + } + assert_eq!(reduction.map_value(Min(None)), Min(None)); + } +} + +/// Decode a position-encoded assignment (`x[v * n + p]`: vertex `v` at position `p`) +/// into the source edge selection, or `None` when it is not a permutation matrix. +fn decode_tour( + source: &TravelingSalesman, + assignment: &[bool], +) -> Option> { + let n = source.num_vertices(); + let order: Vec = (0..n) + .map(|p| { + let mut at_position = (0..n).filter(|&v| assignment[v * n + p]); + at_position.next().filter(|_| at_position.next().is_none()) + }) + .collect::>()?; + if assignment.iter().filter(|&&bit| bit).count() != n || (0..n).any(|v| !order.contains(&v)) { + return None; + } + Some( + source + .edges() + .into_iter() + .map(|(u, v, _)| { + (0..n).any(|i| { + let (a, b) = (order[i], order[(i + 1) % n]); + (a, b) == (u, v) || (a, b) == (v, u) + }) + }) + .collect(), + ) +} + +#[test] +fn test_travelingsalesman_to_qubo_feasible_target_incumbents() { + // K4 with tour costs 22, 22 and 10, so most valid tours are suboptimal. + let tsp = TravelingSalesman::new(SimpleGraph::complete(4), vec![9i64, 1, 2, 3, 4, 8]); + let optimum = BruteForce::new().solve(&tsp).unwrap().unwrap(); + assert_eq!(tsp.evaluate(&optimum).unwrap(), Min(Some(10))); + + let reduction = ReduceTo::>::reduce_to(&tsp).unwrap(); + let qubo = reduction.target_problem(); + let mut suboptimal_tours = 0; + let mut rejected = 0; + for bits in 0..1usize << 16 { + let candidate: Vec = (0..16).map(|i| bits & (1 << i) != 0).collect(); + let expected = decode_tour(&tsp, &candidate); + let recovered = + reduction.recover_result(&tsp, SolveOutcome::feasible(qubo, candidate).unwrap()); + match expected { + Some(tour) => { + let evaluation = tsp.evaluate(&tour).unwrap(); + suboptimal_tours += usize::from(evaluation == Min(Some(22))); + assert_eq!( + recovered, + Ok(SolveOutcome::Feasible { + solution: tour, + evaluation, + }) + ); + } + None => { + rejected += 1; + assert_eq!( + recovered, + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); + } + } + } + // 4! position assignments, two thirds of which encode a cost-22 tour. + assert_eq!(suboptimal_tours, 16); + assert_eq!(rejected, (1 << 16) - 24); +} + +#[test] +fn test_travelingsalesman_to_qubo_infeasible_source_recovers_infeasible() { + // A path on four vertices has no Hamiltonian cycle. + let tsp = TravelingSalesman::new(SimpleGraph::path(4), vec![1i64, 2, 3]); + assert_eq!(BruteForce::new().solve(&tsp).unwrap(), None); + + let reduction = ReduceTo::>::reduce_to(&tsp).unwrap(); + let qubo = reduction.target_problem(); + let optimum = BruteForce::new().solve(qubo).unwrap().unwrap(); + assert_eq!( + reduction.recover_result(&tsp, SolveOutcome::optimal(qubo, optimum.clone()).unwrap()), + Ok(SolveOutcome::Infeasible) + ); + // The same assignment without an optimality proof establishes nothing. + assert_eq!( + reduction.recover_result(&tsp, SolveOutcome::feasible(qubo, optimum).unwrap()), + Err(crate::rules::ExtractionError::InsufficientSolutionQuality) + ); +} diff --git a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs index feb60cb9b..7a7fa516f 100644 --- a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -61,7 +62,14 @@ fn test_undirectedflowlowerbounds_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // extract_solution returns edge orientations z_e assert_eq!(extracted.len(), 2); @@ -76,8 +84,9 @@ fn test_undirectedflowlowerbounds_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionUFLBToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible instance should produce infeasible ILP" ); } @@ -91,7 +100,14 @@ fn test_undirectedflowlowerbounds_to_ilp_extract_solution() { // f_{01}=1, f_{10}=0, f_{12}=1, f_{21}=0, z_0=1, z_1=1 // z_e=1 means u→v direction; model expects config[e]=0 for u→v → extract returns 1-z_e let target_solution = vec![1, 0, 1, 0, 1, 1]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // z_0=1, z_1=1 → extracted = [1-1, 1-1] = [0, 0] (both u→v = 0→1 and 1→2) assert_eq!(extracted, vec![false, false]); assert!( diff --git a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs index 1d8d087ed..43200e2f0 100644 --- a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::solvers::SolveOutcome; use crate::solvers::{BruteForce, ILPSolver}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -103,7 +104,14 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_closed_loop() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!( problem.evaluate(&extracted).unwrap().0, @@ -116,8 +124,9 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionU2CIFToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!( - ILPSolver::new().solve(reduction.target_problem()).is_err(), + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible), "infeasible flow instance should yield infeasible ILP" ); } @@ -136,7 +145,10 @@ fn test_other_commodity_source_cannot_create_flow() { ); let reduction: ReductionU2CIFToILP = ReduceTo::>::reduce_to(&problem).expect("reduction should succeed"); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); + assert_eq!( + ILPSolver::new().solve(reduction.target_problem()), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -157,7 +169,14 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_extract_solution() { 0, 1, // d1_1=0, d2_1=1 1, 1, // d1_2=1, d2_2=1 ]; - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &problem, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // extract_solution returns first 4*3=12 flow variables assert_eq!(extracted.len(), 12); assert!( diff --git a/src/unit_tests/solvers/brute_force.rs b/src/unit_tests/solvers/brute_force.rs index f2b6547d7..09aa9700d 100644 --- a/src/unit_tests/solvers/brute_force.rs +++ b/src/unit_tests/solvers/brute_force.rs @@ -1,6 +1,6 @@ use super::*; use crate::traits::Problem; -use crate::types::{AggregationError, Max, Min, Or, Sum}; +use crate::types::{AggregationError, And, Max, Min, Or, Sum}; use std::cell::Cell; use std::rc::Rc; @@ -14,7 +14,12 @@ impl Problem for MaxSumProblem { type Solution = Vec; type Value = Max; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.weights.len() as u64)]) + } fn evaluate( &self, @@ -37,8 +42,12 @@ impl Problem for MaxSumProblem { } impl crate::solvers::BruteForceProblem for MaxSumProblem { - fn dimensions(&self) -> Vec { - vec![2; self.weights.len()] + fn num_variables(&self) -> Result { + Ok(self.weights.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -52,7 +61,12 @@ impl Problem for MinSumProblem { type Solution = Vec; type Value = Min; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.weights.len() as u64)]) + } fn evaluate( &self, @@ -75,8 +89,12 @@ impl Problem for MinSumProblem { } impl crate::solvers::BruteForceProblem for MinSumProblem { - fn dimensions(&self) -> Vec { - vec![2; self.weights.len()] + fn num_variables(&self) -> Result { + Ok(self.weights.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -91,7 +109,12 @@ impl Problem for SatProblem { type Solution = Vec; type Value = Or; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.num_vars as u64)]) + } fn evaluate( &self, @@ -106,8 +129,12 @@ impl Problem for SatProblem { } impl crate::solvers::BruteForceProblem for SatProblem { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -119,7 +146,12 @@ impl Problem for EvaluationFailureProblem { type Solution = Vec; type Value = Or; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 1usize as u64)]) + } fn evaluate(&self, config: &Self::Solution) -> Result { if config.as_slice() == [1] { @@ -137,8 +169,12 @@ impl Problem for EvaluationFailureProblem { } impl crate::solvers::BruteForceProblem for EvaluationFailureProblem { - fn dimensions(&self) -> Vec { - vec![2] + fn num_variables(&self) -> Result { + Ok(1usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([2][variable]) } } @@ -150,7 +186,12 @@ impl Problem for AggregationFailureProblem { type Solution = Vec; type Value = Max; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 1usize as u64)]) + } fn evaluate(&self, _: &Self::Solution) -> Result, crate::traits::EvaluationError> { Ok(Max(Some(f64::NAN))) @@ -162,8 +203,12 @@ impl Problem for AggregationFailureProblem { } impl crate::solvers::BruteForceProblem for AggregationFailureProblem { - fn dimensions(&self) -> Vec { - vec![2] + fn num_variables(&self) -> Result { + Ok(1usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([2][variable]) } } @@ -178,7 +223,12 @@ impl Problem for CountingSatProblem { type Solution = Vec; type Value = Or; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", 2usize as u64)]) + } fn evaluate( &self, @@ -196,8 +246,12 @@ impl Problem for CountingSatProblem { } impl crate::solvers::BruteForceProblem for CountingSatProblem { - fn dimensions(&self) -> Vec { - vec![2, 2] + fn num_variables(&self) -> Result { + Ok(2usize) + } + + fn dimension(&self, variable: usize) -> Result { + Ok([2, 2][variable]) } } @@ -475,6 +529,97 @@ fn test_sum_fold_preserves_zero_identity() { assert_eq!(Sum::::identity().combine(Sum(6)).unwrap(), Sum(6)); } +/// Unregistered fold-only problem over three binary variables. `Sum` and `And` +/// are evaluation and fold values; they are not `EvaluationValue`s, so this +/// problem cannot enter the solve or recovery APIs. +#[derive(Clone)] +struct FoldProblem { + value: fn(&[usize]) -> V, + evaluations: Rc>, +} + +impl FoldProblem { + fn new(value: fn(&[usize]) -> V) -> Self { + Self { + value, + evaluations: Rc::new(Cell::new(0)), + } + } +} + +impl Problem for FoldProblem { + const NAME: &'static str = "FoldProblem"; + 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", 3)]) + } + + fn evaluate( + &self, + config: &Self::Solution, + ) -> Result { + self.evaluations.set(self.evaluations.get() + 1); + Ok((self.value)(config)) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + +impl crate::solvers::BruteForceProblem for FoldProblem { + fn num_variables(&self) -> Result { + Ok(3) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2) + } +} + +#[test] +fn test_sum_valued_problem_folds_the_exact_count() { + // Count the assignments with at least two ones: 110, 101, 011, 111. + let problem = FoldProblem::new(|config| Sum(u64::from(config.iter().sum::() >= 2))); + + assert_eq!(problem.evaluate(&vec![1, 0, 1]).unwrap(), Sum(1)); + assert_eq!(problem.evaluate(&vec![1, 0, 0]).unwrap(), Sum(0)); + problem.evaluations.set(0); + + let total = BruteForce::new().solve_cartesian(&problem, |c| c).unwrap(); + assert_eq!(total, Sum(4)); + // A sum is never absorbing, so the fold visits every configuration. + assert_eq!(problem.evaluations.get(), 8); +} + +#[test] +fn test_and_valued_problem_folds_every_true_value() { + let problem = FoldProblem::new(|config| And(config.len() == 3)); + + let total = BruteForce::new().solve_cartesian(&problem, |c| c).unwrap(); + assert_eq!(total, And(true)); + assert_eq!(problem.evaluations.get(), 8); +} + +#[test] +fn test_and_valued_problem_stops_at_the_first_false_value() { + // Enumeration order is 000, 001, 010, ...; the third configuration fails. + let problem = FoldProblem::new(|config| And(config != [0, 1, 0])); + + assert_eq!(problem.evaluate(&vec![0, 1, 0]).unwrap(), And(false)); + problem.evaluations.set(0); + + let total = BruteForce::new().solve_cartesian(&problem, |c| c).unwrap(); + assert_eq!(total, And(false)); + // `And(false)` is absorbing, so the remaining five configurations are skipped. + assert_eq!(problem.evaluations.get(), 3); +} + #[test] fn solve_with_witnesses_enumerates_only_aggregate_and_witness_passes() { let evaluations = Rc::new(Cell::new(0)); @@ -563,18 +708,284 @@ fn cartesian_indices_zero_dimension_has_no_candidates() { } #[test] -fn cartesian_indices_is_exact_size() { +fn cartesian_indices_reports_exhaustion_without_a_total_count() { let mut indices = CartesianIndices::new(vec![2, 3]).unwrap(); - assert_eq!(indices.len(), 6); - indices.next(); - assert_eq!(indices.len(), 5); + assert_eq!(indices.size_hint(), (1, None)); + assert_eq!(indices.by_ref().count(), 6); + assert_eq!(indices.size_hint(), (0, Some(0))); + assert_eq!(indices.next(), None); +} + +#[test] +fn cartesian_indices_visits_a_prefix_when_the_total_exceeds_usize() { + let prefix = CartesianIndices::new(vec![usize::MAX, 2]) + .unwrap() + .take(4) + .collect::>(); + assert_eq!(prefix, vec![vec![0, 0], vec![0, 1], vec![1, 0], vec![1, 1]]); +} + +#[test] +fn enumeration_reports_coordinate_count_and_storage_errors() { + use crate::models::set::SetBasis; + let count_overflow = SetBasis::new(2, vec![], usize::MAX); + assert!(matches!( + BruteForceProblem::num_variables(&count_overflow), + Err(SolveError::IntegerOverflow(_)) + )); + assert!(matches!( + BruteForce::new().solve(&count_overflow), + Err(SolveError::IntegerOverflow(_)) + )); + let allocation_overflow = SetBasis::new(1, vec![], usize::MAX); + assert!(matches!( + cartesian_dimensions(&allocation_overflow), + Err(SolveError::Allocation(_)) + )); +} + +#[test] +fn window_product_does_not_restrict_construction_or_evaluation() { + use crate::models::misc::ClosestSubstring; + 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()); + let dimensions = crate::solvers::cartesian_dimensions(&restored).unwrap(); + assert_eq!(dimensions[0], 1); + assert_eq!(&dimensions[1..], &[2; 64]); + let prefix: Vec<_> = CartesianIndices::new(dimensions).unwrap().take(2).collect(); + assert_eq!(prefix.len(), 2); + for witness in prefix { + assert_eq!(restored.evaluate(&witness).unwrap(), Min(Some(0))); + } +} + +#[test] +fn scalar_counts_report_unrepresentable_search_coordinates() { + use crate::models::algebraic::BMF; + use crate::models::misc::{ConsistencyOfDatabaseFrequencyTables, EnsembleComputation}; + let cases = [ + ( + "biclique slots", + crate::models::graph::BicliqueCover::new( + crate::topology::BipartiteGraph::new(1, 1, vec![(0, 0)]), + usize::MAX, + ) + .num_variables(), + ), + ( + "tree slots", + crate::models::graph::KthBestSpanningTree::new( + crate::topology::SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1i64, 1], + usize::MAX, + 2, + ) + .num_variables(), + ), + ( + "tile slots", + crate::models::misc::SquareTiling::new(1, vec![(0, 0, 0, 0)], usize::MAX) + .num_variables(), + ), + ( + "factor rows", + BMF::new(vec![vec![true]; 2], usize::MAX).num_variables(), + ), + ( + "factor columns", + BMF::new(vec![vec![true; 2]], usize::MAX).num_variables(), + ), + ( + "factor sum", + BMF::new(vec![vec![true]], usize::MAX).num_variables(), + ), + ( + "operation operands", + EnsembleComputation::new(1, vec![], usize::MAX).num_variables(), + ), + ( + "database entries", + ConsistencyOfDatabaseFrequencyTables::new(usize::MAX, vec![1, 1], vec![], vec![]) + .num_variables(), + ), + ]; + for (context, result) in cases { + assert!( + matches!(result, Err(SolveError::IntegerOverflow(_))), + "{context}: {result:?}" + ); + } +} + +#[test] +fn scalar_domains_report_unrepresentable_coordinate_cardinalities() { + use crate::models::misc::{ + ConjunctiveQueryFoldability, EnsembleComputation, MinimumExternalMacroDataCompression, + MinimumInternalMacroDataCompression, + }; + let external = MinimumExternalMacroDataCompression::new(usize::MAX, vec![0], 1); + let cases = [ + ("external symbol", external.dimension(0)), + ("external pointer", external.dimension(1)), + ( + "internal alphabet", + MinimumInternalMacroDataCompression::new(usize::MAX, vec![0], 1).dimension(0), + ), + ( + "internal sentinel", + MinimumInternalMacroDataCompression::new(usize::MAX - 1, vec![0], 1).dimension(0), + ), + ( + "operand labels", + EnsembleComputation::new(usize::MAX, vec![], 1).dimension(0), + ), + ( + "distinguished labels", + ConjunctiveQueryFoldability::new(usize::MAX, 1, 1, vec![], vec![], vec![]).dimension(0), + ), + ( + "undistinguished labels", + ConjunctiveQueryFoldability::new(usize::MAX, 0, 1, vec![], vec![], vec![]).dimension(0), + ), + ]; + for (context, result) in cases { + assert!( + matches!(result, Err(SolveError::IntegerOverflow(_))), + "{context}: {result:?}" + ); + } +} + +#[test] +fn string_domains_reserve_a_representable_sentinel() { + use crate::models::misc::{ + LongestCommonSubsequence, ShortestCommonSupersequence, ShortestCommonSuperstring, + }; + use crate::models::set::ConsecutiveSets; + let cases = [ + ( + "subsequence", + LongestCommonSubsequence::new(usize::MAX, vec![vec![0]]).dimension(0), + ), + ( + "supersequence", + ShortestCommonSupersequence::new(usize::MAX, vec![vec![0]]).dimension(0), + ), + ( + "superstring", + ShortestCommonSuperstring::new(usize::MAX, vec![vec![0]]).dimension(0), + ), + ( + "consecutive sets", + ConsecutiveSets::new(usize::MAX, vec![vec![0]], 1).dimension(0), + ), + ]; + for (context, result) in cases { + assert!( + matches!(result, Err(SolveError::IntegerOverflow(_))), + "{context}: {result:?}" + ); + } } #[test] -fn cartesian_indices_reports_cardinality_overflow() { +fn decision_tree_slots_fail_before_enumeration_storage_is_allocated() { + use crate::models::misc::MinimumDecisionTree; + let objects = usize::BITS as usize + 1; + let tests = objects.ilog2() as usize + 1; + let matrix = (0..tests) + .map(|bit| { + (0..objects) + .map(|object| object & (1 << bit) != 0) + .collect() + }) + .collect(); + let problem = MinimumDecisionTree::new(matrix, objects, tests); assert!(matches!( - CartesianIndices::new(vec![usize::MAX, 2]), - Err(crate::solvers::SolveError::SearchSpaceOverflow(dimensions)) - if dimensions == vec![usize::MAX, 2] + cartesian_dimensions(&problem), + Err(SolveError::Evaluation( + crate::traits::EvaluationError::IntegerOverflow(_) + )) )); } + +#[test] +fn large_products_remain_symbolic_in_model_parameters() { + use crate::models::misc::{ + ConsistencyOfDatabaseFrequencyTables, MinimumDiscretePlanarInverseKinematics, + }; + let arm = 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(&arm).unwrap()).unwrap(); + assert_eq!(arm.parameters(), restored.parameters()); + assert_eq!(arm.evaluate(&vec![0; 64]).unwrap(), Min(Some(0.0))); + assert_eq!( + CartesianIndices::new(cartesian_dimensions(&arm).unwrap()) + .unwrap() + .take(2) + .count(), + 2 + ); + let database = ConsistencyOfDatabaseFrequencyTables::new(1, vec![2; 64], vec![], vec![]); + let restored: ConsistencyOfDatabaseFrequencyTables = + serde_json::from_value(serde_json::to_value(&database).unwrap()).unwrap(); + assert_eq!(database.parameters(), restored.parameters()); + assert_eq!(database.evaluate(&vec![0; 64]).unwrap(), Or(true)); +} + +#[test] +fn test_max_solution_selection() { + assert!(Max::contributes_to_solution(&Max(Some(7)), &Max(Some(7)))); + assert!(!Max::contributes_to_solution(&Max(Some(3)), &Max(Some(7)))); + assert!(!Max::contributes_to_solution(&Max(None), &Max(Some(7)))); +} + +#[test] +fn test_min_solution_selection() { + assert!(Min::contributes_to_solution(&Min(Some(3)), &Min(Some(3)))); + assert!(!Min::contributes_to_solution(&Min(Some(7)), &Min(Some(3)))); + assert!(!Min::contributes_to_solution(&Min(None), &Min(Some(3)))); +} + +#[test] +fn test_or_solution_selection() { + assert!(Or::contributes_to_solution(&Or(true), &Or(true))); + assert!(!Or::contributes_to_solution(&Or(false), &Or(true))); + assert!(!Or::contributes_to_solution(&Or(true), &Or(false))); +} + +#[test] +fn test_extremum_solution_selection() { + // Matching value and sense -> contributes + assert!(Extremum::contributes_to_solution( + &Extremum::maximize(Some(10)), + &Extremum::maximize(Some(10)), + )); + + // Different value -> does not contribute + assert!(!Extremum::contributes_to_solution( + &Extremum::maximize(Some(5)), + &Extremum::maximize(Some(10)), + )); + + // None config -> does not contribute + assert!(!Extremum::contributes_to_solution( + &Extremum::::maximize(None), + &Extremum::maximize(Some(10)), + )); +} + +#[test] +fn test_minimumcutintoboundedsets_selects_optimal_solutions() { + type Value = as Problem>::Value; + assert!(Value::contributes_to_solution(&Min(Some(3)), &Min(Some(3)))); +} diff --git a/src/unit_tests/solvers/customized/closest_vector_problem.rs b/src/unit_tests/solvers/customized/closest_vector_problem.rs index fe570f3f2..327485494 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] @@ -22,47 +19,16 @@ 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(); - assert_eq!(solve(&tied).unwrap(), vec![0]); - +fn test_cvp_solver_handles_empty_basis() { let empty = ClosestVectorProblem::new(Vec::new(), vec![1_i64, 2]).unwrap(); assert!(solve(&empty).unwrap().is_empty()); } -#[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(_)) - )); - - let out_of_range = ClosestVectorProblem::new(vec![vec![1]], vec![1e20]).unwrap(); - assert!(matches!( - solve(&out_of_range), - 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,66 +48,20 @@ 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. - 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], - ) - .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 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:?}"); - } - } - } - } -} - -#[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(); - // Nearest-plane rounding yields [0, 0]; the adjacent branch is closer. - assert_eq!(solve(&problem).unwrap(), vec![0, 1]); -} - #[test] 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(), + let integer = ClosestVectorProblem::new( + vec![vec![3, 1], vec![2, 1]], + vec![5 * coefficient, 2 * coefficient], ) .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 +73,17 @@ fn test_cvp_pruning_handles_nearly_parallel_integer_columns() { .unwrap(); assert_eq!(solve(&problem).unwrap(), vec![-n - 2, n + 1]); } + +#[test] +fn test_cvp_search_steps_do_not_limit_integer_solutions() { + let problem = + ClosestVectorProblem::new(vec![vec![2, 0], vec![0, 1]], vec![1, i64::MIN]).unwrap(); + let solution = solve(&problem).unwrap(); + assert_eq!(problem.squared_distance(&solution).unwrap(), 1); + let unrepresentable = + ClosestVectorProblem::new(vec![vec![1, 0], vec![1, 1]], vec![i64::MIN, i64::MAX]).unwrap(); + assert!(matches!( + solve(&unrepresentable), + Err(SolveError::IntegerOverflow(_)) + )); +} diff --git a/src/unit_tests/solvers/customized/minimum_decision_tree.rs b/src/unit_tests/solvers/customized/minimum_decision_tree.rs index 6905c543e..45ebd53be 100644 --- a/src/unit_tests/solvers/customized/minimum_decision_tree.rs +++ b/src/unit_tests/solvers/customized/minimum_decision_tree.rs @@ -1,5 +1,6 @@ use super::*; -use crate::solvers::BruteForce; +use crate::registry::load_dyn; +use crate::solvers::{BruteForce, SolveOutcome, SolverExecution, SolverRequest}; use crate::traits::Problem; #[test] @@ -43,6 +44,50 @@ fn test_subset_dp_minimum_decision_tree_handles_eight_objects() { .map(|bit| (0..8).map(|object| object & (1 << bit) != 0).collect()) .collect(); let problem = MinimumDecisionTree::new(matrix, 8, 3); - let solution = solve(&problem).unwrap(); + let loaded = load_dyn( + MinimumDecisionTree::NAME, + &Default::default(), + serde_json::to_value(&problem).unwrap(), + ) + .unwrap(); + let result = crate::solvers::solve(&loaded, SolverRequest::Default).unwrap(); + assert!(matches!( + result.solver, + SolverExecution::Customized { + implementation: "subset-dp" + } + )); + let SolveOutcome::Optimal { + solution, + evaluation, + } = result.outcome + else { + panic!("the instance has a solution"); + }; + assert_eq!(evaluation, "Min(24)"); + let solution = serde_json::from_value(solution).unwrap(); assert_eq!(problem.evaluate(&solution).unwrap().0, Some(24)); } + +#[test] +fn subset_dp_reports_mask_and_table_representation_errors() { + 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 loaded = load_dyn( + MinimumDecisionTree::NAME, + &Default::default(), + serde_json::to_value(&problem).unwrap(), + ) + .unwrap(); + let error = crate::solvers::solve(&loaded, SolverRequest::Default).unwrap_err(); + if n == usize::BITS as usize { + assert!(matches!(error, SolveError::IntegerOverflow(_))); + } else { + assert!(matches!(error, SolveError::Allocation(_))); + } + } +} diff --git a/src/unit_tests/solvers/customized/shortest_common_superstring.rs b/src/unit_tests/solvers/customized/shortest_common_superstring.rs index a331c02f7..79565095b 100644 --- a/src/unit_tests/solvers/customized/shortest_common_superstring.rs +++ b/src/unit_tests/solvers/customized/shortest_common_superstring.rs @@ -1,5 +1,6 @@ use super::*; -use crate::solvers::BruteForce; +use crate::registry::load_dyn; +use crate::solvers::{BruteForce, SolveOutcome, SolverExecution, SolverRequest}; use crate::traits::Problem; #[test] @@ -35,6 +36,45 @@ fn test_subset_dp_shortest_common_superstring_handles_containment_and_scale() { vec![0, 1], ], ); - let solution = solve(&problem).unwrap(); + let loaded = load_dyn( + ShortestCommonSuperstring::NAME, + &Default::default(), + serde_json::to_value(&problem).unwrap(), + ) + .unwrap(); + let result = crate::solvers::solve(&loaded, SolverRequest::Default).unwrap(); + assert!(matches!( + result.solver, + SolverExecution::Customized { + implementation: "subset-dp" + } + )); + let SolveOutcome::Optimal { + solution, + evaluation, + } = result.outcome + else { + panic!("the instance has a solution"); + }; + assert_eq!(evaluation, "Min(6)"); + let solution = serde_json::from_value(solution).unwrap(); assert_eq!(problem.evaluate(&solution).unwrap().0, Some(6)); } + +#[test] +fn subset_dp_reports_mask_and_table_size_overflow() { + for count in [usize::BITS as usize, usize::BITS as usize - 1] { + let problem = + ShortestCommonSuperstring::new(count, (0..count).map(|symbol| vec![symbol]).collect()); + let loaded = load_dyn( + ShortestCommonSuperstring::NAME, + &Default::default(), + serde_json::to_value(&problem).unwrap(), + ) + .unwrap(); + assert!(matches!( + crate::solvers::solve(&loaded, SolverRequest::Default), + Err(SolveError::IntegerOverflow(_)) + )); + } +} diff --git a/src/unit_tests/solvers/customized/solver.rs b/src/unit_tests/solvers/customized/solver.rs index 85294c3e6..c40daf729 100644 --- a/src/unit_tests/solvers/customized/solver.rs +++ b/src/unit_tests/solvers/customized/solver.rs @@ -1,10 +1,10 @@ -use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; +use crate::models::graph::{KColoring, PartialFeedbackEdgeSet, RootedTreeArrangement}; use crate::solvers::brute_force::CartesianIndices; use crate::solvers::registry::solver_capability_registry; -use crate::solvers::BruteForceProblem as _; use crate::solvers::ExactProblemKey; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; +use crate::variant::K2; struct CustomizedTestSolver; @@ -29,7 +29,7 @@ impl CustomizedTestSolver { .unwrap() .lookup(&key) .customized?; - let solution = (registration.solve_fn)(problem).unwrap()?; + let solution = (registration.solve_fn)(problem).unwrap().into_solution()?; Some( serde_json::from_value(solution) .expect("customized solver returned the wrong witness representation"), @@ -59,7 +59,7 @@ fn exact_partial_feedback_edge_set_feasible( max_cycle_length: usize, ) -> bool { let problem = PartialFeedbackEdgeSet::new(graph.clone(), budget, max_cycle_length); - CartesianIndices::new(problem.dimensions()) + CartesianIndices::new(crate::solvers::cartesian_dimensions(&problem).unwrap()) .unwrap() .any(|config| { let solution = crate::config::config_to_bits(&config); @@ -69,7 +69,7 @@ fn exact_partial_feedback_edge_set_feasible( fn exact_rooted_tree_arrangement_min_stretch(graph: &SimpleGraph) -> Option { let problem = RootedTreeArrangement::new(graph.clone(), i64::MAX); - CartesianIndices::new(problem.dimensions()) + CartesianIndices::new(crate::solvers::cartesian_dimensions(&problem).unwrap()) .unwrap() .filter_map(|config| problem.total_edge_stretch(&config).unwrap()) .min() @@ -82,6 +82,18 @@ fn test_customized_solver_returns_none_for_unsupported_problem() { assert!(solver.solve_dyn(&problem).is_none()); } +#[test] +fn test_two_coloring_solver_handles_disconnected_and_non_bipartite_graphs() { + let bipartite = KColoring::::new(SimpleGraph::new(6, vec![(0, 1), (1, 2), (3, 4)])); + let coloring = CustomizedTestSolver::new() + .solve_dyn(&bipartite) + .expect("bipartite graph must have a two-coloring"); + assert!(bipartite.evaluate(&coloring).unwrap().0); + + let triangle = KColoring::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])); + assert!(super::solve_two_coloring(&triangle).is_none()); +} + // --- FD model parity tests against BruteForce --- #[test] diff --git a/src/unit_tests/solvers/decision_search.rs b/src/unit_tests/solvers/decision_search.rs deleted file mode 100644 index 1a56f00de..000000000 --- a/src/unit_tests/solvers/decision_search.rs +++ /dev/null @@ -1,78 +0,0 @@ -use super::*; -use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; -use crate::solvers::BruteForce; -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)); -} - -#[test] -fn test_decision_search_max() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MaximumIndependentSet::new(graph, vec![1i64; 3]); - - assert_eq!(solve_via_decision(&problem, 0, 3).unwrap(), Some(2)); -} - -#[test] -fn test_decision_search_matches_brute_force() { - let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); - let problem = MinimumVertexCover::new(graph, vec![1i64; 5]); - - let solution = BruteForce::new().solve(&problem).unwrap().unwrap(); - let brute_force_value = problem.evaluate(&solution).unwrap(); - - assert_eq!( - solve_via_decision(&problem, 0, 5).unwrap(), - brute_force_value.size().copied() - ); -} - -#[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); -} - -#[test] -fn test_decision_search_max_returns_none_when_interval_is_above_optimum() { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MaximumIndependentSet::new(graph, vec![1i64; 3]); - - assert_eq!(solve_via_decision(&problem, 3, 4).unwrap(), None); -} - -#[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); -} - -#[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)); -} diff --git a/src/unit_tests/solvers/ilp/adapter.rs b/src/unit_tests/solvers/ilp/adapter.rs new file mode 100644 index 000000000..f78545a0a --- /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(IlpBackendError::Infeasible) + ); + assert_eq!( + accept_backend_status(HighsModelStatus::Unbounded), + Err(IlpBackendError::Unbounded) + ); + assert_eq!( + accept_backend_status(HighsModelStatus::ReachedTimeLimit), + Err(IlpBackendError::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(IlpBackendError::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(IlpBackendError::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(IlpBackendError::InvalidSolution(_)) + )); + let float = ILP::::new( + 1, + vec![LinearConstraint::le(vec![(0, 1.0)], 1.0 - 5e-10)], + vec![], + ObjectiveSense::Minimize, + ) + .unwrap(); + assert!(!float.is_feasible(&[1]).unwrap()); + assert!(matches!( + decode_and_validate(&float, [1.0]), + Err(IlpBackendError::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(IlpBackendError::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(IlpBackendError::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(IlpBackendError::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(IlpBackendError::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..c4e07649e 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, ILP}; use crate::traits::Problem; fn binary_ilp( @@ -113,26 +113,6 @@ fn test_ilp_solver_rejects_inexact_integer_transport() { )); } -#[test] -fn test_backend_errors_are_classified_without_losing_the_cause() { - assert_eq!( - classify_backend_error(ResolutionError::Infeasible, None), - ILPSolveError::Infeasible, - ); - assert_eq!( - classify_backend_error(ResolutionError::Unbounded, None), - ILPSolveError::Unbounded, - ); - assert_eq!( - classify_backend_error(ResolutionError::Other("NoSolutionFound"), Some(0.1)), - ILPSolveError::Timeout, - ); - assert!(matches!( - classify_backend_error(ResolutionError::Other("SolveError"), None), - ILPSolveError::BackendFailure(message) if message.contains("SolveError") - )); -} - #[test] fn test_ilp_rejects_solution_that_is_infeasible_after_rounding() { let ilp = binary_ilp( @@ -259,69 +239,43 @@ fn test_registered_ilp_pipeline_success() { } #[test] -fn test_ilp_solve_dyn_bool() { - let ilp = ILP::::new(1, vec![], vec![(0, 1.0)], ObjectiveSense::Maximize).unwrap(); - assert!(ILPSolver::new() - .solve_dyn(&ilp as &dyn std::any::Any) - .is_ok()); +fn test_float_qubo_objective_matches_reference() { + use crate::models::algebraic::QUBO; + use crate::solvers::BruteForce; + + let source = QUBO::::from_matrix(vec![ + vec![0.5, -2.5, 1.5, -4.0], + vec![0.0, -3.5, 4.0, -3.0], + vec![0.0, 0.0, 1.0, 4.5], + vec![0.0, 0.0, 0.0, -4.0], + ]) + .unwrap(); + let actual = ILPSolver::new().solve(&source).unwrap(); + let reference = BruteForce::new().solve(&source).unwrap().unwrap(); + let actual_value = source.evaluate(&actual).unwrap(); + assert!(actual_value.is_valid()); + assert_eq!(actual_value, source.evaluate(&reference).unwrap()); } #[test] -fn test_ilp_solve_dyn_i64() { - let ilp = ILP::::with_variables( - vec![ - IntegerVariable::new(Some(0), Some(3)).unwrap(), - IntegerVariable::new(Some(0), Some(3)).unwrap(), - ], - vec![], +fn test_ilp_solver_rejects_objective_overflow_after_backend_success() { + let ilp = ILP::::with_variables( + vec![IntegerVariable::new(Some(1025), Some(1025)).unwrap()], vec![], - ObjectiveSense::Minimize, + vec![(0, crate::types::MAX_EXACT_F64_INTEGER)], + ObjectiveSense::Maximize, ) .unwrap(); - assert!(ILPSolver::new() - .solve_dyn(&ilp as &dyn std::any::Any) - .is_ok()); -} - -#[test] -fn test_ilp_solve_dyn_unknown_type_returns_unsupported_problem_type() { - let result = ILPSolver::new().solve_dyn(&42_i64 as &dyn std::any::Any); - assert_eq!(result, Err(ILPSolveError::UnsupportedProblemType)); -} - -// Test acceptance policy in source-objective units, separate from variable rounding. -// This allows small absolute numerical differences near zero; it is not a -// guaranteed objective-error bound derived from HiGHS feasibility tolerances. -fn objective_close(a: f64, b: f64) -> bool { - let abs_tol = 1e-7; - let rel_tol = 1e-7; - (a - b).abs() <= abs_tol + rel_tol * a.abs().max(b.abs()) + assert!(matches!( + ILPSolver::new().solve(&ilp), + Err(ILPSolveError::InvalidSolution(_)) + )); } #[test] -fn test_float_qubo_objective_matches_reference_within_tolerance() { - use crate::models::algebraic::QUBO; - use crate::solvers::BruteForce; - - for scale in [1e-9, 1.0] { - let matrix = vec![ - vec![1.0, -5.0, 3.0, -8.0], - vec![0.0, -7.0, 8.0, -6.0], - vec![0.0, 0.0, 2.0, 9.0], - vec![0.0, 0.0, 0.0, -8.0], - ] - .into_iter() - .map(|row| row.into_iter().map(|v| v * scale).collect()) - .collect(); - let source = QUBO::::from_matrix(matrix).unwrap(); - let actual = ILPSolver::new().solve(&source).unwrap(); - let reference = BruteForce::new().solve(&source).unwrap().unwrap(); - let actual_value = source.evaluate(&actual).unwrap(); - let reference_value = source.evaluate(&reference).unwrap(); - assert!(actual_value.is_valid()); - assert!(objective_close( - actual_value.0.unwrap(), - reference_value.0.unwrap() - )); - } +fn test_invalid_public_time_limit_returns_existing_backend_error() { + assert!(matches!( + ILPSolver::with_time_limit(-1.0).solve(&ILP::::empty()), + Err(ILPSolveError::BackendFailure(_)) + )); } diff --git a/src/unit_tests/solvers/outcome.rs b/src/unit_tests/solvers/outcome.rs new file mode 100644 index 000000000..be105d9f9 --- /dev/null +++ b/src/unit_tests/solvers/outcome.rs @@ -0,0 +1,174 @@ +use super::SolveOutcome; +use crate::traits::{EvaluationError, EvaluationValue, Problem}; +use crate::types::{Extremum, Max, Min, Or, ProblemParameters}; +use std::cell::Cell; + +#[test] +fn reported_evaluations_compare_numeric_values_without_losing_integer_precision() { + use super::check_reported_evaluation; + use serde_json::json; + + for reported in ["Min(2)", "Min(2.0)", "Min(2e0)", "Min(2.000000001)"] { + assert!(check_reported_evaluation(&json!(reported), &Min(Some(2.0f64))).is_ok()); + } + for reported in [ + "Min(2.001)", + "Max(2)", + "Min(NaN)", + "Min(inf)", + "Min(abc)", + "Min(2", + "Min2)", + ] { + assert!(check_reported_evaluation(&json!(reported), &Min(Some(2.0f64))).is_err()); + } + assert!(check_reported_evaluation(&json!("Min(0.0000000005)"), &Min(Some(0.0f64))).is_ok()); + assert!(check_reported_evaluation(&json!("Max(2e0)"), &Max(Some(2.0f64))).is_ok()); + assert!( + check_reported_evaluation(&json!("Min(2e0)"), &Extremum::minimize(Some(2.0f64))).is_ok() + ); + assert!( + check_reported_evaluation(&json!("Max(2e0)"), &Extremum::maximize(Some(2.0f64))).is_ok() + ); + assert!(check_reported_evaluation(&json!("Min(+002)"), &Min(Some(2i64))).is_ok()); + assert!(check_reported_evaluation(&json!("Min(3)"), &Min(Some(2i64))).is_err()); + assert!(check_reported_evaluation( + &json!("Min(9007199254740992)"), + &Min(Some(9007199254740993i64)) + ) + .is_err()); + assert!(check_reported_evaluation(&json!("Or(true)"), &Or(true)).is_ok()); + assert!(check_reported_evaluation(&json!("Or(false)"), &Or(true)).is_err()); +} + +#[derive(Clone)] +struct Evaluated { + value: Result, + evaluations: Cell, +} + +impl Problem for Evaluated { + const NAME: &'static str = "Evaluated"; + type Solution = (); + type Value = V; + + fn parameter_names() -> &'static [&'static str] { + &[] + } + fn parameters(&self) -> ProblemParameters { + ProblemParameters::default() + } + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } + fn evaluate(&self, _: &()) -> Result { + self.evaluations.set(self.evaluations.get() + 1); + self.value.clone() + } +} + +fn check_candidate(value: V, valid: bool) { + let problem = Evaluated { + value: Ok(value.clone()), + evaluations: Cell::new(0), + }; + let optimal = SolveOutcome::optimal(&problem, ()); + assert_eq!(problem.evaluations.get(), 1); + let feasible = SolveOutcome::feasible(&problem, ()); + assert_eq!(problem.evaluations.get(), 2); + if valid { + assert_eq!( + optimal, + Ok(SolveOutcome::Optimal { + solution: (), + evaluation: value.clone() + }) + ); + assert_eq!( + feasible, + Ok(SolveOutcome::Feasible { + solution: (), + evaluation: value + }) + ); + } else { + assert_eq!(optimal, Err(EvaluationError::ConstraintViolation)); + assert_eq!(feasible, Err(EvaluationError::ConstraintViolation)); + } +} + +#[test] +fn constructors_validate_candidate_values_with_one_evaluation() { + check_candidate(Min(Some(0)), true); + check_candidate(Min::(None), false); + check_candidate(Max(Some(-1)), true); + check_candidate(Max::(None), false); + check_candidate(Or(true), true); + check_candidate(Or(false), false); + check_candidate(Extremum::minimize(Some(0)), true); + check_candidate(Extremum::::minimize(None), false); + check_candidate(Extremum::maximize(Some(-1)), true); + check_candidate(Extremum::::maximize(None), false); +} + +#[test] +fn constructors_preserve_evaluation_failures() { + for error in [ + EvaluationError::InvalidConfiguration("wrong solution length".into()), + EvaluationError::IntegerOverflow("summing weights".into()), + ] { + let problem = Evaluated::> { + value: Err(error.clone()), + evaluations: Cell::new(0), + }; + assert_eq!(SolveOutcome::optimal(&problem, ()), Err(error.clone())); + assert_eq!(SolveOutcome::feasible(&problem, ()), Err(error)); + assert_eq!(problem.evaluations.get(), 2); + } +} + +#[test] +fn invalid_candidate_does_not_establish_problem_infeasibility() { + use crate::models::graph::MinimumVertexCover; + use crate::topology::SimpleGraph; + + let problem = MinimumVertexCover::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i64; 2]); + let invalid = vec![false, false]; + assert_eq!(problem.evaluate(&invalid).unwrap(), Min(None)); + assert_eq!( + SolveOutcome::feasible(&problem, invalid.clone()), + Err(EvaluationError::ConstraintViolation), + ); + assert_eq!( + SolveOutcome::optimal(&problem, invalid), + Err(EvaluationError::ConstraintViolation), + ); + assert_eq!( + SolveOutcome::feasible(&problem, vec![true, false]).unwrap(), + SolveOutcome::Feasible { + solution: vec![true, false], + evaluation: Min(Some(1)), + }, + ); +} + +#[test] +fn result_serialization_rejects_invalid_stored_evaluations() { + for outcome in [ + SolveOutcome::Optimal { + solution: (), + evaluation: Or(false), + }, + SolveOutcome::Feasible { + solution: (), + evaluation: Or(false), + }, + ] { + assert!(matches!( + super::outcome_to_json(&outcome), + Err(crate::rules::ExtractionError::Evaluation( + EvaluationError::ConstraintViolation + )), + )); + } +} diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 97c55d085..e27ac76b1 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -32,10 +32,6 @@ fn generic_decision_ilp_respects_maximization_bounds() { name: "ILP", variant: BOOL_VARIANT, }, - StaticProblemStep { - name: "ILP", - variant: FLOAT_BOOL_VARIANT, - }, ], }; let registry = build_registry( @@ -54,16 +50,17 @@ fn generic_decision_ilp_respects_maximization_bounds() { ); for bound in [0, 1, 2] { let decision = Decision::new(inner.clone(), bound); - let result = pipeline.solve(&decision, &crate::solvers::ILPSolver::new()); + let result = pipeline.solve(&decision, &HighsAdapter::new(None)); if bound > 1 { assert!(matches!( result, - Err(crate::solvers::ILPSolveError::UnresolvedDecision(_)) + Ok(crate::solvers::SolveOutcome::Infeasible) )); assert!(BruteForce::new().solve(&decision).unwrap().is_none()); continue; } - let solution: Vec = serde_json::from_value(result.unwrap()).unwrap(); + let solution: Vec = + serde_json::from_value(result.unwrap().into_solution().unwrap()).unwrap(); assert_eq!( crate::traits::Problem::evaluate(&decision, &solution).unwrap(), crate::types::Or(true) @@ -72,25 +69,59 @@ fn generic_decision_ilp_respects_maximization_bounds() { } #[test] -fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { +fn generic_decision_ilp_reports_no_but_preserves_extraction_errors() { use crate::models::decision::Decision; use crate::models::graph::MinimumVertexCover; use crate::rules::{ExtractionError, ReductionResult}; - use crate::solvers::{ILPSolveError, ILPSolver}; + use crate::solvers::ILPSolveError; 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> { + fn recover_result( + &self, + source: &Self::Source, + target: crate::solvers::ProblemOutcome, + ) -> crate::rules::ExtractionResult> { + match target { + crate::solvers::SolveOutcome::Infeasible => { + Ok(crate::solvers::SolveOutcome::Infeasible) + } + crate::solvers::SolveOutcome::Optimal { + solution, + evaluation, + } => { + if !crate::types::OptimizationValue::meets_bound(&evaluation, source.bound()) { + return Ok(crate::solvers::SolveOutcome::Infeasible); + } + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::optimal(source, solution)?) + } + crate::solvers::SolveOutcome::Feasible { + solution, + evaluation, + } => { + if !crate::types::OptimizationValue::meets_bound(&evaluation, source.bound()) { + return Err(ExtractionError::InsufficientSolutionQuality); + } + let solution = self.map_solution(&solution)?; + Ok(crate::solvers::SolveOutcome::feasible(source, solution)?) + } + } + } + } + + impl BrokenExtractor { + fn map_solution(&self, _: &Vec) -> crate::rules::ExtractionResult> { Err(ExtractionError::invalid("broken witness decoder")) } } @@ -108,17 +139,18 @@ fn generic_decision_ilp_reports_unresolved_but_preserves_extraction_errors() { path: original.path.clone(), reducers: original.reducers.clone(), }; - pipeline.reducers[0].0 = |source| { + pipeline.reducers[0] = |source| { let source = source.downcast_ref::>().unwrap(); - Ok(Box::new(BrokenExtractor(source.inner().clone()))) + let result = std::rc::Rc::new(BrokenExtractor(source.clone())); + Ok(crate::rules::registry::ExecutedStep { witness: result }) }; 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(_)) + pipeline.solve(&Decision::new(inner.clone(), 0), &HighsAdapter::new(None)), + Ok(crate::solvers::SolveOutcome::Infeasible) )); assert!(matches!( - pipeline.solve(&Decision::new(inner, 1), &ILPSolver::new()), + pipeline.solve(&Decision::new(inner, 1), &HighsAdapter::new(None)), Err(ILPSolveError::Extraction(ExtractionError::Reduction { message, .. })) if message == "broken witness decoder" )); @@ -174,8 +206,8 @@ fn source_variant() -> Vec<(&'static str, &'static str)> { fn no_solution( _: &dyn std::any::Any, -) -> Result, crate::solvers::SolveError> { - Ok(None) +) -> Result { + Ok(crate::solvers::SolveOutcome::Infeasible) } static CUSTOMIZED_A: CustomizedSolverRegistration = CustomizedSolverRegistration { @@ -406,11 +438,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 +463,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] @@ -538,18 +563,12 @@ fn solver_capability_registry_ignores_unrelated_reduction_edges() { minimal_pipeline .reducers .iter() - .map(|(reducer, aggregate)| ( - *reducer as usize, - aggregate.map(|reduce| reduce as usize) - )) + .map(|reducer| *reducer as usize) .collect::>(), expanded_pipeline .reducers .iter() - .map(|(reducer, aggregate)| ( - *reducer as usize, - aggregate.map(|reduce| reduce as usize) - )) + .map(|reducer| *reducer as usize) .collect::>() ); } @@ -588,3 +607,34 @@ fn solver_capability_registry_ambiguous_exact_edge_is_rejected() { RegistryBuildError::InvalidEdge { matches: 2, .. } )); } + +#[test] +fn native_terminal_dispatch_rejects_non_ilp_values() { + assert!(matches!( + solve_ilp_terminal(&42_i64, &HighsAdapter::new(None)), + Err(crate::solvers::ILPSolveError::UnsupportedProblemType) + )); +} + +#[test] +fn registered_pipelines_stop_at_the_first_native_ilp() { + let registry = solver_capability_registry().unwrap(); + for pipeline in registry.ilp.values() { + assert!(pipeline.path.last().unwrap().is_supported_ilp()); + assert!(pipeline.path[..pipeline.path.len() - 1] + .iter() + .all(|step| !step.is_supported_ilp())); + } + for variable in ["bool", "i64"] { + for coefficient in ["i64", "f64"] { + let key = ExactProblemKey::new( + "ILP", + BTreeMap::from([ + ("variable".into(), variable.into()), + ("coefficient".into(), coefficient.into()), + ]), + ); + assert_eq!(registry.lookup(&key).ilp.unwrap().path(), &[key]); + } + } +} diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index 8cc05f909..91253e4f4 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -42,16 +42,6 @@ fn decision_reductions_check_target_optimum_before_extracting_witness() { 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 { SolveOutcome::Optimal { solution, @@ -59,7 +49,13 @@ fn decision_reductions_check_target_optimum_before_extracting_witness() { } => { assert!(expected, "{name}, {backend:?}"); assert_eq!(evaluation, "Or(true)"); - assert_eq!(problem.evaluate_dyn(&solution).unwrap(), "Or(true)"); + assert_eq!( + problem.evaluate_dyn(&solution).unwrap(), + ("Or(true)".into(), true) + ); + } + SolveOutcome::Feasible { .. } => { + panic!("exact solver returned only a feasible incumbent") } SolveOutcome::Infeasible => assert!(!expected, "{name}, {backend:?}"), } @@ -85,16 +81,6 @@ 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(); assert_eq!( matches!(actual.outcome, SolveOutcome::Infeasible), @@ -102,7 +88,10 @@ fn hamiltonian_ilp_matches_exhaustive_search_on_small_graphs() { "graph {mask}" ); if let SolveOutcome::Optimal { solution, .. } = actual.outcome { - assert_eq!(problem.evaluate_dyn(&solution).unwrap(), "Or(true)"); + assert_eq!( + problem.evaluate_dyn(&solution).unwrap(), + ("Or(true)".into(), true) + ); } } } @@ -153,19 +142,6 @@ fn generic_decision_ilp_compares_inner_optimum_with_bound() { 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(); if bound < optimum { assert_eq!( @@ -182,7 +158,10 @@ fn generic_decision_ilp_compares_inner_optimum_with_bound() { panic!("expected a witness for {name}, {bound}, {backend:?}"); }; assert_eq!(evaluation, "Or(true)"); - assert_eq!(loaded.evaluate_dyn(&solution).unwrap(), "Or(true)"); + assert_eq!( + loaded.evaluate_dyn(&solution).unwrap(), + ("Or(true)".into(), true) + ); } } } @@ -239,19 +218,6 @@ 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(); assert_eq!( matches!(actual.outcome, SolveOutcome::Infeasible), @@ -259,7 +225,10 @@ fn generic_decision_ilp_matches_exhaustive_search_on_small_graphs() { "{name}, graph {mask}, bound {bound}" ); if let SolveOutcome::Optimal { solution, .. } = actual.outcome { - assert_eq!(loaded.evaluate_dyn(&solution).unwrap(), "Or(true)"); + assert_eq!( + loaded.evaluate_dyn(&solution).unwrap(), + ("Or(true)".into(), true) + ); } } } @@ -365,7 +334,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 +350,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!( @@ -460,7 +429,7 @@ fn solve_outcome_has_disjoint_json_states() { }) ); assert_eq!( - serde_json::to_value(SolveOutcome::Infeasible).unwrap(), + serde_json::to_value(SolveOutcome::::Infeasible).unwrap(), serde_json::json!({"status": "infeasible"}) ); } @@ -498,7 +467,6 @@ fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { "MaximumIndependentSet", "MaximumSetPacking", "ILP", - "ILP", ] ); } @@ -589,22 +557,6 @@ fn check_unit_dominating_decision(num_vertices: usize, edges: &[(usize, usize)], 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 SolverExecution::Ilp { reduction_path } = &actual.solver else { panic!("expected the registered ILP pipeline"); @@ -626,7 +578,10 @@ fn check_unit_dominating_decision(num_vertices: usize, edges: &[(usize, usize)], } = actual.outcome { assert_eq!(evaluation, "Or(true)"); - assert_eq!(problem.evaluate_dyn(&solution).unwrap(), "Or(true)"); + assert_eq!( + problem.evaluate_dyn(&solution).unwrap(), + ("Or(true)".into(), true) + ); } } } @@ -658,3 +613,129 @@ fn unit_dominating_decision_ilp_matches_all_three_vertex_graphs() { } } } + +#[test] +fn decision_closest_vector_solver_preserves_bound_after_serialization() { + use crate::models::algebraic::ClosestVectorProblem; + use crate::models::decision::Decision; + use crate::models::misc::SubsetSum; + use crate::rules::{ReduceTo, ReductionResult}; + + for (weights, sum, expected) in [(vec![1u32, 2], 3u32, true), (vec![2, 4], 3, false)] { + let source = SubsetSum::new(weights, sum); + let reduction = ReduceTo::>::reduce_to(&source).unwrap(); + let target = reduction.target_problem(); + let loaded = load_dyn( + >::NAME, + &BTreeMap::from([("coefficient".into(), "i64".into())]), + serde_json::to_value(target).unwrap(), + ) + .unwrap(); + let result = solve(&loaded, SolverRequest::Default).unwrap(); + assert_eq!( + result.solver, + SolverExecution::Customized { + implementation: "cvp-sphere-enumeration" + } + ); + match result.outcome { + SolveOutcome::Feasible { .. } => { + panic!("exact solver returned only a feasible incumbent") + } + SolveOutcome::Infeasible => assert!(!expected), + SolveOutcome::Optimal { solution, .. } => { + assert!(expected); + let solution = serde_json::from_value(solution).unwrap(); + assert!(target.evaluate(&solution).unwrap().0); + assert!( + source + .evaluate( + &reduction + .recover_result( + &source, + SolveOutcome::optimal( + reduction.target_problem(), + solution.clone() + ) + .unwrap() + ) + .map(|result| result.into_solution().expect( + "qualifying target result must recover a source solution" + )) + .unwrap() + ) + .unwrap() + .0 + ); + } + } + } +} + +#[test] +fn customized_dispatch_rejects_a_constraint_violating_candidate() { + use crate::solvers::registry::CustomizedSolverRegistration; + use crate::solvers::SolveError; + use crate::traits::EvaluationError; + + static INVALID_SOLVER: CustomizedSolverRegistration = CustomizedSolverRegistration { + source_name: "MinimumVertexCover", + source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "i64")], + implementation: "invalid-candidate", + solve_fn: |any| { + let problem = any.downcast_ref::>().unwrap(); + let outcome = SolveOutcome::optimal(problem, vec![false, false])?; + Ok(crate::solvers::outcome_to_json(&outcome)?) + }, + }; + let problem = load_dyn( + "MinimumVertexCover", + &BTreeMap::from([ + ("graph".into(), "SimpleGraph".into()), + ("weight".into(), "i64".into()), + ]), + serde_json::json!({"graph": {"num_vertices": 2, "edges": [[0, 1]]}, "weights": [1, 1]}), + ) + .unwrap(); + assert!(matches!( + super::solve_customized(&problem, &INVALID_SOLVER), + Err(SolveError::Evaluation(EvaluationError::ConstraintViolation)), + )); + // The model is feasible; the error concerns only the solver's candidate. + assert!( + problem + .evaluate_dyn(&serde_json::json!([true, false])) + .unwrap() + .1 + ); +} + +#[test] +fn native_float_ilp_preserves_solution_and_fractional_evaluation() { + let problem = + ILP::::new(1, vec![], vec![(0, 0.5)], ObjectiveSense::Maximize).unwrap(); + // Both variable domains accept this same explicitly bounded [0, 1] instance. + let data = serde_json::to_value(problem).unwrap(); + for variable in ["bool", "i64"] { + let loaded = load_dyn( + "ILP", + &BTreeMap::from([ + ("variable".into(), variable.into()), + ("coefficient".into(), "f64".into()), + ]), + data.clone(), + ) + .unwrap(); + let result = solve(&loaded, SolverRequest::Ilp).unwrap(); + let solution = serde_json::json!([1]); + let (evaluation, valid) = loaded.evaluate_dyn(&solution).unwrap(); + assert!(valid); + assert_eq!( + result.outcome, + SolveOutcome::Optimal { + solution, + evaluation + } + ); + } +} diff --git a/src/unit_tests/topology/bipartite_graph.rs b/src/unit_tests/topology/bipartite_graph.rs index 46e3b381a..ede1b866a 100644 --- a/src/unit_tests/topology/bipartite_graph.rs +++ b/src/unit_tests/topology/bipartite_graph.rs @@ -58,3 +58,19 @@ fn test_bipartite_graph_invalid_left_index() { fn test_bipartite_graph_invalid_right_index() { BipartiteGraph::new(2, 2, vec![(0, 2)]); } + +#[test] +fn deserialize_checks_partition_endpoints_and_total_size() { + for edges in [vec![(1, 0)], vec![(0, 1)]] { + assert!(serde_json::from_value::(serde_json::json!({ + "left_size": 1, "right_size": 1, "edges": edges + })) + .is_err()); + } + assert!(BipartiteGraph::try_new(usize::MAX, 1, vec![]).is_err()); + let graph: BipartiteGraph = serde_json::from_value(serde_json::json!({ + "left_size": 1, "right_size": 1, "edges": [[0, 0]] + })) + .unwrap(); + assert_eq!(graph.edges(), vec![(0, 1)]); +} diff --git a/src/unit_tests/topology/directed_graph.rs b/src/unit_tests/topology/directed_graph.rs index 086728ae0..98b8e420b 100644 --- a/src/unit_tests/topology/directed_graph.rs +++ b/src/unit_tests/topology/directed_graph.rs @@ -234,3 +234,11 @@ fn test_directed_graph_json_format() { fn test_directed_graph_invalid_arc() { DirectedGraph::new(3, vec![(0, 5)]); } + +#[test] +fn deserialize_rejects_out_of_range_arcs() { + assert!(serde_json::from_value::(serde_json::json!({ + "num_vertices": 2, "arcs": [[2, 0]] + })) + .is_err()); +} diff --git a/src/unit_tests/topology/graph.rs b/src/unit_tests/topology/graph.rs index da89fb5ba..f98e2e4d2 100644 --- a/src/unit_tests/topology/graph.rs +++ b/src/unit_tests/topology/graph.rs @@ -153,3 +153,11 @@ fn test_simplegraph_json_format() { assert!(!json_str.contains("node_holes")); assert!(json_str.contains("num_vertices")); } + +#[test] +fn deserialize_rejects_out_of_range_edges() { + assert!(serde_json::from_value::(serde_json::json!({ + "num_vertices": 2, "edges": [[0, 2]] + })) + .is_err()); +} diff --git a/src/unit_tests/topology/mixed_graph.rs b/src/unit_tests/topology/mixed_graph.rs index 316e4ac02..8dcea1800 100644 --- a/src/unit_tests/topology/mixed_graph.rs +++ b/src/unit_tests/topology/mixed_graph.rs @@ -56,3 +56,13 @@ fn test_mixed_graph_serialization_roundtrip() { fn test_mixed_graph_panics_on_out_of_bounds_arc() { MixedGraph::new(3, vec![(0, 3)], vec![]); } + +#[test] +fn deserialize_checks_both_arc_and_edge_endpoints() { + for (arcs, edges) in [(vec![(0, 2)], vec![]), (vec![], vec![(2, 0)])] { + assert!(serde_json::from_value::(serde_json::json!({ + "num_vertices": 2, "arcs": arcs, "edges": edges + })) + .is_err()); + } +} diff --git a/src/unit_tests/topology/planar_graph.rs b/src/unit_tests/topology/planar_graph.rs index 7fae09cb6..463985412 100644 --- a/src/unit_tests/topology/planar_graph.rs +++ b/src/unit_tests/topology/planar_graph.rs @@ -44,3 +44,19 @@ fn test_planar_graph_tree() { let g = PlanarGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); assert_eq!(g.num_edges(), 3); } + +#[test] +fn deserialize_checks_the_edge_bound() { + let edges: Vec<_> = (0..5) + .flat_map(|u| ((u + 1)..5).map(move |v| (u, v))) + .collect(); + assert!(serde_json::from_value::(serde_json::json!({ + "inner": {"num_vertices": 5, "edges": edges} + })) + .is_err()); + let graph: PlanarGraph = serde_json::from_value(serde_json::json!({ + "inner": {"num_vertices": 2, "edges": [[0, 1]]} + })) + .unwrap(); + assert_eq!(graph.num_edges(), 1); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 4f766da84..8518a207e 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -8,7 +8,7 @@ use crate::topology::{BipartiteGraph, DirectedGraph, SimpleGraph}; use crate::variant::K3; fn check_brute_force_problem(problem: &P, name: &str) { - let dims = problem.dimensions(); + let dims = crate::solvers::cartesian_dimensions(&problem).unwrap(); assert!( !dims.is_empty() || name.contains("empty"), "{} should have dimensions", diff --git a/src/unit_tests/traits.rs b/src/unit_tests/traits.rs index 2c6cec82d..14950fcf3 100644 --- a/src/unit_tests/traits.rs +++ b/src/unit_tests/traits.rs @@ -13,7 +13,12 @@ impl Problem for TestSatProblem { type Solution = Vec; type Value = Or; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.num_vars as u64)]) + } fn evaluate( &self, @@ -28,8 +33,12 @@ impl Problem for TestSatProblem { } impl crate::solvers::BruteForceProblem for TestSatProblem { - fn dimensions(&self) -> Vec { - vec![2; self.num_vars] + fn num_variables(&self) -> Result { + Ok(self.num_vars) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -40,7 +49,10 @@ fn test_problem_sat() { satisfying: vec![vec![1, 0], vec![0, 1]], }; - assert_eq!(p.dimensions(), vec![2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2] + ); assert_eq!(p.evaluate(&vec![1, 0]).unwrap(), Or(true)); assert_eq!(p.evaluate(&vec![0, 0]).unwrap(), Or(false)); } @@ -52,8 +64,8 @@ fn test_problem_num_variables() { satisfying: vec![], }; - assert_eq!(p.num_variables(), 5); - assert_eq!(p.dimensions().len(), 5); + assert_eq!(p.num_variables().unwrap(), 5); + assert_eq!(crate::solvers::cartesian_dimensions(&p).unwrap().len(), 5); } #[test] @@ -63,8 +75,8 @@ fn test_problem_empty() { satisfying: vec![], }; - assert_eq!(p.num_variables(), 0); - assert!(p.dimensions().is_empty()); + assert_eq!(p.num_variables().unwrap(), 0); + assert!(crate::solvers::cartesian_dimensions(&p).unwrap().is_empty()); } #[derive(Clone)] @@ -77,7 +89,12 @@ impl Problem for TestMaxProblem { type Solution = Vec; type Value = Max; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.weights.len() as u64)]) + } fn evaluate( &self, @@ -99,12 +116,6 @@ impl Problem for TestMaxProblem { } } -impl crate::solvers::BruteForceProblem for TestMaxProblem { - fn dimensions(&self) -> Vec { - vec![2; self.weights.len()] - } -} - #[derive(Clone)] struct TestMinProblem { costs: Vec, @@ -115,7 +126,12 @@ impl Problem for TestMinProblem { type Solution = Vec; type Value = Min; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.costs.len() as u64)]) + } fn evaluate( &self, @@ -137,12 +153,6 @@ impl Problem for TestMinProblem { } } -impl crate::solvers::BruteForceProblem for TestMinProblem { - fn dimensions(&self) -> Vec { - vec![2; self.costs.len()] - } -} - #[test] fn test_problem_max_value() { let p = TestMaxProblem { @@ -175,7 +185,12 @@ impl Problem for MultiDimProblem { type Solution = Vec; type Value = Sum; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.dims.len() as u64)]) + } fn evaluate( &self, @@ -190,8 +205,12 @@ impl Problem for MultiDimProblem { } impl crate::solvers::BruteForceProblem for MultiDimProblem { - fn dimensions(&self) -> Vec { - self.dims.clone() + fn num_variables(&self) -> Result { + Ok(self.dims.len()) + } + + fn dimension(&self, variable: usize) -> Result { + Ok(self.dims[variable]) } } @@ -201,10 +220,21 @@ fn test_multi_dim_problem() { dims: vec![2, 3, 4], }; - assert_eq!(p.dimensions(), vec![2, 3, 4]); - assert_eq!(p.num_variables(), 3); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 3, 4] + ); + assert_eq!(p.num_variables().unwrap(), 3); assert_eq!(p.evaluate(&vec![0, 0, 0]).unwrap(), Sum(0)); assert_eq!(p.evaluate(&vec![1, 2, 3]).unwrap(), Sum(6)); + // Each coordinate value v in dimension d appears in 24 / d configurations: + // 12 * 1 + 8 * (1 + 2) + 6 * (1 + 2 + 3) = 72. + assert_eq!( + crate::solvers::BruteForce::new() + .solve_cartesian(&p, |config| config) + .unwrap(), + Sum(72) + ); } #[test] @@ -225,7 +255,12 @@ impl Problem for FloatProblem { type Solution = Vec; type Value = Max; - crate::problem_parameters![("num_variables", num_variables)]; + fn parameter_names() -> &'static [&'static str] { + &["num_variables"] + } + fn parameters(&self) -> crate::types::ProblemParameters { + crate::types::ProblemParameters::new(vec![("num_variables", self.weights.len() as u64)]) + } fn evaluate( &self, @@ -248,8 +283,12 @@ impl Problem for FloatProblem { } impl crate::solvers::BruteForceProblem for FloatProblem { - fn dimensions(&self) -> Vec { - vec![2; self.weights.len()] + fn num_variables(&self) -> Result { + Ok(self.weights.len()) + } + + fn dimension(&self, _variable: usize) -> Result { + Ok(2usize) } } @@ -259,7 +298,10 @@ fn test_float_value_problem() { weights: vec![1.5, 2.5, 3.0], }; - assert_eq!(p.dimensions(), vec![2, 2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p).unwrap(), + vec![2, 2, 2] + ); assert!((p.evaluate(&vec![1, 1, 0]).unwrap().0.unwrap() - 4.0).abs() < 1e-10); assert!((p.evaluate(&vec![1, 1, 1]).unwrap().0.unwrap() - 7.0).abs() < 1e-10); } @@ -283,6 +325,9 @@ fn test_problem_is_clone() { }; let p2 = p1.clone(); - assert_eq!(p2.dimensions(), vec![2, 2]); + assert_eq!( + crate::solvers::cartesian_dimensions(&p2).unwrap(), + vec![2, 2] + ); assert_eq!(p2.evaluate(&vec![1, 0]).unwrap(), Or(true)); } diff --git a/src/unit_tests/truth_table.rs b/src/unit_tests/truth_table.rs index 1685079ae..509305a1f 100644 --- a/src/unit_tests/truth_table.rs +++ b/src/unit_tests/truth_table.rs @@ -2,7 +2,7 @@ use super::*; #[test] fn test_and_gate() { - let and = TruthTable::and(2); + let and = TruthTable::and(2).unwrap(); assert!(!and.evaluate(&[false, false])); assert!(!and.evaluate(&[true, false])); assert!(!and.evaluate(&[false, true])); @@ -11,7 +11,7 @@ fn test_and_gate() { #[test] fn test_or_gate() { - let or = TruthTable::or(2); + let or = TruthTable::or(2).unwrap(); assert!(!or.evaluate(&[false, false])); assert!(or.evaluate(&[true, false])); assert!(or.evaluate(&[false, true])); @@ -27,7 +27,7 @@ fn test_not_gate() { #[test] fn test_xor_gate() { - let xor = TruthTable::xor(2); + let xor = TruthTable::xor(2).unwrap(); assert!(!xor.evaluate(&[false, false])); assert!(xor.evaluate(&[true, false])); assert!(xor.evaluate(&[false, true])); @@ -36,7 +36,7 @@ fn test_xor_gate() { #[test] fn test_nand_gate() { - let nand = TruthTable::nand(2); + let nand = TruthTable::nand(2).unwrap(); assert!(nand.evaluate(&[false, false])); assert!(nand.evaluate(&[true, false])); assert!(nand.evaluate(&[false, true])); @@ -54,7 +54,8 @@ fn test_implies() { #[test] fn test_from_function() { - let majority = TruthTable::from_function(3, |input| input.iter().filter(|&&b| b).count() >= 2); + let majority = + TruthTable::from_function(3, |input| input.iter().filter(|&&b| b).count() >= 2).unwrap(); assert!(!majority.evaluate(&[false, false, false])); assert!(!majority.evaluate(&[true, false, false])); assert!(majority.evaluate(&[true, true, false])); @@ -63,7 +64,7 @@ fn test_from_function() { #[test] fn test_evaluate_config() { - let and = TruthTable::and(2); + let and = TruthTable::and(2).unwrap(); assert!(!and.evaluate_config(&[0, 0])); assert!(!and.evaluate_config(&[1, 0])); assert!(and.evaluate_config(&[1, 1])); @@ -71,26 +72,26 @@ fn test_evaluate_config() { #[test] fn test_satisfiable() { - let or = TruthTable::or(2); + let or = TruthTable::or(2).unwrap(); assert!(or.is_satisfiable()); - let contradiction = TruthTable::from_outputs(2, vec![false, false, false, false]); + let contradiction = TruthTable::from_outputs(2, vec![false, false, false, false]).unwrap(); assert!(!contradiction.is_satisfiable()); assert!(contradiction.is_contradiction()); } #[test] fn test_tautology() { - let tautology = TruthTable::from_outputs(2, vec![true, true, true, true]); + let tautology = TruthTable::from_outputs(2, vec![true, true, true, true]).unwrap(); assert!(tautology.is_tautology()); - let or = TruthTable::or(2); + let or = TruthTable::or(2).unwrap(); assert!(!or.is_tautology()); } #[test] fn test_satisfying_assignments() { - let xor = TruthTable::xor(2); + let xor = TruthTable::xor(2).unwrap(); let sat = xor.satisfying_assignments(); assert_eq!(sat.len(), 2); assert!(sat.contains(&vec![true, false])); @@ -99,14 +100,14 @@ fn test_satisfying_assignments() { #[test] fn test_count() { - let and = TruthTable::and(2); + let and = TruthTable::and(2).unwrap(); assert_eq!(and.count_ones(), 1); assert_eq!(and.count_zeros(), 3); } #[test] fn test_index_to_input() { - let tt = TruthTable::and(3); + let tt = TruthTable::and(3).unwrap(); assert_eq!(tt.index_to_input(0), vec![false, false, false]); assert_eq!(tt.index_to_input(1), vec![true, false, false]); assert_eq!(tt.index_to_input(7), vec![true, true, true]); @@ -114,49 +115,49 @@ fn test_index_to_input() { #[test] fn test_outputs_vec() { - let and = TruthTable::and(2); + let and = TruthTable::and(2).unwrap(); assert_eq!(and.outputs_vec(), vec![false, false, false, true]); } #[test] fn test_and_with() { - let a = TruthTable::from_outputs(1, vec![false, true]); - let b = TruthTable::from_outputs(1, vec![true, false]); + let a = TruthTable::from_outputs(1, vec![false, true]).unwrap(); + let b = TruthTable::from_outputs(1, vec![true, false]).unwrap(); let result = a.and_with(&b); assert_eq!(result.outputs_vec(), vec![false, false]); } #[test] fn test_or_with() { - let a = TruthTable::from_outputs(1, vec![false, true]); - let b = TruthTable::from_outputs(1, vec![true, false]); + let a = TruthTable::from_outputs(1, vec![false, true]).unwrap(); + let b = TruthTable::from_outputs(1, vec![true, false]).unwrap(); let result = a.or_with(&b); assert_eq!(result.outputs_vec(), vec![true, true]); } #[test] fn test_negate() { - let and = TruthTable::and(2); + let and = TruthTable::and(2).unwrap(); let nand = and.negate(); assert_eq!(nand.outputs_vec(), vec![true, true, true, false]); } #[test] fn test_num_rows() { - let tt = TruthTable::and(3); + let tt = TruthTable::and(3).unwrap(); assert_eq!(tt.num_rows(), 8); } #[test] fn test_3_input_and() { - let and3 = TruthTable::and(3); + let and3 = TruthTable::and(3).unwrap(); assert!(!and3.evaluate(&[true, true, false])); assert!(and3.evaluate(&[true, true, true])); } #[test] fn test_xnor() { - let xnor = TruthTable::xnor(2); + let xnor = TruthTable::xnor(2).unwrap(); assert!(xnor.evaluate(&[false, false])); assert!(!xnor.evaluate(&[true, false])); assert!(!xnor.evaluate(&[false, true])); @@ -165,7 +166,7 @@ fn test_xnor() { #[test] fn test_nor() { - let nor = TruthTable::nor(2); + let nor = TruthTable::nor(2).unwrap(); assert!(nor.evaluate(&[false, false])); assert!(!nor.evaluate(&[true, false])); assert!(!nor.evaluate(&[false, true])); @@ -174,7 +175,7 @@ fn test_nor() { #[test] fn test_serialization() { - let and = TruthTable::and(2); + let and = TruthTable::and(2).unwrap(); let json = serde_json::to_string(&and).unwrap(); let deserialized: TruthTable = serde_json::from_str(&json).unwrap(); assert_eq!(and, deserialized); @@ -182,13 +183,37 @@ fn test_serialization() { #[test] fn test_outputs() { - let and = TruthTable::and(2); + let and = TruthTable::and(2).unwrap(); let outputs = and.outputs(); assert_eq!(outputs.len(), 4); } #[test] fn test_num_inputs() { - let and = TruthTable::and(3); + let and = TruthTable::and(3).unwrap(); assert_eq!(and.num_inputs(), 3); } + +#[test] +fn construction_and_deserialization_enforce_row_shape() { + for outputs in [vec![true], vec![false; 5]] { + assert!(TruthTable::from_outputs(2, outputs.clone()).is_err()); + assert!(serde_json::from_value::(serde_json::json!({ + "num_inputs": 2, "outputs": outputs + })) + .is_err()); + } + let inputs = usize::BITS as usize; + assert!(matches!( + TruthTable::from_outputs(inputs, vec![]), + Err(ConstructionError::IntegerOverflow(_)) + )); + assert!(TruthTable::from_function(inputs, |_| true).is_err()); + assert!(serde_json::from_value::(serde_json::json!({ + "num_inputs": inputs, "outputs": [] + })) + .is_err()); + let empty = TruthTable::from_outputs(0, vec![true]).unwrap(); + assert_eq!(empty.num_rows(), 1); + assert!(empty.evaluate(&[])); +} diff --git a/src/unit_tests/types.rs b/src/unit_tests/types.rs index ac53023b0..de88e9934 100644 --- a/src/unit_tests/types.rs +++ b/src/unit_tests/types.rs @@ -1,6 +1,6 @@ use super::*; use crate::traits::EvaluationError; -use crate::types::{Aggregate, SolutionAggregate}; +use crate::types::Aggregate; #[test] fn test_max_identity_and_combine() { @@ -96,27 +96,6 @@ fn test_and_absorbing_value_is_false() { assert!(And(false).is_absorbing()); } -#[test] -fn test_max_solution_selection() { - assert!(Max::contributes_to_solution(&Max(Some(7)), &Max(Some(7)))); - assert!(!Max::contributes_to_solution(&Max(Some(3)), &Max(Some(7)))); - assert!(!Max::contributes_to_solution(&Max(None), &Max(Some(7)))); -} - -#[test] -fn test_min_solution_selection() { - assert!(Min::contributes_to_solution(&Min(Some(3)), &Min(Some(3)))); - assert!(!Min::contributes_to_solution(&Min(Some(7)), &Min(Some(3)))); - assert!(!Min::contributes_to_solution(&Min(None), &Min(Some(3)))); -} - -#[test] -fn test_or_solution_selection() { - assert!(Or::contributes_to_solution(&Or(true), &Or(true))); - assert!(!Or::contributes_to_solution(&Or(false), &Or(true))); - assert!(!Or::contributes_to_solution(&Or(true), &Or(false))); -} - #[test] fn test_max_helpers() { let size = Max(Some(42)); @@ -332,27 +311,6 @@ fn test_extremum_aggregate_identity_and_combine() { assert_eq!(combined, Extremum::minimize(Some(3))); } -#[test] -fn test_extremum_solution_selection() { - // Matching value and sense -> contributes - assert!(Extremum::contributes_to_solution( - &Extremum::maximize(Some(10)), - &Extremum::maximize(Some(10)), - )); - - // Different value -> does not contribute - assert!(!Extremum::contributes_to_solution( - &Extremum::maximize(Some(5)), - &Extremum::maximize(Some(10)), - )); - - // None config -> does not contribute - assert!(!Extremum::contributes_to_solution( - &Extremum::::maximize(None), - &Extremum::maximize(Some(10)), - )); -} - #[test] fn test_extremum_display() { assert_eq!(format!("{}", Extremum::maximize(Some(42))), "Max(42)"); diff --git a/tests/data/qubo_legacy_dense.json b/tests/data/qubo_legacy_dense.json new file mode 100644 index 000000000..1a44553ec --- /dev/null +++ b/tests/data/qubo_legacy_dense.json @@ -0,0 +1 @@ +{"num_vars":3,"matrix":[[3,-5,0],[99,0,7],[0,0,-6]]} diff --git a/tests/suites/integration.rs b/tests/suites/integration.rs index db976e33f..748140f1c 100644 --- a/tests/suites/integration.rs +++ b/tests/suites/integration.rs @@ -532,3 +532,52 @@ mod weighted_problems { assert!(satisfying.is_empty()); } } + +/// Exercise the solver as a downstream crate: struct construction, generic +/// bounds, solution type, and exhaustive error matching must keep compiling. +#[test] +fn ilp_public_api_supports_generic_witness_solving() { + use problemreductions::solvers::{ILPSolveError, ILPSolver}; + fn solve_generic

(problem: &P) -> std::result::Result + where + P: Problem + 'static, + P::Solution: 'static, + P::Value: problemreductions::solvers::SolutionAggregate, + { + ILPSolver::new().solve(problem) + } + fn classify_error(error: ILPSolveError) -> &'static str { + match error { + ILPSolveError::Infeasible => "infeasible", + ILPSolveError::Unbounded => "unbounded", + ILPSolveError::Timeout => "timeout", + ILPSolveError::BackendFailure(_) => "backend", + ILPSolveError::UnsupportedProblemType => "unsupported", + ILPSolveError::MissingPipeline(_) => "missing pipeline", + ILPSolveError::InvalidRegistry(_) => "registry", + ILPSolveError::PipelineTypeMismatch(_) => "type mismatch", + ILPSolveError::InvalidSolution(_) => "invalid solution", + ILPSolveError::Evaluation(_) => "evaluation", + ILPSolveError::InexactTransport(_) => "transport", + ILPSolveError::Extraction(_) => "extraction", + ILPSolveError::Reduction(_) => "reduction", + } + } + let solver = ILPSolver { time_limit: None }; + let ILPSolver { time_limit } = solver.clone(); + assert_eq!(time_limit, None); + let ilp = ILP::::new(1, vec![], vec![(0, 1)], ObjectiveSense::Maximize).unwrap(); + let solution: Vec = solve_generic(&ilp).unwrap(); + assert_eq!(solution, vec![1]); + let infeasible = ILP::::new( + 0, + vec![LinearConstraint::ge(vec![], 1)], + vec![], + ObjectiveSense::Minimize, + ) + .unwrap(); + assert_eq!( + classify_error(solver.solve(&infeasible).unwrap_err()), + "infeasible" + ); +} diff --git a/tests/suites/ksatisfiability_simultaneous_incongruences.rs b/tests/suites/ksatisfiability_simultaneous_incongruences.rs index 947f81dab..f1339b5ff 100644 --- a/tests/suites/ksatisfiability_simultaneous_incongruences.rs +++ b/tests/suites/ksatisfiability_simultaneous_incongruences.rs @@ -2,6 +2,7 @@ use problemreductions::models::algebraic::SimultaneousIncongruences; use problemreductions::models::formula::{CNFClause, KSatisfiability}; use problemreductions::rules::{ReduceTo, ReductionResult}; use problemreductions::solvers::BruteForce; +use problemreductions::solvers::SolveOutcome; use problemreductions::variant::K3; use problemreductions::Problem; @@ -19,7 +20,7 @@ fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { .expect("reduction should succeed"); let target = reduction.target_problem(); - assert_eq!(target.lcm_moduli(), 105); + assert_eq!(target.lcm_moduli().unwrap(), 105); assert_eq!(target.num_pairs(), 11); let solver = BruteForce::new(); @@ -27,7 +28,14 @@ fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { .solve(target) .unwrap() .expect("target should be satisfiable"); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(source.evaluate(&extracted).unwrap()); } diff --git a/tests/suites/reductions.rs b/tests/suites/reductions.rs index 51f4746e4..25f075a89 100644 --- a/tests/suites/reductions.rs +++ b/tests/suites/reductions.rs @@ -9,6 +9,7 @@ use problemreductions::prelude::*; use problemreductions::rules::ReductionGraph; use problemreductions::solvers::BruteForceProblem as _; use problemreductions::solvers::ILPSolver; +use problemreductions::solvers::SolveOutcome; use problemreductions::topology::{Graph, SimpleGraph}; use problemreductions::types::{Min, Or}; use problemreductions::variant::{K2, K3}; @@ -39,7 +40,14 @@ mod is_vc_reductions { let vc_solutions = solver.find_all_witnesses(vc_problem).unwrap(); // Extract back to IS solution - let is_solution = result.extract_solution(&vc_solutions[0]).unwrap(); + let is_solution = result + .recover_result( + &is_problem, + SolveOutcome::optimal(result.target_problem(), vc_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Solution should be valid for original problem assert!(is_problem.evaluate(&is_solution).unwrap().is_valid()); @@ -67,7 +75,14 @@ mod is_vc_reductions { let is_solutions = solver.find_all_witnesses(is_problem).unwrap(); // Extract back to VC solution - let vc_solution = result.extract_solution(&is_solutions[0]).unwrap(); + let vc_solution = result + .recover_result( + &vc_problem, + SolveOutcome::optimal(result.target_problem(), is_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Solution should be valid for original problem assert!(vc_problem.evaluate(&vc_solution).unwrap().is_valid()); @@ -102,8 +117,22 @@ mod is_vc_reductions { let solutions = solver.find_all_witnesses(final_is).unwrap(); // Extract through the chain - let intermediate_sol = back_to_is.extract_solution(&solutions[0]).unwrap(); - let original_sol = to_vc.extract_solution(&intermediate_sol).unwrap(); + let intermediate_sol = back_to_is + .recover_result( + vc_problem, + SolveOutcome::optimal(back_to_is.target_problem(), solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + let original_sol = to_vc + .recover_result( + &original, + SolveOutcome::optimal(to_vc.target_problem(), intermediate_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should be valid assert!(original.evaluate(&original_sol).unwrap().is_valid()); @@ -169,7 +198,14 @@ mod is_sp_reductions { let sp_solutions = solver.find_all_witnesses(sp_problem).unwrap(); // Extract to IS solution - let is_solution = result.extract_solution(&sp_solutions[0]).unwrap(); + let is_solution = result + .recover_result( + &is_problem, + SolveOutcome::optimal(result.target_problem(), sp_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(is_problem.evaluate(&is_solution).unwrap().is_valid()); } @@ -192,7 +228,14 @@ mod is_sp_reductions { let is_solutions = solver.find_all_witnesses(is_problem).unwrap(); // Extract to SP solution - let sp_solution = result.extract_solution(&is_solutions[0]).unwrap(); + let sp_solution = result + .recover_result( + &sp_problem, + SolveOutcome::optimal(result.target_problem(), is_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // All sets can be packed (disjoint) assert_eq!(sp_solution.iter().filter(|&&selected| selected).count(), 3); @@ -216,7 +259,14 @@ mod is_sp_reductions { let sp_solutions = solver.find_all_witnesses(sp_problem).unwrap(); // Extract to IS solution - let is_solution = to_sp.extract_solution(&sp_solutions[0]).unwrap(); + let is_solution = to_sp + .recover_result( + &original, + SolveOutcome::optimal(to_sp.target_problem(), sp_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Valid for original assert!(original.evaluate(&is_solution).unwrap().is_valid()); @@ -246,14 +296,21 @@ mod sg_qubo_reductions { let result = ReduceTo::>::reduce_to(&sg).expect("reduction should succeed"); let qubo = result.target_problem(); - assert_eq!(qubo.num_variables(), 2); + assert_eq!(qubo.num_variables().unwrap(), 2); // Solve QUBO let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); // Extract to SG solution - let sg_solution = result.extract_solution(&qubo_solutions[0]).unwrap(); + let sg_solution = result + .recover_result( + &sg, + SolveOutcome::optimal(result.target_problem(), qubo_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sg_solution.len(), 2); } @@ -273,7 +330,14 @@ mod sg_qubo_reductions { let sg_solutions = solver.find_all_witnesses(sg).unwrap(); // Extract to QUBO solution - let qubo_solution = result.extract_solution(&sg_solutions[0]).unwrap(); + let qubo_solution = result + .recover_result( + &qubo, + SolveOutcome::optimal(result.target_problem(), sg_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(qubo_solution.len(), 2); } @@ -297,7 +361,14 @@ mod sg_qubo_reductions { let qubo_solutions = solver.find_all_witnesses(qubo).unwrap(); // Extract QUBO solution back to SG - let extracted = result.extract_solution(&qubo_solutions[0]).unwrap(); + let extracted = result + .recover_result( + &sg, + SolveOutcome::optimal(result.target_problem(), qubo_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should be among optimal SG solutions (or equivalent) let sg_energy = sg.compute_energy(&sg_solutions[0]).unwrap(); @@ -325,7 +396,14 @@ mod minimum_covering_by_cliques_ilp_reductions { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("MinimumCoveringByCliques -> ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), ilp_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Min(Some(3))); } @@ -337,20 +415,26 @@ 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() .solve(target) .unwrap() .expect("target should be solvable"); - let extracted = reduction.extract_solution(&target_solution).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solution.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Or(true)); } @@ -358,12 +442,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); } } @@ -392,7 +478,15 @@ mod max2sat_maxcut_reductions { let solver = BruteForce::new(); let target_solutions = solver.find_all_witnesses(target).unwrap(); - let extracted = reduction.extract_solution(&target_solutions[0]).unwrap(); + let extracted = reduction + .recover_result( + &source, + SolveOutcome::optimal(reduction.target_problem(), target_solutions[0].clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(source.evaluate(&extracted).unwrap(), Max(Some(5))); } @@ -424,7 +518,15 @@ mod sg_maxcut_reductions { let maxcut_solutions = solver.find_all_witnesses(maxcut).unwrap(); // Extract to SG solution - let sg_solution = result.extract_solution(&maxcut_solutions[0]).unwrap(); + let sg_solution = result + .recover_result( + &sg, + SolveOutcome::optimal(result.target_problem(), maxcut_solutions[0].clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(sg_solution.len(), 3); } @@ -447,7 +549,14 @@ mod sg_maxcut_reductions { let sg_solutions = solver.find_all_witnesses(sg).unwrap(); // Extract to MaxCut solution - let maxcut_solution = result.extract_solution(&sg_solutions[0]).unwrap(); + let maxcut_solution = result + .recover_result( + &maxcut, + SolveOutcome::optimal(result.target_problem(), sg_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!(maxcut_solution.len(), 3); } @@ -472,7 +581,15 @@ mod sg_maxcut_reductions { let maxcut_solutions = solver.find_all_witnesses(maxcut).unwrap(); // Extract MaxCut solution back to SG - let extracted = result.extract_solution(&maxcut_solutions[0]).unwrap(); + let extracted = result + .recover_result( + &sg, + SolveOutcome::optimal(result.target_problem(), maxcut_solutions[0].clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should have same energy as directly solved SG let direct_energy = sg.compute_energy(&sg_solutions[0]).unwrap(); @@ -574,20 +691,32 @@ mod qubo_reductions { .expect("Should reduce MaximumIndependentSet to QUBO"); let qubo: &QUBO = chain.target_problem(); - assert_eq!(qubo.num_variables(), data.qubo_num_vars); + assert_eq!(qubo.num_variables().unwrap(), data.qubo_num_vars); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(qubo).unwrap(); // All QUBO optimal solutions should extract to valid IS solutions for sol in &solutions { - let extracted = chain.extract_solution(sol).unwrap(); + let extracted = chain + .recover_result::, QUBO>( + &is, + SolveOutcome::optimal(qubo, (sol).clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert!(is.evaluate(&extracted).unwrap().is_valid()); } // Optimal IS size should match ground truth let gt_is_size: usize = data.qubo_optimal.configs[0].iter().sum(); - let our_is_solution: Vec = chain.extract_solution(&solutions[0]).unwrap(); + let our_is_solution: Vec = chain + .recover_result::, QUBO>( + &is, + SolveOutcome::optimal(qubo, solutions[0].clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); let our_is_size = our_is_solution.iter().filter(|&&selected| selected).count(); assert_eq!(our_is_size, gt_is_size); } @@ -617,16 +746,25 @@ 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); + assert_eq!(qubo.num_variables().unwrap(), data.qubo_num_vars); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &kc, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(kc.evaluate(&extracted).unwrap()); } @@ -657,21 +795,33 @@ mod qubo_reductions { let reduction = ReduceTo::>::reduce_to(&sp).expect("reduction should succeed"); let qubo = reduction.target_problem(); - assert_eq!(qubo.num_variables(), data.qubo_num_vars); + assert_eq!(qubo.num_variables().unwrap(), data.qubo_num_vars); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &sp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(sp.evaluate(&extracted).unwrap().is_valid()); } // Optimal packing should match ground truth let gt_selected: usize = data.qubo_optimal.configs[0].iter().sum(); let our_selected: usize = reduction - .extract_solution(&solutions[0]) + .recover_result( + &sp, + SolveOutcome::optimal(reduction.target_problem(), solutions[0].clone()).unwrap(), + ) .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution") .iter() .filter(|&&selected| selected) .count(); @@ -724,22 +874,38 @@ 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); + assert_eq!(qubo.num_variables().unwrap(), data.qubo_num_vars); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ksat.evaluate(&extracted).unwrap()); } // Verify extracted solution matches ground truth assignment let gt_config = &data.qubo_optimal.configs[0]; - let our_config = reduction.extract_solution(&solutions[0]).unwrap(); + let our_config = reduction + .recover_result( + &ksat, + SolveOutcome::optimal(reduction.target_problem(), solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( our_config, gt_config @@ -815,19 +981,33 @@ mod qubo_reductions { let qubo = reduction.target_problem(); // QUBO may have more variables (slack), but original count matches - assert!(qubo.num_variables() >= data.qubo_num_vars); + assert!(qubo.num_variables().unwrap() >= data.qubo_num_vars); let solver = BruteForce::new(); let solutions = solver.find_all_witnesses(qubo).unwrap(); for sol in &solutions { - let extracted = reduction.extract_solution(sol).unwrap(); + let extracted = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), (sol).clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert!(ilp.evaluate(&extracted).unwrap().is_valid()); } // Optimal assignment should match ground truth let gt_config = &data.qubo_optimal.configs[0]; - let our_config = reduction.extract_solution(&solutions[0]).unwrap(); + let our_config = reduction + .recover_result( + &ilp, + SolveOutcome::optimal(reduction.target_problem(), solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); assert_eq!( &our_config, >_config @@ -897,12 +1077,24 @@ mod qubo_reductions { // Extract back through the full chain to get VC solution for sol in &solutions { - let vc_sol = chain.extract_solution(sol).unwrap(); + let vc_sol = chain + .recover_result::, QUBO>( + &vc, + SolveOutcome::optimal(qubo, (sol).clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert!(vc.evaluate(&vc_sol).unwrap().is_valid()); } // Optimal VC size should match ground truth - let vc_sol: Vec = chain.extract_solution(&solutions[0]).unwrap(); + let vc_sol: Vec = chain + .recover_result::, QUBO>( + &vc, + SolveOutcome::optimal(qubo, solutions[0].clone()).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); let gt_vc_size: usize = data.qubo_optimal.configs[0].iter().sum(); let our_vc_size = vc_sol.iter().filter(|&&selected| selected).count(); assert_eq!(our_vc_size, gt_vc_size); @@ -991,7 +1183,14 @@ mod end_to_end { .expect("reduction should succeed"); let vc = to_vc.target_problem(); let vc_solutions = solver.find_all_witnesses(vc).unwrap(); - let vc_extracted = to_vc.extract_solution(&vc_solutions[0]).unwrap(); + let vc_extracted = to_vc + .recover_result( + &is, + SolveOutcome::optimal(to_vc.target_problem(), vc_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let via_vc_size = vc_extracted.iter().filter(|&&selected| selected).count(); // Reduce to MaximumSetPacking and solve @@ -999,7 +1198,14 @@ mod end_to_end { ReduceTo::>::reduce_to(&is).expect("reduction should succeed"); let sp = to_sp.target_problem(); let sp_solutions = solver.find_all_witnesses(sp).unwrap(); - let sp_extracted = to_sp.extract_solution(&sp_solutions[0]).unwrap(); + let sp_extracted = to_sp + .recover_result( + &is, + SolveOutcome::optimal(to_sp.target_problem(), sp_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let via_sp_size = sp_extracted.iter().filter(|&&selected| selected).count(); // All should give same optimal size @@ -1028,7 +1234,15 @@ mod end_to_end { ReduceTo::>::reduce_to(&sg).expect("reduction should succeed"); let maxcut = to_maxcut.target_problem(); let maxcut_solutions = solver.find_all_witnesses(maxcut).unwrap(); - let maxcut_extracted = to_maxcut.extract_solution(&maxcut_solutions[0]).unwrap(); + let maxcut_extracted = to_maxcut + .recover_result( + &sg, + SolveOutcome::optimal(to_maxcut.target_problem(), maxcut_solutions[0].clone()) + .unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); let via_maxcut_energy = sg.compute_energy(&maxcut_extracted).unwrap(); @@ -1057,8 +1271,22 @@ mod end_to_end { let vc_solutions = solver.find_all_witnesses(vc).unwrap(); // Extract back through chain - let is_sol = is_to_vc.extract_solution(&vc_solutions[0]).unwrap(); - let sp_sol = sp_to_is.extract_solution(&is_sol).unwrap(); + let is_sol = is_to_vc + .recover_result( + is, + SolveOutcome::optimal(is_to_vc.target_problem(), vc_solutions[0].clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); + let sp_sol = sp_to_is + .recover_result( + &sp, + SolveOutcome::optimal(sp_to_is.target_problem(), is_sol.clone()).unwrap(), + ) + .unwrap() + .into_solution() + .expect("qualifying target result must recover a source solution"); // Should be valid MaximumSetPacking assert!(sp.evaluate(&sp_sol).unwrap().is_valid()); diff --git a/tests/suites/register_assignment_reductions.rs b/tests/suites/register_assignment_reductions.rs index 04afcc570..307bbb770 100644 --- a/tests/suites/register_assignment_reductions.rs +++ b/tests/suites/register_assignment_reductions.rs @@ -4,6 +4,7 @@ use problemreductions::models::misc::FeasibleRegisterAssignment; use problemreductions::prelude::*; use problemreductions::rules::{ReductionGraph, ReductionPath}; use problemreductions::solvers::ILPSolver; +use problemreductions::solvers::SolveOutcome; use problemreductions::types::Or; use problemreductions::variant::K3; @@ -69,10 +70,22 @@ fn test_ksat_to_fra_structure_and_closed_loop_via_ilp() { let ilp_solution = ILPSolver::new() .solve(ilp) .expect("satisfiable FRA instance should reduce to a feasible ILP"); - let fra_solution = fra_chain.extract_solution(&ilp_solution).unwrap(); + let fra_solution = fra_chain + .recover_result::>( + fra, + SolveOutcome::optimal(ilp, ilp_solution).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(fra.evaluate(&fra_solution).unwrap(), Or(true)); - let sat_solution = ksat_chain.extract_solution(&fra_solution).unwrap(); + let sat_solution = ksat_chain + .recover_result::, FeasibleRegisterAssignment>( + &source, + SolveOutcome::optimal(fra, fra_solution).unwrap(), + ) + .map(|outcome| outcome.into_solution().unwrap()) + .unwrap(); assert_eq!(source.evaluate(&sat_solution).unwrap(), Or(true)); } diff --git a/tests/suites/simultaneous_incongruences.rs b/tests/suites/simultaneous_incongruences.rs index 6c4aa63c0..7306e27e5 100644 --- a/tests/suites/simultaneous_incongruences.rs +++ b/tests/suites/simultaneous_incongruences.rs @@ -1,6 +1,5 @@ use problemreductions::models::algebraic::SimultaneousIncongruences; use problemreductions::solvers::BruteForce; -use problemreductions::solvers::BruteForceProblem as _; use problemreductions::traits::Problem; #[test] @@ -10,8 +9,11 @@ fn test_simultaneous_incongruences_issue_example() { assert_eq!(problem.num_pairs(), 4); assert_eq!(problem.pairs(), &[(2, 2), (1, 3), (2, 5), (3, 7)]); - assert_eq!(problem.lcm_moduli(), 210); - assert_eq!(problem.dimensions(), vec![210]); + assert_eq!(problem.lcm_moduli().unwrap(), 210); + assert_eq!( + problemreductions::solvers::cartesian_dimensions(&problem).unwrap(), + vec![210] + ); // x=5: 5%2=1!=0(=2%2), 5%3=2!=1, 5%5=0!=2, 5%7=5!=3 => valid assert!(problem.evaluate(&5).unwrap()); // x=2: 2%2=0=2%2 => invalid (first incongruence violated)