From d9b25fb456a4ab8c7f99fed0c2a02284716b99c6 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 13:40:40 +0800 Subject: [PATCH 01/31] Add symbolic growth domain (src/growth.rs) (#1075) Implement the growth domain: a dedicated asymptotic normal form that computes Big-O bottom-up in a single pass over `Expr`, without the exponential monomial expansion in `canonical.rs` that was the root cause of issue #1069. - `GrowthTerm`: one growth monomial over `exp` / `poly` / `logs` maps. - `Growth`: an antichain of pairwise-incomparable dominant terms, or the absorbing `Unknown` sentinel; both are deterministically sorted for platform-stable equality and serialization. - `from_expr`: transfer functions for Var/Const/Add/Mul/Pow/Exp/Log/Sqrt with upward widening (subtraction -> addition, constants dropped, linear exponents -> base-2 `exp` rates, nonlinear exponents / factorial / negative exponents -> `Unknown`). - `dominates`: purely symbolic partial order (per variable, lexicographic on exp rate / poly degree / log power) that replaces the foolable numerical sampling heuristic. - Antichain cap 32 with upward widening to the componentwise-max term. - Serde support (`Serialize` derived; `Deserialize` hand-written to leak string keys to `&'static str`, matching `Expr`'s parser convention). This module changes no existing behavior; `big_o.rs` / search rewiring is scoped to later issues in the milestone, so the module is `#[allow(dead_code)]` for now. Also commits the shared batch design doc referenced by the milestone. Includes the six named verification cases plus the negative control from the issue, and extra coverage. `cargo test growth` and `cargo clippy -- -D warnings` pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- docs/design/symbolic-growth-domain.md | 350 ++++++++++++++++ src/growth.rs | 558 ++++++++++++++++++++++++++ src/lib.rs | 5 + src/unit_tests/growth.rs | 228 +++++++++++ 4 files changed, 1141 insertions(+) create mode 100644 docs/design/symbolic-growth-domain.md create mode 100644 src/growth.rs create mode 100644 src/unit_tests/growth.rs diff --git a/docs/design/symbolic-growth-domain.md b/docs/design/symbolic-growth-domain.md new file mode 100644 index 000000000..6897f523c --- /dev/null +++ b/docs/design/symbolic-growth-domain.md @@ -0,0 +1,350 @@ +# Symbolic Growth Domain & Pareto Path Search — Product Design + +Status: approved design, ready for decomposition into issues. +Origin: issue #1069 (`pred path --all` OOMs/hangs in `big_o_normal_form`). The acute +symptom is already mitigated on `main` by a stopgap: `MAX_CANONICAL_TERMS = 50_000` +in `canonical.rs` aborts oversized expansions, and the CLI falls back to printing the +*unreduced* composed expression as `O()` on failure +(`problemreductions-cli/src/commands/graph.rs:349`). This design replaces +refuse-or-bluff with a system that answers. + +## Need + +The symbolic overhead system conflates exact expressions with asymptotic queries: +`big_o_normal_form` (src/big_o.rs) fully expands composed path overheads to monomial +normal form (src/canonical.rs) before projecting to Big-O. Expansion of nested +`(sum)^2 * (sum)^2` structures is exponential in nesting depth — the root cause of +issue #1069. The stopgap cap prevents the OOM but leaves three structural defects: + +1. **Refuse-or-bluff answers.** Paths whose composed overhead exceeds the expansion + cap get no normalized Big-O; the CLI falls back to printing the raw unreduced + expression disguised as `O(...)`. The exponential-expansion algorithm is still + there, merely fenced. +2. **Heuristic dominance.** Asymptotic comparison relies on a foolable two-point + numerical sampling heuristic (`numerical_dominance_check`) — e.g. `n^100` vs + `1.001^n` is decided wrongly because the crossover lies beyond the sampled range. +3. **Unsound search.** The scalar Dijkstra in `ReductionGraph::find_cheapest_path` + has a latent correctness hole: edge costs depend on the size accumulated along the + path, which violates Dijkstra's assumptions — a cheaper-so-far path with a larger + intermediate size can be wrongly preferred. And there is no instance-free + (asymptotic) search mode at all. + +We need a **trustworthy** (explicit semantic axioms, bounded termination, per-rule +verifiability) and **extensible** (new functions/variables without touching the core) +symbolic system: an exact `Expr` layer separated from an asymptotic growth domain, +with both Big-O rendering and path search running in the asymptotic domain at +polynomial cost. Occam's razor is a hard constraint: no new entities beyond what the +selected features require. + +**Users:** library maintainers adding models/rules; CLI/MCP consumers of +`pred path` / `find_path`; the Typst paper's auto-derivation pipeline. + +**Success criteria** (the stopgap already prevents OOM; these measure what the +principled system adds): +- **Answers, not refusals:** every enumerable path gets a genuine normalized Big-O. + The `MAX_CANONICAL_TERMS` bail-out and the `O()` CLI fallback are + deleted; the only remaining "cannot normalize" sources are nonlinear exponents + and factorials, rendered as an explicit annotation (the one `2^num_vertices` + overhead edge gets a real exponential bound via the linear `exp` field). + Regression: issue #1069's exploding path (KSat → … → QuadraticAssignment → ILP → + QUBO) asserts a real normalized Big-O, not an error or fallback. +- **Trustworthy comparison:** the numerical sampling heuristic is replaced by a + symbolic decision procedure, property-tested against numeric evaluation. +- **Correct search:** Pareto label search fixes the path-dependent-cost hole and adds + an instance-free asymptotic mode. +- Big-O for all enumerated paths across the whole reduction graph completes within a + CI time budget (each test < 5 s per repo policy). +- Output is byte-identical across Linux/macOS (no inventory-order dependence). + +**Constraints:** +- The `#[reduction]` macro and overhead declaration syntax stay unchanged (dozens of + rule files untouched). +- Internal APIs and CLI output format may break (0.x semver). +- No new external dependencies. + +## Prior art & landscape + +Surveyed via four research passes (CAS systems; compiler symbolic-cost systems; +e-graph engines; asymptotics theory and formalization). Borrow-vs-build verdict: + +| Candidate | Verdict | Why | +|---|---|---| +| Albert–Alonso–Arenas–Genaim–Puebla, *Asymptotic Resource Usage Bounds* (APLAS 2009) | **Adopt as spec** | Published normal form (sums of products of `2^(r·A)`, `A^r`, `log A`) with a soundness theorem `e ∈ Θ(asymp(e))` — our correctness contract | +| SageMath `AsymptoticRing` / growth groups | **Borrow the design, not the code** | GPL; the core (exponent-vector arithmetic + poset of summands with O-term absorption) is small enough to reimplement cleanly | +| KoAT weakly-monotone bound grammar (Brockschmidt et al., TOPLAS 2016) | **Adopt as axiom** | Weak monotonicity ⇒ composition-by-substitution is sound ⇒ Pareto label search is correct (isotonicity) | +| LLVM SCEV / GCC chrec | **Adopt patterns** | Construction-time canonicalization, explicit budgets with graceful degradation, absorbing "don't know" sentinel (`SCEVCouldNotCompute`, `chrec_dont_know`) | +| Multivariate Big-O semantics: Howell (KSU TR 2007-4); Guéneau–Charguéraud–Pottier (ESOP 2018) | **Adopt definition** | Naive multivariate O is inconsistent (Howell Thm 2.3/2.4); the product-filter definition restricted to nonnegative weakly-monotone functions is the trustworthy one | +| McRAPTOR / OpenTripPlanner `ParetoSet` / nigiri `pareto_set.h`; Martins 1984; NAMOA* | **Adopt algorithm** | Per-node label bags (antichains) with dominance pruning are the industry and literature standard for partial-order path costs; enumerate-then-filter appears nowhere as a recommended method | +| ProblemReductions.jl `reduction_paths` | **Anti-pattern baseline** | `all_simple_paths` with no cost model, no ranking, no filter; survives only because its graph is tiny | +| egg / egglog e-graphs | **Dropped** | Directional normalization doesn't need equality saturation (Cranelift aegraph retrospective: mean e-class size 1.13); egglog API unstable | +| SymPy / GiNaC / Symbolica | **Concepts only** | Never auto-expand; deterministic total order on atoms; function-registry extensibility (deferred with F6) | + +Nothing is directly reusable as a dependency; this is a build against published specs. + +**Empirical inventory scan** (drives the grammar decision): registered overhead +expressions are overwhelmingly polynomial with subtraction and constant division. +Exceptions: one `log` factor (`ksatisfiability_*`: `(num_vars + num_clauses)^2 * +log(num_vars + num_clauses + 1)`), one genuine exponential +(`highlyconnecteddeletion_ilp.rs`: `num_vars = "2^num_vertices"`), and one +`sqrt((x)^2)` used as an absolute-value idiom. `declare_variants!` complexity strings +are heavily exponential, but they are consumed only by `pred list/show` display and +the dropped F8 — outside this design's data path. + +## Features + +Selected (rough, agentic-coding-adjusted estimates): + +| # | Feature | Effort | +|---|---|---| +| F1 | Growth domain: `GrowthTerm`/`Growth` antichain, symbolic dominance, pruning, absorbing `Unknown`, caps with upward widening | ~2–3 days | +| F2 | Replace the `big_o.rs` pipeline with the growth domain; delete `canonical.rs`; issue-1069 regression + whole-graph CI budget tests | ~1–2 days | +| F3 | Pareto label search kernel replacing `dijkstra`, with two label domains: F3a asymptotic (`Growth` per size field) and F3b concrete instance (**measured**: execute reductions, prune via symbolic pre-flight guards + budget + branch-and-bound) | ~3–4 days | +| F12 | Per-edge overhead calibration test: canonical examples run through `reduce_to()`, measured sizes must not exceed formula predictions | ~0.5–1 day | +| F4 | CLI/MCP surface: Pareto-front output, deterministic ordering, `--json` no longer renders text | ~1–2 days | +| F5+F11 (merged support work, folded into F1/F3/F4) | Redundancy check (`find_dominated_rules`) rewired to the same dominance order; `Growth` serde + `Display` consumed by CLI JSON and paper export | ~1.5 days | + +Total: ~10–14 days. + +Deferred / dropped, with reasons: + +- **F6 `Expr::Func(FuncKind)` registry** and **F7 shared parser crate** — deferred to a + later milestone. Genuine extensibility improvements, but independent of this + milestone's goal; the growth domain consumes `Expr` as-is. +- **F8 effective-complexity ranking** (target complexity ∘ overhead) — deferred until a + concrete find-problem need; requires an exponential part in `GrowthTerm` (see + Extensibility). +- **F9 convex-hull/AM-GM pruning** — deferred until antichain sizes measurably hurt; + Pareto pruning suffices at current variable counts. +- **F10 egg-based display simplification** — dropped per survey (directional ruleset + does not need equality saturation). + +## Semantic foundation (normative) + +These definitions and axioms are the trust contract; tests enforce them. + +- **Definition (multivariate Big-O, product filter).** For size functions + `f, g : ℕ_{≥2}^k → ℝ_{≥0}`: `g ∈ O(f)` iff `∃ c > 0, N` such that + `g(x) ≤ c·f(x)` whenever **all** variables `x_i ≥ N`. (Howell's `O_∀`; + Guéneau et al.'s product filter.) +- **Domain axioms.** Every expression admitted to the growth domain is nonnegative + and weakly monotone (nondecreasing in each variable) on `vars ≥ 2`. Under these + axioms Howell's inconsistencies vanish and `f + g ≍ max(f, g)` up to a constant + factor, which licenses `add = antichain union + prune`. +- **Widening rules (always upward, i.e. toward a valid upper bound):** + - Subtraction: `a − b ⇝ a + b` (sound since `b ≥ 0`; also covers the + `sqrt((a−b)^2)` absolute-value idiom because `|a−b| ≤ a+b`). + - Constant division and all multiplicative constants: dropped on entry. + - Exponentials with **linear** exponents (`c^x`, `c^(r·x)`, `exp(x)`) are + first-class (see M1's `exp` field). Nonlinear exponents (`2^(n*k)`, + `2^sqrt(n)`, double exponentials), `factorial(·)`, and negative exponents: + `Growth::Unknown` (absorbing). +- **Forbidden moves (documented + tested):** never specialize a variable to a + constant inside an O-fact; never rescale coefficients of exponents + (`2^(2n) ∉ O(2^n)` — exp rates compare coefficientwise, exactly). +- **Isotonicity invariant (for search):** if label `A` dominates label `B`, then for + any edge `e`, `extend(A, e)` dominates `extend(B, e)`. This follows from the + monotonicity axiom (composition by substitution into monotone expressions) and is + the correctness condition for dominance pruning in M3. + +## Modules + +Only one new file. Everything else is in-place replacement; net LOC is expected +near zero or negative (`canonical.rs`, 431 lines, is deleted). + +### M1 — `src/growth.rs` (the one new entity) + +```rust +/// One growth monomial, e.g. 2^(3k)·n^2·m·log(n) → +/// { exp: {k:3.0}, poly: {n:2.0, m:1.0}, logs: {n:1} }. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GrowthTerm { + exp: BTreeMap<&'static str, f64>, // variable → rate, base normalized to 2 + // (3^n → {n: log2(3)}); linear forms only + poly: BTreeMap<&'static str, f64>, // variable → degree (0.5 covers sqrt) + logs: BTreeMap<&'static str, u32>, // variable → log power +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Growth { + /// Antichain of pairwise-incomparable dominant terms, sorted by a + /// deterministic total order (for stable output/serialization). + Terms(Vec), + /// Absorbing sentinel: exp/factorial/negative exponents, or cap overflow + /// that even widening cannot represent. Absorbs through all operations. + Unknown, +} +``` + +Operations (each prunes back to an antichain immediately): + +- `Growth::from_expr(&Expr) -> Growth` — single bottom-up pass, linear in tree size. + `Var → {poly:{v:1}}`; `Const → O(1)` (empty term); `Add → union + prune`; + `Mul → pairwise map-merge + prune`; `Pow(base, const k ≥ 0) →` compute base's + antichain, then pairwise products (never expands the underlying sums); + `Log(a) → log(dominant(a))` using `log(n^a·m^b) ≍ log n + log m`; + `Sqrt = Pow 0.5`; everything else → `Unknown`. +- `dominates(&GrowthTerm, &GrowthTerm) -> bool` — per variable, lexicographic on + (exp rate, poly degree, log power); dominated iff ≤ on every variable and < on at + least one. This decides e.g. `1.001^n ≻ n^100` correctly, which the sampling + heuristic gets wrong. + Purely symbolic; replaces `numerical_dominance_check`. +- Caps: antichain length cap (default 32). On overflow, **widen upward** to the + single term taking the componentwise max of all exponents (a valid upper bound), + never truncate by order. +- Axiom guards: `debug_assert!` nonnegativity/monotonicity preconditions at entry. + +Deps: read-only on `expr.rs`. Serde derive here is the whole of former F11. + +### M2 — `big_o.rs` pipeline replacement + +`big_o_normal_form(&Expr) -> Result` keeps its +signature: internally `Growth::from_expr` → render `Growth` back to a display `Expr` +(`Unknown` maps to the existing `Unsupported` error). CLI callers (`big_o_of`, +`overhead_to_json`, `format_path_text`) are untouched. `compose_path_overhead` +continues to produce the compact nested `Expr` (≤ ~2 KB in the worst observed case); +`from_expr` walks it in microseconds — **no caching, no registry changes**. +`canonical.rs` and the `asymptotic_normal_form` compatibility wrapper are deleted +along with their unit tests (internal API breakage is in-scope). + +`pred-sym` (the standalone symbolic CLI, used by the find-problem skills for +`big-o` and `eval`) follows suit: the `canon` subcommand is deleted (no live +consumers), and `compare` narrows its semantics to Big-O equivalence via the growth +domain. `big-o` keeps working on the skills' effective-complexity inputs +(`1.5^n * n^2`) thanks to the linear `exp` field; nonlinear-exponent inputs report +`Unknown` and the skills fall back to `pred-sym eval`. + +Alternatives considered: capped expansion (rejected: keeps the exponential algorithm +and reintroduces order-dependent truncation); per-edge growth caching in +`ReductionEntry` with per-path folding (rejected for now: YAGNI at current graph +size; revisit if profiling ever shows `from_expr` on composed paths as hot). + +### M3 — Pareto label search kernel (`src/rules/graph.rs`, in-place) + +Replace `dijkstra` (~60 lines) with one generic label-setting search (~100 lines) +plus a minimal trait: + +```rust +pub trait PathLabel: Clone { + fn extend(&self, edge: &ReductionEdge) -> Self; // must be isotone + fn dominates(&self, other: &Self) -> bool; // partial order +} +``` + +- Per-node **bag** = antichain of non-dominated labels, each with a predecessor + pointer for path reconstruction (McRAPTOR structure). +- Deterministic bounding, in the style of transit routers: hop cap (default 16) and + per-node bag cap with a **deterministic tie-break** (fewest hops, then + lexicographic node-name order) — never iteration-order truncation. +- Label domains: + - **F3a asymptotic:** label = `BTreeMap` mapping each size field of + the current node to its growth in the source's variables; `extend` substitutes + the edge's overhead expressions; `dominates` is componentwise. Exponential + growth is comparable via the `exp` field (polynomial paths dominate exponential + ones); `Unknown` fields make a label dominated by any known label — undecidable + paths rank last, which is the honest ranking. + - **F3b instance (measured):** for a concrete instance, formulas are advisory — + **measured sizes are authoritative**. Overhead formulas are scaling upper bounds + over the declared size fields and can be arbitrarily loose on + structure-dependent constructions (see #107), so they must never arbitrate + between concrete candidates. Label = the actual `ProblemSize` measured on the + constructed intermediate problem (plus the reduction chain itself, reused for + solving/witness extraction by the winner); `extend` executes the edge's + `reduce_to()` and measures. Pruning stack, in order: + 1. **Symbolic pre-flight guard:** evaluate the edge's overhead formula at the + current *measured* size; if even the (upper-bound) prediction exceeds the + hard size budget, skip without executing. Because formulas are upper bounds + (enforced by the per-edge calibration test), this guard errs only toward + over-skipping — a catastrophic construction is never started, making OOM + structurally impossible. + 2. **Measured budget check** after execution. + 3. **Branch-and-bound** against the best completed path's final size. + 4. **Componentwise measured-size dominance** — heuristic under a documented + size-monotone-future assumption; `--exhaustive` disables this one guard + (1–3 remain, and are sound), falling back to budgeted full enumeration. + This fixes the path-dependent-cost hole in the current Dijkstra *and* removes + the dependency on formula accuracy for concrete decisions. +- `find_cheapest_path*` become thin wrappers returning the front (instance mode + typically collapses to a single optimum after the numeric tie-break). +- `find_dominated_rules` / `compare_overhead` (`src/rules/analysis.rs`) are rewired + to the same `dominates` order, deleting their bespoke comparison heuristics — + one trusted comparison everywhere (former F5). +- `all_simple_paths`-based enumeration (`find_all_paths`, `find_paths_up_to`) remains + solely for the explicit `--all` listing use case, not for optimum-finding. + +Alternatives considered: enumerate-then-filter (rejected: combinatorial growth as the +graph densifies, and any truncation limit is iteration-order-dependent — the sibling +package ProblemReductions.jl does exactly this, with no cost model, and it is the +baseline we are improving on); a generic semiring algebraic-path framework (rejected: +over-engineering for two label domains); formula-evaluated instance labels (rejected +after review: overhead formulas are upper bounds over declared size fields and can be +arbitrarily loose on structure-dependent constructions, so a formula-ranked front may +not contain the true winner — measured sizes are the ground truth and affordable at +interactive scales, with formulas retained as pre-flight guards and ordering +heuristics). + +### M4 — CLI/MCP surface (`problemreductions-cli/src/commands/graph.rs`, in-place) + +- Asymptotic `pred path S T`: print the Pareto front (typically 1–3 paths), each with + its Big-O per size field; paths whose composed growth is `Unknown` (nonlinear + exponents, factorial) are annotated explicitly instead of showing a fake bound. +- Instance mode (`--size …`): output shape unchanged (single best path). +- `path --all`: keep enumeration; Big-O per path now via M2 (fast); **`--json` mode + no longer builds the text rendering** (the unconditional `format_path_text` call + named in issue #1069). +- All path lists sorted by (hops, lexicographic names). JSON emits the structured + `Growth` serialization. (The paper export consumes raw overhead expressions, not + Big-O strings — verified unaffected.) + +## Quality requirements + +- **Reliability:** every public function terminates with an answer or `Unknown` — + no input can hang or OOM. Regression: issue #1069 path #34; a whole-graph test + enumerating paths (bounded length) between hot pairs asserts Big-O completion + within the CI budget (< 5 s per test). +- **Trustworthiness testing:** each `from_expr` transfer function and the dominance + order get randomized property tests (≥ 5000 checks, matching the repo's + verify-reduction culture): `eval(expr) ≤ C · eval(render(growth(expr)))` at large + sizes; `growth` idempotent on its own rendering; `dominates(a,b)` ⟹ sampled + `eval(b)/eval(a)` grows. Isotonicity of both `PathLabel` impls is property-tested. +- **Determinism:** identical output across platforms; a test compares `pred path` + output against golden files (antichain and front ordering are total and + deterministic by construction). +- **Performance:** `pred path KSat QUBO --all` end-to-end < 1 s (currently OOM). +- **Extensibility:** the linear `exp` field ships in M1 (required by the + find-problem skills' use of `pred-sym big-o` on effective-complexity + expressions). The remaining upgrade path — nonlinear exponents (a polynomial + exponent instead of a linear form), needed only if F8-style effective-complexity + ranking over complexity strings like `2^(num_edges * k)` is ever built — touches + only `dominates`, `mul`, and `from_expr`'s `Pow/Exp` arms; antichain machinery, + caps, search kernel, and serialization are unaffected. + +## Out of scope + +- `#[reduction]` macro, overhead declaration syntax, and all rule files. +- `declare_variants!` complexity strings and their validation + (`is_valid_complexity_notation`) — untouched; they are display-only in this design. +- FuncKind registry, shared parser crate, effective-complexity ranking, hull pruning, + egg display layer (deferred/dropped as listed under Features). + +## References + +- E. Albert, D. Alonso, P. Arenas, S. Genaim, G. Puebla. *Asymptotic Resource Usage + Bounds.* APLAS 2009. (Normal form + `Θ`-preservation theorem.) +- R. Howell. *On Asymptotic Notation with Multiple Variables.* Kansas State + University TR 2007-4. (Multivariate O inconsistencies; `O_∀` definition.) +- A. Guéneau, A. Charguéraud, F. Pottier. *A Fistful of Dollars: Formalizing + Asymptotic Complexity Claims via Deductive Program Verification.* ESOP 2018. + (Filter-based O; nonnegative-monotone cost discipline; documented pitfalls.) +- M. Brockschmidt, F. Emmes, S. Falke, C. Fuhs, J. Giesl. *Analyzing Runtime and Size + Complexity of Integer Programs.* TOPLAS 2016. (Weakly monotone bounds compose.) +- SageMath `sage.rings.asymptotic` (growth groups, O-term absorption) — design + reference only (GPL). +- LLVM `ScalarEvolution` / GCC `tree-chrec` — budgets, sentinels, construction-time + canonicalization. +- D. Delling, T. Pajor, R. Werneck. *Round-Based Public Transit Routing.* ALENEX + 2012 (McRAPTOR bags); E. Martins. *On a Multicriteria Shortest Path Problem.* EJOR + 1984; L. Mandow, J.-L. Pérez de la Cruz. *Multiobjective A\* with Consistent + Heuristics.* JACM 2010. +- D. Gruntz. *On Computing Limits in a Symbolic Manipulation System.* ETH 1996 + (dominance ordering; relevant when the `exp` field is added). +- Issue #1069 — root-cause analysis this design responds to. diff --git a/src/growth.rs b/src/growth.rs new file mode 100644 index 000000000..f3306b578 --- /dev/null +++ b/src/growth.rs @@ -0,0 +1,558 @@ +//! Symbolic growth domain: a dedicated asymptotic normal form for reduction +//! overhead expressions. +//! +//! Where [`crate::canonical`] answers Big-O questions by fully expanding an +//! [`Expr`] to monomial normal form (exponential in nesting depth — the root +//! cause of issue #1069), the growth domain computes an asymptotic upper bound +//! *bottom-up* in a single pass, linear in the tree size, without ever expanding +//! nested sums. +//! +//! # Representation +//! +//! A [`GrowthTerm`] is one growth monomial +//! +//! ```text +//! ∏_v 2^(exp[v] · v) · ∏_v v^(poly[v]) · ∏_v (log v)^(logs[v]) +//! ``` +//! +//! and a [`Growth`] is an *antichain* of pairwise-incomparable dominant terms +//! (each summand of an asymptotic sum), or the absorbing [`Growth::Unknown`] +//! sentinel for content we cannot bound symbolically. +//! +//! # Semantic foundation (the trust contract) +//! +//! Every expression admitted to the domain is assumed **nonnegative** and +//! **weakly monotone** (nondecreasing in each variable) on `vars ≥ 2`. Under +//! these axioms Howell's multivariate-O inconsistencies vanish and +//! `f + g ≍ max(f, g)` up to a constant factor, which licenses +//! `add = antichain union + prune`. All bounds produced are **upper** bounds. +//! +//! Widening (always toward a valid upper bound): +//! - Subtraction `a − b ⇝ a + b`: `a - b` is stored as `Add(a, Mul(-1, b))`; +//! the constant `-1` is dropped by [`Growth::from_expr`], so `from_expr` of a +//! subtraction is exactly the union of the two operands. This also covers the +//! `sqrt((a − b)^2)` absolute-value idiom (`|a − b| ≤ a + b`). +//! - Constants and constant multipliers/divisors are dropped on entry. +//! - Exponentials with a **linear** exponent (`c^x`, `c^(r·x)`, `exp(x)`) are +//! first-class via the `exp` field (base normalized to 2, e.g. `3^n → {n: +//! log2 3}`). Nonlinear exponents (`2^(n·k)`, `2^sqrt(n)`), `factorial(·)`, +//! and negative exponents widen to [`Growth::Unknown`], which absorbs through +//! every operation. +//! +//! # `Pow` note +//! +//! `Pow(base, k)` for a nonnegative constant `k` raises **each** antichain term +//! of `base` to the power `k` (scaling its exponents). This is the tight +//! asymptotic answer — `(n + m)^2 ≍ max(n, m)^2 = max(n^2, m^2)` by AM-GM, so no +//! binomial cross term is introduced — and it is what makes the widening chain +//! `sqrt((n − m)^2) ≍ n + m` hold exactly. + +use crate::expr::Expr; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +/// Maximum number of terms kept in an antichain. On overflow the antichain is +/// widened upward to the single componentwise-max term (a valid upper bound), +/// never truncated by iteration order. +const ANTICHAIN_CAP: usize = 32; + +/// One growth monomial, e.g. `2^(3k) · n^2 · m · log(n)` → +/// `{ exp: {k: 3.0}, poly: {n: 2.0, m: 1.0}, logs: {n: 1} }`. +/// +/// Empty maps represent `O(1)`. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct GrowthTerm { + /// variable → exponential rate, base normalized to 2 (`3^n → {n: log2 3}`); + /// linear exponent forms only. + exp: BTreeMap<&'static str, f64>, + /// variable → polynomial degree (`0.5` covers `sqrt`). + poly: BTreeMap<&'static str, f64>, + /// variable → log power. + logs: BTreeMap<&'static str, u32>, +} + +/// The asymptotic growth class of an [`Expr`]. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum Growth { + /// Antichain of pairwise-incomparable dominant terms, sorted by a + /// deterministic total order for platform-stable output/serialization. + Terms(Vec), + /// Absorbing sentinel: exp/factorial/negative exponents, or cap overflow + /// that even widening cannot represent. Absorbs through all operations. + Unknown, +} + +impl GrowthTerm { + /// The `O(1)` term (all maps empty). + fn one() -> Self { + GrowthTerm { + exp: BTreeMap::new(), + poly: BTreeMap::new(), + logs: BTreeMap::new(), + } + } + + /// The `(exp rate, poly degree, log power)` triple for a variable, treating + /// an absent variable as `(0, 0, 0)`. + fn triple(&self, var: &str) -> (f64, f64, u32) { + ( + self.exp.get(var).copied().unwrap_or(0.0), + self.poly.get(var).copied().unwrap_or(0.0), + self.logs.get(var).copied().unwrap_or(0), + ) + } + + /// A deterministic, platform-stable total-order key. `{v:?}` renders an + /// `f64` at full precision and is stable across platforms. + fn sort_key(&self) -> String { + let mut s = String::new(); + for (k, v) in &self.exp { + s.push('E'); + s.push_str(k); + s.push('='); + s.push_str(&format!("{v:?}")); + s.push(';'); + } + s.push('|'); + for (k, v) in &self.poly { + s.push('P'); + s.push_str(k); + s.push('='); + s.push_str(&format!("{v:?}")); + s.push(';'); + } + s.push('|'); + for (k, v) in &self.logs { + s.push('L'); + s.push_str(k); + s.push('='); + s.push_str(&v.to_string()); + s.push(';'); + } + s + } + + /// Raise this term to a nonnegative real power `k` (scale every exponent). + /// Log powers are `u32`; a fractional result is rounded **up** (a valid + /// upper bound, since `(log v)^p ≤ (log v)^⌈p⌉` for `v ≥ 2`). + fn powf(&self, k: f64) -> GrowthTerm { + let mut r = GrowthTerm::one(); + for (v, rate) in &self.exp { + r.exp.insert(v, rate * k); + } + for (v, deg) in &self.poly { + r.poly.insert(v, deg * k); + } + for (v, p) in &self.logs { + r.logs.insert(v, ((*p as f64) * k).ceil() as u32); + } + r + } + + /// Multiply two monomials (add matching exponents). + fn mul(&self, other: &GrowthTerm) -> GrowthTerm { + let mut t = self.clone(); + for (k, v) in &other.exp { + *t.exp.entry(k).or_insert(0.0) += *v; + } + for (k, v) in &other.poly { + *t.poly.entry(k).or_insert(0.0) += *v; + } + for (k, v) in &other.logs { + *t.logs.entry(k).or_insert(0) += *v; + } + t + } + + /// Partial order on terms: `Some(Greater)` iff `self` dominates `other` + /// (`≥` on every variable and `>` on at least one), where per variable the + /// `(exp rate, poly degree, log power)` triples are compared + /// lexicographically. Returns `None` for incomparable terms. + fn cmp(&self, other: &GrowthTerm) -> Option { + let mut vars: BTreeSet<&'static str> = BTreeSet::new(); + for m in [&self.exp, &other.exp] { + vars.extend(m.keys().copied()); + } + for m in [&self.poly, &other.poly] { + vars.extend(m.keys().copied()); + } + for m in [&self.logs, &other.logs] { + vars.extend(m.keys().copied()); + } + + let mut saw_gt = false; + let mut saw_lt = false; + for v in &vars { + match cmp_triple(self.triple(v), other.triple(v)) { + Ordering::Greater => saw_gt = true, + Ordering::Less => saw_lt = true, + Ordering::Equal => {} + } + } + match (saw_gt, saw_lt) { + (true, true) => None, + (true, false) => Some(Ordering::Greater), + (false, true) => Some(Ordering::Less), + (false, false) => Some(Ordering::Equal), + } + } + + /// `true` iff `self` dominates `other` (grows at least as fast, and strictly + /// faster on at least one variable). + fn dominates(&self, other: &GrowthTerm) -> bool { + matches!(self.cmp(other), Some(Ordering::Greater)) + } + + /// `true` iff `self` dominates `other` or is asymptotically equal to it. + fn dominates_or_eq(&self, other: &GrowthTerm) -> bool { + matches!( + self.cmp(other), + Some(Ordering::Greater) | Some(Ordering::Equal) + ) + } +} + +/// Lexicographic comparison of `(exp rate, poly degree, log power)` triples. +fn cmp_triple(a: (f64, f64, u32), b: (f64, f64, u32)) -> Ordering { + a.0.partial_cmp(&b.0) + .unwrap_or(Ordering::Equal) + .then(a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)) + .then(a.2.cmp(&b.2)) +} + +impl Growth { + /// Compute the growth class of an expression in a single bottom-up pass. + pub fn from_expr(expr: &Expr) -> Growth { + // Any wholly constant subexpression is O(1). Handling it up front keeps + // constant idioms (`n / 2` = `n * 2^(-1)`, `factorial(3)`, `2^3`) out of + // the negative-exponent / factorial `Unknown` bails below. + if expr.constant_value().is_some() { + return Growth::Terms(vec![GrowthTerm::one()]); + } + match expr { + // A pure constant is O(1) — the empty term (also caught above). + Expr::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), + Expr::Var(v) => { + let mut t = GrowthTerm::one(); + t.poly.insert(*v, 1.0); + Growth::Terms(vec![t]) + } + Expr::Add(a, b) => add(Growth::from_expr(a), Growth::from_expr(b)), + Expr::Mul(a, b) => mul(Growth::from_expr(a), Growth::from_expr(b)), + Expr::Pow(base, exp) => pow_expr(base, exp), + Expr::Exp(a) => exponential(std::f64::consts::E, a), + Expr::Log(a) => log_growth(Growth::from_expr(a)), + Expr::Sqrt(a) => pow_const(Growth::from_expr(a), 0.5), + Expr::Factorial(_) => Growth::Unknown, + } + } + + /// Partial order: `true` iff `self` grows at least as fast as `other`. + /// + /// Per the growth-rate reading, [`Growth::Unknown`] is the top element (it + /// may be arbitrarily large, e.g. a factorial), so it dominates everything + /// and nothing known dominates it. For two term antichains, `self` + /// dominates `other` iff every term of `other` is dominated-or-equal by + /// some term of `self` — the standard antichain (Pareto) comparison. + pub fn dominates(&self, other: &Growth) -> bool { + match (self, other) { + (Growth::Unknown, _) => true, + (Growth::Terms(_), Growth::Unknown) => false, + (Growth::Terms(a), Growth::Terms(b)) => { + b.iter().all(|tb| a.iter().any(|ta| ta.dominates_or_eq(tb))) + } + } + } +} + +/// Prune a bag of terms to its maximal antichain: drop any term dominated by +/// another and collapse exact duplicates. The resulting *set* is independent of +/// input order. +fn prune(terms: Vec) -> Vec { + let mut result: Vec = Vec::new(); + for t in terms { + if result.iter().any(|r| r.dominates_or_eq(&t)) { + continue; + } + result.retain(|r| !t.dominates(r)); + result.push(t); + } + result +} + +/// The single term taking the componentwise maximum of every exponent — a valid +/// upper bound that dominates every input term. +fn componentwise_max(terms: &[GrowthTerm]) -> GrowthTerm { + let mut m = GrowthTerm::one(); + for t in terms { + for (k, v) in &t.exp { + let e = m.exp.entry(*k).or_insert(0.0); + if *v > *e { + *e = *v; + } + } + for (k, v) in &t.poly { + let e = m.poly.entry(*k).or_insert(0.0); + if *v > *e { + *e = *v; + } + } + for (k, v) in &t.logs { + let e = m.logs.entry(*k).or_insert(0); + if *v > *e { + *e = *v; + } + } + } + m +} + +/// Prune, apply the antichain cap (widening upward on overflow), and sort into +/// the deterministic total order. +fn make_growth(terms: Vec) -> Growth { + let mut pruned = prune(terms); + if pruned.len() > ANTICHAIN_CAP { + pruned = vec![componentwise_max(&pruned)]; + } + // Axiom guard: exponents are nonnegative (weak monotonicity precondition). + for t in &pruned { + debug_assert!(t.exp.values().all(|r| *r >= 0.0), "negative exp rate"); + debug_assert!(t.poly.values().all(|d| *d >= 0.0), "negative poly degree"); + } + pruned.sort_by_key(|a| a.sort_key()); + Growth::Terms(pruned) +} + +/// Antichain union (asymptotic `+ ≍ max`). +fn add(a: Growth, b: Growth) -> Growth { + match (a, b) { + (Growth::Unknown, _) | (_, Growth::Unknown) => Growth::Unknown, + (Growth::Terms(mut x), Growth::Terms(y)) => { + x.extend(y); + make_growth(x) + } + } +} + +/// Pairwise product of two antichains. +fn mul(a: Growth, b: Growth) -> Growth { + match (a, b) { + (Growth::Unknown, _) | (_, Growth::Unknown) => Growth::Unknown, + (Growth::Terms(x), Growth::Terms(y)) => { + let mut prod = Vec::with_capacity(x.len() * y.len()); + for tx in &x { + for ty in &y { + prod.push(tx.mul(ty)); + } + } + make_growth(prod) + } + } +} + +/// Raise a whole antichain to a nonnegative real power `k` (raise each term). +fn pow_const(g: Growth, k: f64) -> Growth { + match g { + Growth::Unknown => Growth::Unknown, + Growth::Terms(terms) => make_growth(terms.iter().map(|t| t.powf(k)).collect()), + } +} + +/// Transfer function for `Pow(base, exp)`. +fn pow_expr(base: &Expr, exp: &Expr) -> Growth { + if let Some(k) = exp.constant_value() { + // Constant exponent → polynomial power. + if k < 0.0 { + return Growth::Unknown; // negative exponent + } + if k == 0.0 { + return Growth::Terms(vec![GrowthTerm::one()]); // x^0 = O(1) + } + pow_const(Growth::from_expr(base), k) + } else if let Some(c) = base.constant_value() { + // Constant base, variable exponent → exponential. + exponential(c, exp) + } else { + // Variable base and variable exponent (e.g. n^m) → not representable. + Growth::Unknown + } +} + +/// Transfer function for `c^exp` (also `exp(x)` with `c = e`). Requires a linear +/// exponent; anything else widens to [`Growth::Unknown`]. +fn exponential(c: f64, exp: &Expr) -> Growth { + if c <= 0.0 { + return Growth::Unknown; + } + if c <= 1.0 { + // 1^x = 1, and c^x with 0 < c < 1 decays: both bounded by O(1). + return Growth::Terms(vec![GrowthTerm::one()]); + } + match linear_form(exp) { + None => Growth::Unknown, // nonlinear exponent + Some(coeffs) => { + let log2c = c.log2(); + let mut term = GrowthTerm::one(); + for (v, coeff) in coeffs { + let rate = coeff * log2c; + // Drop non-positive rates (upward widening: 2^(n - m) ≤ 2^n). + if rate > 0.0 { + term.exp.insert(v, rate); + } + } + make_growth(vec![term]) + } + } +} + +/// Extract the linear coefficients of an expression (variable → coefficient), +/// or `None` if the expression is not linear in its variables. The additive +/// constant term is ignored (dropped). Pure constants map to the empty form. +fn linear_form(expr: &Expr) -> Option> { + if expr.constant_value().is_some() { + return Some(BTreeMap::new()); + } + match expr { + Expr::Var(v) => { + let mut m = BTreeMap::new(); + m.insert(*v, 1.0); + Some(m) + } + Expr::Add(a, b) => { + let mut m = linear_form(a)?; + for (k, v) in linear_form(b)? { + *m.entry(k).or_insert(0.0) += v; + } + Some(m) + } + Expr::Mul(a, b) => { + // A linear term times a variable is nonlinear, so one side must be + // a constant scalar. + if let Some(c) = a.constant_value() { + Some( + linear_form(b)? + .into_iter() + .map(|(k, v)| (k, v * c)) + .collect(), + ) + } else if let Some(c) = b.constant_value() { + Some( + linear_form(a)? + .into_iter() + .map(|(k, v)| (k, v * c)) + .collect(), + ) + } else { + None + } + } + // Pow / Exp / Log / Sqrt / Factorial of variables are nonlinear. + _ => None, + } +} + +/// Transfer function for `Log(a)`: `log` of an antichain is `log` of its +/// dominant term(s), unioned. Uses `log(n^a · m^b) ≍ log n + log m` and +/// `log(2^(r·n)) ≍ n`. +fn log_growth(g: Growth) -> Growth { + match g { + Growth::Unknown => Growth::Unknown, + Growth::Terms(terms) => { + let mut out = Vec::new(); + for t in &terms { + out.extend(log_term(t)); + } + if out.is_empty() { + out.push(GrowthTerm::one()); // log(O(1)) = O(1) + } + make_growth(out) + } + } +} + +/// `log` of a single monomial, returned as its own (small) antichain of summands. +fn log_term(t: &GrowthTerm) -> Vec { + // log(2^(r·n) · …) ≍ r·n ≍ n: the exponential part dominates and is linear. + let exp_vars: Vec<&'static str> = t + .exp + .iter() + .filter(|(_, r)| **r > 0.0) + .map(|(k, _)| *k) + .collect(); + if !exp_vars.is_empty() { + return exp_vars + .into_iter() + .map(|v| { + let mut g = GrowthTerm::one(); + g.poly.insert(v, 1.0); + g + }) + .collect(); + } + // log(n^a · m^b) ≍ log n + log m. + let poly_vars: Vec<&'static str> = t + .poly + .iter() + .filter(|(_, d)| **d > 0.0) + .map(|(k, _)| *k) + .collect(); + if !poly_vars.is_empty() { + return poly_vars + .into_iter() + .map(|v| { + let mut g = GrowthTerm::one(); + g.logs.insert(v, 1); + g + }) + .collect(); + } + // log((log v)^s) = log log v, upper-bounded by log v (log log v ≤ log v for v ≥ 2). + let log_vars: Vec<&'static str> = t.logs.keys().copied().collect(); + if !log_vars.is_empty() { + return log_vars + .into_iter() + .map(|v| { + let mut g = GrowthTerm::one(); + g.logs.insert(v, 1); + g + }) + .collect(); + } + // Empty term: log(O(1)) = O(1). + vec![GrowthTerm::one()] +} + +// --- serde --- +// +// `GrowthTerm` uses `&'static str` keys (to align with `Expr::Var`), which serde +// cannot deserialize directly. `Deserialize` reads owned `String` keys and leaks +// them to `&'static str`, matching the convention of `Expr`'s runtime parser. +// Each unique key leaks a small allocation that is never freed; acceptable for +// the CLI's one-shot serialization, not for hot loops with adversarial input. + +impl<'de> serde::Deserialize<'de> for GrowthTerm { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + struct Repr { + exp: BTreeMap, + poly: BTreeMap, + logs: BTreeMap, + } + fn leak(s: String) -> &'static str { + Box::leak(s.into_boxed_str()) + } + let r = Repr::deserialize(deserializer)?; + Ok(GrowthTerm { + exp: r.exp.into_iter().map(|(k, v)| (leak(k), v)).collect(), + poly: r.poly.into_iter().map(|(k, v)| (leak(k), v)).collect(), + logs: r.logs.into_iter().map(|(k, v)| (leak(k), v)).collect(), + }) + } +} + +#[cfg(test)] +#[path = "unit_tests/growth.rs"] +mod tests; diff --git a/src/lib.rs b/src/lib.rs index 2083070c1..74e94267d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,11 @@ pub mod error; pub mod example_db; pub mod export; pub(crate) mod expr; +// The growth domain is consumed by later milestone issues (big_o.rs / search +// rewiring); nothing references it on `main` yet, so its public API is dead code +// for now. +#[allow(dead_code)] +pub(crate) mod growth; pub mod io; pub mod models; pub mod registry; diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs new file mode 100644 index 000000000..1461f7bd8 --- /dev/null +++ b/src/unit_tests/growth.rs @@ -0,0 +1,228 @@ +//! Unit tests for the symbolic growth domain (`src/growth.rs`). + +use super::{add, make_growth, mul, Growth, GrowthTerm}; +use crate::expr::Expr; + +/// Build a term from `(exp, poly, logs)` entry lists. +fn term( + exp: &[(&'static str, f64)], + poly: &[(&'static str, f64)], + logs: &[(&'static str, u32)], +) -> GrowthTerm { + GrowthTerm { + exp: exp.iter().copied().collect(), + poly: poly.iter().copied().collect(), + logs: logs.iter().copied().collect(), + } +} + +fn terms_of(g: &Growth) -> &[GrowthTerm] { + match g { + Growth::Terms(t) => t, + Growth::Unknown => panic!("expected Terms, got Unknown"), + } +} + +fn g(s: &str) -> Growth { + Growth::from_expr(&Expr::parse(s)) +} + +// --- The six named verification cases from issue #1075 --- + +/// 1. No-expansion regression: the nested sum-of-squares shape that OOM'd in +/// issue #1069 is handled without expansion, quickly, with few terms. +#[test] +fn test_growth_no_expansion_regression() { + let e = Expr::parse("(12*(n + 3*m) + 5)^2 * (12*(n + 3*m) + 5)^2"); + let start = std::time::Instant::now(); + let result = Growth::from_expr(&e); + let elapsed = start.elapsed(); + + let ts = terms_of(&result); + assert!( + ts.contains(&term(&[], &[("n", 4.0)], &[])), + "expected n^4 in {ts:?}" + ); + assert!( + ts.contains(&term(&[], &[("m", 4.0)], &[])), + "expected m^4 in {ts:?}" + ); + assert!(ts.len() <= 6, "expected <= 6 terms, got {}", ts.len()); + assert!(elapsed.as_millis() < 10, "from_expr took {elapsed:?}"); +} + +/// 2. Dominance beats the old sampling heuristic: `1.001^n` dominates `n^100` +/// (any positive exponential rate outranks any polynomial degree). +#[test] +fn test_growth_exponential_dominates_polynomial() { + let exp = g("1.001^n"); + let poly = g("n^100"); + assert!(exp.dominates(&poly)); + assert!(!poly.dominates(&exp)); +} + +/// 3. Incomparability is honest: neither `n^2` nor `n*m` dominates the other, +/// and both are kept in the sum. +#[test] +fn test_growth_incomparable_terms_both_kept() { + let n2 = g("n^2"); + let nm = g("n*m"); + assert!(!n2.dominates(&nm)); + assert!(!nm.dominates(&n2)); + + let sum = g("n^2 + n*m"); + assert_eq!(terms_of(&sum).len(), 2); +} + +/// 4. Exponent rates are exact: `2^(2n)` dominates `2^n` (not conversely), and +/// `3^n` dominates `2^n` via base-2 rates. +#[test] +fn test_growth_exponent_rates_exact() { + let two_2n = g("2^(2*n)"); + let two_n = g("2^n"); + assert!(two_2n.dominates(&two_n)); + assert!(!two_n.dominates(&two_2n)); + + let three_n = g("3^n"); + assert!(three_n.dominates(&two_n)); + assert!(!two_n.dominates(&three_n)); +} + +/// 5. Widening: subtraction widens to addition, including the `sqrt((a-b)^2)` +/// absolute-value idiom. +#[test] +fn test_growth_widening() { + assert_eq!(g("n - m"), g("n + m")); + assert_eq!(g("sqrt((n - m)^2)"), g("n + m")); +} + +/// 6. Determinism: the antichain is canonically sorted, so structurally +/// equivalent inputs are equal regardless of term order. +#[test] +fn test_growth_determinism() { + assert_eq!(g("n*m + m*n"), g("m*n + n*m")); +} + +// --- Negative control --- + +/// Unsupported content widens to `Unknown`, and `Unknown` absorbs through add +/// and mul — unsupported content can never silently produce a fake bound. +#[test] +fn test_growth_unknown_negative_control() { + assert_eq!(g("2^(n*k)"), Growth::Unknown); + assert_eq!(g("factorial(n)"), Growth::Unknown); + + // Absorption through the real `from_expr` add/mul paths. + assert_eq!(g("factorial(n) + n^2"), Growth::Unknown); + assert_eq!(g("n^2 + factorial(n)"), Growth::Unknown); + assert_eq!(g("factorial(n) * n^2"), Growth::Unknown); + assert_eq!(g("n^2 * factorial(n)"), Growth::Unknown); + + // Absorption at the operation level too. + let n2 = g("n^2"); + assert_eq!(add(Growth::Unknown, n2.clone()), Growth::Unknown); + assert_eq!(add(n2.clone(), Growth::Unknown), Growth::Unknown); + assert_eq!(mul(Growth::Unknown, n2.clone()), Growth::Unknown); + assert_eq!(mul(n2, Growth::Unknown), Growth::Unknown); +} + +// --- Additional coverage --- + +/// Pure constants, constant factors, and constant division are all O(1) / dropped. +#[test] +fn test_growth_constants_are_o1() { + let c = g("42"); + assert_eq!(terms_of(&c), [GrowthTerm::one()]); + + // A wholly constant subtree (including `2^3`, `factorial(3)`, `1/2`) is O(1). + assert_eq!(g("2^3"), c); + assert_eq!(g("factorial(3)"), c); + + // Constant multiplier and constant divisor drop out. + assert_eq!(g("3 * n"), g("n")); + assert_eq!(g("n / 2"), g("n")); +} + +/// `x^0` is O(1); a negative exponent on a variable base is not admitted. +#[test] +fn test_growth_pow_special_cases() { + assert_eq!(terms_of(&g("n^0")), [GrowthTerm::one()]); + assert_eq!(g("n^(-1)"), Growth::Unknown); + // Variable base with variable exponent is not representable. + assert_eq!(g("n^m"), Growth::Unknown); +} + +/// `exp(n)` uses base e; a decaying/unit base is bounded by O(1). +#[test] +fn test_growth_exponential_variants() { + // exp(n) = e^n = 2^(log2(e) * n): exponential, dominates any polynomial. + let en = g("exp(n)"); + assert!(en.dominates(&g("n^5"))); + // 2^(n-m) ≤ 2^n after dropping the negative rate. + assert_eq!(g("2^(n - m)"), g("2^n")); + // Unit / decaying bases collapse to O(1). + assert_eq!(g("1^n"), g("7")); +} + +/// `log` lowers each level: log of an exponential is linear, log of a +/// polynomial is a log, and log distributes over products as a sum. +#[test] +fn test_growth_log_levels() { + // log(2^n) ≍ n. + assert_eq!(g("log(2^n)"), g("n")); + // log(n) is a single log term. + assert_eq!( + g("log(n)"), + Growth::Terms(vec![term(&[], &[], &[("n", 1)])]) + ); + // log(n*m) ≍ log n + log m (two summands, not a product). + assert_eq!(terms_of(&g("log(n*m)")).len(), 2); + // log of a constant is O(1). + assert_eq!(terms_of(&g("log(5)")), [GrowthTerm::one()]); +} + +/// `Unknown` is the top of the growth order. +#[test] +fn test_growth_unknown_dominance() { + let n2 = g("n^2"); + assert!(Growth::Unknown.dominates(&n2)); + assert!(!n2.dominates(&Growth::Unknown)); + assert!(Growth::Unknown.dominates(&Growth::Unknown)); +} + +/// On antichain-cap overflow the domain widens up to the single componentwise +/// max term (a valid upper bound), never truncating by iteration order. +#[test] +fn test_growth_antichain_cap_widens() { + // 40 distinct single-variable terms are pairwise incomparable. + let vars: Vec<&'static str> = (0..40) + .map(|i| &*Box::leak(format!("v{i}").into_boxed_str())) + .collect(); + let many: Vec = vars.iter().map(|v| term(&[], &[(*v, 1.0)], &[])).collect(); + + let widened = make_growth(many); + let ts = terms_of(&widened); + assert_eq!(ts.len(), 1, "cap overflow should widen to one term"); + // The single term dominates every original (it carries all variables). + for v in &vars { + assert!( + ts[0].dominates(&term(&[], &[(*v, 1.0)], &[])) || ts[0] == term(&[], &[(*v, 1.0)], &[]) + ); + } +} + +/// Structured serde round-trips (with `&'static str` keys leaked on read), and +/// `Unknown` round-trips. +#[test] +fn test_growth_serde_roundtrip() { + let value = g("2^n * m^2 + n * log(k)"); + let json = serde_json::to_string(&value).unwrap(); + let back: Growth = serde_json::from_str(&json).unwrap(); + assert_eq!(value, back); + + let unknown_json = serde_json::to_string(&Growth::Unknown).unwrap(); + assert_eq!( + serde_json::from_str::(&unknown_json).unwrap(), + Growth::Unknown + ); +} From b198ddc5eae173d45e7719dcfdd0c3e2536cf19d Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 15:50:08 +0800 Subject: [PATCH 02/31] Rewire big_o_normal_form to the growth domain; delete canonical.rs (#1078) Swap `big_o_normal_form`'s implementation to the growth domain and delete the exponential-cost expansion machinery. One trusted asymptotic engine now backs all Big-O queries. - `big_o.rs`: `big_o_normal_form` is now `Growth::from_expr(expr).to_expr()`, mapping `Growth::Unknown` to the existing `Unsupported` error. Signature unchanged; the 360-line canonical projection is gone. - `growth.rs`: add `Growth::to_expr()` rendering the antichain back to a display `Expr`, de-normalizing exp rates to readable bases (`{n:1} -> 2^n`, `{n: log2 3} -> 3^n`, `{n: log2 e} -> exp(n)`, with float-snapping so `1.5^x` renders cleanly). - Delete `canonical.rs` (incl. the `MAX_CANONICAL_TERMS` stopgap) and its tests, the `asymptotic_normal_form` wrapper, the now-dead `CanonicalizationError`, and their `lib.rs` re-exports. - `pred-sym`: drop the `canon` subcommand; `compare` narrows to Big-O equivalence via the growth domain. - `analysis.rs`: `prepare_expr_for_comparison` stops canonicalizing (returns the expr clone); the full analysis-to-growth rewire is a separate issue. Behavioral changes from the stronger semantics, reflected in updated tests: the #1069-shaped `((a+b+c+d)^4)^4` now returns a real degree-16 bound instantly instead of erroring; `-1 * n` widens to `n` (constant factor dropped) instead of being rejected; `2^sqrt(n)` (nonlinear exponent) is now unsupported; exp/sqrt structural identities in the overhead comparator report Unknown until the analysis rewire lands. Verification (all from the issue): `cargo test` green; `grep canonical_form` returns nothing; `pred-sym big-o` prints `O(n^2)` / an exp*poly bound / a degree-4 bound instantly; `factorial(n)` and `canon` fail loudly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- problemreductions-cli/src/bin/pred_sym.rs | 57 +-- problemreductions-cli/tests/pred_sym_tests.rs | 12 +- src/big_o.rs | 392 +--------------- src/canonical.rs | 431 ------------------ src/expr.rs | 26 -- src/growth.rs | 73 +++ src/lib.rs | 9 +- src/rules/analysis.rs | 5 +- src/unit_tests/big_o.rs | 24 +- src/unit_tests/canonical.rs | 165 ------- src/unit_tests/expr.rs | 89 ---- src/unit_tests/rules/analysis.rs | 16 +- 12 files changed, 151 insertions(+), 1148 deletions(-) delete mode 100644 src/canonical.rs delete mode 100644 src/unit_tests/canonical.rs diff --git a/problemreductions-cli/src/bin/pred_sym.rs b/problemreductions-cli/src/bin/pred_sym.rs index daa28bf7a..c20c2b97b 100644 --- a/problemreductions-cli/src/bin/pred_sym.rs +++ b/problemreductions-cli/src/bin/pred_sym.rs @@ -1,5 +1,5 @@ use clap::{Parser, Subcommand}; -use problemreductions::{big_o_normal_form, canonical_form, Expr, ProblemSize}; +use problemreductions::{big_o_normal_form, Expr, ProblemSize}; #[derive(Parser)] #[command( @@ -19,11 +19,6 @@ enum Commands { /// Expression string expr: String, }, - /// Compute exact canonical form - Canon { - /// Expression string - expr: String, - }, /// Compute Big-O normal form BigO { /// Expression string @@ -33,7 +28,7 @@ enum Commands { #[arg(long)] raw: bool, }, - /// Compare two expressions (exits with code 1 if neither exact nor Big-O equal) + /// Compare two expressions for Big-O equivalence (exits 1 if not equal) Compare { /// First expression a: String, @@ -69,16 +64,6 @@ fn main() { let parsed = parse_expr_or_exit(&expr); println!("{parsed}"); } - Commands::Canon { expr } => { - let parsed = parse_expr_or_exit(&expr); - match canonical_form(&parsed) { - Ok(result) => println!("{result}"), - Err(e) => { - eprintln!("Error: {e}"); - std::process::exit(1); - } - } - } Commands::BigO { expr, raw } => { let parsed = parse_expr_or_exit(&expr); match big_o_normal_form(&parsed) { @@ -98,29 +83,31 @@ fn main() { Commands::Compare { a, b } => { let expr_a = parse_expr_or_exit(&a); let expr_b = parse_expr_or_exit(&b); - let canon_a = canonical_form(&expr_a); - let canon_b = canonical_form(&expr_b); let big_o_a = big_o_normal_form(&expr_a); let big_o_b = big_o_normal_form(&expr_b); println!("Expression A: {a}"); println!("Expression B: {b}"); - let mut exact_equal = false; - let mut big_o_equal = false; - if let (Ok(ca), Ok(cb)) = (&canon_a, &canon_b) { - exact_equal = ca == cb; - println!("Canonical A: {ca}"); - println!("Canonical B: {cb}"); - println!("Exact equal: {exact_equal}"); - } - if let (Ok(ba), Ok(bb)) = (&big_o_a, &big_o_b) { - big_o_equal = ba == bb; - println!("Big-O A: O({ba})"); - println!("Big-O B: O({bb})"); - println!("Big-O equal: {big_o_equal}"); - } - if !exact_equal && !big_o_equal { - std::process::exit(1); + match (&big_o_a, &big_o_b) { + (Ok(ba), Ok(bb)) => { + // Rendering is canonical, so equal growth ⇒ equal Big-O expr. + let big_o_equal = ba == bb; + println!("Big-O A: O({ba})"); + println!("Big-O B: O({bb})"); + println!("Big-O equal: {big_o_equal}"); + if !big_o_equal { + std::process::exit(1); + } + } + _ => { + if let Err(e) = &big_o_a { + println!("Big-O A: "); + } + if let Err(e) = &big_o_b { + println!("Big-O B: "); + } + std::process::exit(1); + } } } Commands::Eval { expr, vars } => { diff --git a/problemreductions-cli/tests/pred_sym_tests.rs b/problemreductions-cli/tests/pred_sym_tests.rs index 64b424d2a..9bf644973 100644 --- a/problemreductions-cli/tests/pred_sym_tests.rs +++ b/problemreductions-cli/tests/pred_sym_tests.rs @@ -13,11 +13,10 @@ fn test_pred_sym_parse() { } #[test] -fn test_pred_sym_canon_merge_terms() { +fn test_pred_sym_canon_subcommand_removed() { + // The exact-canonical-form engine is gone; `canon` is no longer a subcommand. let output = pred_sym().args(["canon", "n + n"]).output().unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert_eq!(stdout.trim(), "2 * n"); + assert!(!output.status.success()); } #[test] @@ -53,7 +52,10 @@ fn test_pred_sym_big_o_signed_polynomial() { #[test] fn test_pred_sym_big_o_sqrt_display() { - let output = pred_sym().args(["big-o", "2^(n^(1/2))"]).output().unwrap(); + // A fractional polynomial degree renders with sqrt notation. + // (`2^sqrt(n)` — a nonlinear exponent — is now unsupported, so use an + // in-domain sqrt input instead.) + let output = pred_sym().args(["big-o", "sqrt(n * m)"]).output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( diff --git a/src/big_o.rs b/src/big_o.rs index 1b782d862..7941ae026 100644 --- a/src/big_o.rs +++ b/src/big_o.rs @@ -1,387 +1,23 @@ -//! Big-O asymptotic projection for canonical expressions. +//! Big-O asymptotic normal form. //! -//! Takes the output of `canonical_form()` and projects it into an -//! asymptotic growth class by dropping dominated terms and constant factors. +//! Thin wrapper over the [growth domain](crate::growth): compute the growth +//! class of an expression bottom-up (linear cost, no monomial expansion) and +//! render it back to a display [`Expr`]. Content the growth domain cannot bound +//! symbolically ([`Growth::Unknown`] — nonlinear exponents, factorials, negative +//! exponents) maps to the [`AsymptoticAnalysisError::Unsupported`] error. -use crate::canonical::canonical_form; -use crate::expr::{AsymptoticAnalysisError, CanonicalizationError, Expr}; - -#[derive(Clone, Debug)] -struct ProjectedTerm { - expr: Expr, - negative: bool, -} +use crate::expr::{AsymptoticAnalysisError, Expr}; +use crate::growth::Growth; /// Compute the Big-O normal form of an expression. /// -/// This is a two-phase pipeline: -/// 1. `canonical_form()` — exact symbolic simplification -/// 2. Asymptotic projection — drop dominated terms and constant factors -/// -/// Returns an expression representing the asymptotic growth class. +/// Returns an expression representing the asymptotic growth class, or +/// [`AsymptoticAnalysisError::Unsupported`] when the growth domain widens the +/// input to [`Growth::Unknown`]. pub fn big_o_normal_form(expr: &Expr) -> Result { - let canonical = canonical_form(expr).map_err(|e| match e { - CanonicalizationError::Unsupported(s) => AsymptoticAnalysisError::Unsupported(s), - })?; - - project_big_o(&canonical) -} - -/// Project a canonicalized expression into its Big-O growth class. -fn project_big_o(expr: &Expr) -> Result { - // Decompose into additive terms - let mut terms = Vec::new(); - collect_additive_terms(expr, &mut terms); - - // Project each term: drop constant multiplicative factors - let mut projected: Vec = Vec::new(); - for term in &terms { - if let Some(projected_term) = project_term(term)? { - projected.push(projected_term); - } - // Pure constants are dropped (asymptotically irrelevant) - } - - // Remove dominated terms - let survivors = remove_dominated_terms(projected); - - if survivors.is_empty() { - // All terms were constants → O(1) - return Ok(Expr::Const(1.0)); - } - - if let Some(negative) = survivors.iter().find(|term| term.negative) { - return Err(AsymptoticAnalysisError::Unsupported(format!( - "-1 * {}", - negative.expr - ))); - } - - // Deduplicate - let mut seen = std::collections::BTreeSet::new(); - let mut deduped = Vec::new(); - for term in survivors { - let key = term.expr.to_string(); - if seen.insert(key) { - deduped.push(term); - } - } - - // Rebuild sum - let mut result = deduped[0].expr.clone(); - for term in &deduped[1..] { - result = result + term.expr.clone(); - } - - Ok(result) -} - -fn collect_additive_terms(expr: &Expr, out: &mut Vec) { - match expr { - Expr::Add(a, b) => { - collect_additive_terms(a, out); - collect_additive_terms(b, out); - } - other => out.push(other.clone()), - } -} - -/// Project a single multiplicative term: strip constant factors. -/// Returns None if the term is a pure constant. -fn project_term(term: &Expr) -> Result, AsymptoticAnalysisError> { - if term.constant_value().is_some() { - return Ok(None); // Pure constant → dropped - } - - // Collect multiplicative factors - let mut factors = Vec::new(); - collect_multiplicative_factors(term, &mut factors); - - let mut coeff = 1.0; - let mut symbolic = Vec::new(); - for factor in &factors { - if let Some(c) = factor.constant_value() { - coeff *= c; - continue; - } - if contains_negative_exponent(factor) { - return Err(AsymptoticAnalysisError::Unsupported(term.to_string())); - } - symbolic.push(factor.clone()); - } - - if symbolic.is_empty() { - return Ok(None); - } - - let mut result = symbolic[0].clone(); - for f in &symbolic[1..] { - result = result * f.clone(); - } - - Ok(Some(ProjectedTerm { - expr: result, - negative: coeff < 0.0, - })) -} - -fn collect_multiplicative_factors(expr: &Expr, out: &mut Vec) { - match expr { - Expr::Mul(a, b) => { - collect_multiplicative_factors(a, out); - collect_multiplicative_factors(b, out); - } - other => out.push(other.clone()), - } -} - -/// Remove terms dominated by other terms using monomial comparison. -/// -/// A term `t` is dominated if there exists another term `s` such that -/// `t` grows no faster than `s` asymptotically. -fn remove_dominated_terms(terms: Vec) -> Vec { - if terms.len() <= 1 { - return terms; - } - - let mut survivors = Vec::new(); - for (i, term) in terms.iter().enumerate() { - let is_dominated = terms - .iter() - .enumerate() - .any(|(j, other)| i != j && term_dominated_by(&term.expr, &other.expr)); - if !is_dominated { - survivors.push(term.clone()); - } - } - survivors -} - -/// Check if `small` is asymptotically dominated by `big`. -/// -/// Supports three comparison strategies: -/// 1. Polynomial monomial exponent comparison (exact) -/// 2. Exponential vs subexponential / base comparison (structural) -/// 3. Numerical evaluation at two scales (for subexponential cross-class) -fn term_dominated_by(small: &Expr, big: &Expr) -> bool { - // Case 1: Both pure polynomial monomials — use exponent comparison - let small_exps = extract_var_exponents(small); - let big_exps = extract_var_exponents(big); - if let (Some(ref se), Some(ref be)) = (small_exps, big_exps) { - return polynomial_dominated(se, be); - } - - // Cross-class comparison: small's variables must be a subset of big's - let small_vars = small.variables(); - let big_vars = big.variables(); - if small_vars.is_empty() || big_vars.is_empty() || !small_vars.is_subset(&big_vars) { - return false; - } - - // Case 2: Exponential comparison - let small_has_exp = has_exponential_growth(small); - let big_has_exp = has_exponential_growth(big); - match (small_has_exp, big_has_exp) { - (false, true) => return true, // exponential dominates subexponential - (true, false) => return false, // subexponential can't dominate exponential - (true, true) => { - // Compare effective exponential bases - if let (Some(sb), Some(bb)) = (effective_exp_base(small), effective_exp_base(big)) { - if bb > sb * (1.0 + 1e-10) { - return true; - } - } - return false; - } - (false, false) => {} // both subexponential, fall through - } - - // Case 3: Both subexponential, same variables — numerical comparison - // Handles: poly vs poly*log, log vs log(log), poly vs log, etc. - if small_vars == big_vars { - return numerical_dominance_check(small, big, &small_vars); - } - - false -} - -/// Check polynomial dominance: small ≤ big component-wise with at least one strict inequality. -fn polynomial_dominated( - se: &std::collections::BTreeMap<&'static str, f64>, - be: &std::collections::BTreeMap<&'static str, f64>, -) -> bool { - let mut all_leq = true; - let mut any_strictly_less = false; - - for (var, small_exp) in se { - let big_exp = be.get(var).copied().unwrap_or(0.0); - if *small_exp > big_exp + 1e-15 { - all_leq = false; - break; - } - if *small_exp < big_exp - 1e-15 { - any_strictly_less = true; - } - } - - if all_leq { - for (var, big_exp) in be { - if !se.contains_key(var) && *big_exp > 1e-15 { - any_strictly_less = true; - } - } - } - - all_leq && any_strictly_less -} - -/// Extract variable → exponent mapping from a monomial expression. -/// Returns None for non-polynomial terms (exp, log, etc.). -fn extract_var_exponents(expr: &Expr) -> Option> { - use std::collections::BTreeMap; - let mut exps = BTreeMap::new(); - extract_var_exponents_inner(expr, &mut exps)?; - Some(exps) -} - -fn extract_var_exponents_inner( - expr: &Expr, - exps: &mut std::collections::BTreeMap<&'static str, f64>, -) -> Option<()> { - match expr { - Expr::Var(name) => { - *exps.entry(name).or_insert(0.0) += 1.0; - Some(()) - } - Expr::Pow(base, exp) => { - if let (Expr::Var(name), Some(e)) = (base.as_ref(), exp.constant_value()) { - if e < 0.0 { - return None; - } - *exps.entry(name).or_insert(0.0) += e; - Some(()) - } else { - None // Non-simple power - } - } - Expr::Mul(a, b) => { - extract_var_exponents_inner(a, exps)?; - extract_var_exponents_inner(b, exps) - } - Expr::Const(_) => Some(()), // Constants don't affect exponents - _ => None, // exp, log, sqrt → not a polynomial monomial - } -} - -fn contains_negative_exponent(expr: &Expr) -> bool { - match expr { - Expr::Pow(_, exp) => exp.constant_value().is_some_and(|e| e < 0.0), - Expr::Mul(a, b) | Expr::Add(a, b) => { - contains_negative_exponent(a) || contains_negative_exponent(b) - } - Expr::Exp(arg) | Expr::Log(arg) | Expr::Sqrt(arg) | Expr::Factorial(arg) => { - contains_negative_exponent(arg) - } - Expr::Const(_) | Expr::Var(_) => false, - } -} - -/// Check if an expression has exponential growth. -/// -/// Returns true if the expression contains `exp(var_expr)` or `c^(var_expr)` where c > 1. -fn has_exponential_growth(expr: &Expr) -> bool { - match expr { - Expr::Exp(arg) => !arg.variables().is_empty(), - Expr::Pow(base, exp) => { - base.constant_value().is_some_and(|c| c > 1.0) && !exp.variables().is_empty() - } - Expr::Mul(a, b) => has_exponential_growth(a) || has_exponential_growth(b), - _ => false, - } -} - -/// Compute the effective exponential base for growth rate comparison. -/// -/// For `c^(f(n))`, approximates the effective base as `c^(f(1))`. -/// This works correctly for linear exponents (the common case in complexity expressions). -fn effective_exp_base(expr: &Expr) -> Option { - match expr { - Expr::Exp(arg) => { - let vars = arg.variables(); - if vars.is_empty() { - None - } else { - let size = unit_problem_size(&vars); - let rate = arg.eval(&size); - Some(std::f64::consts::E.powf(rate)) - } - } - Expr::Pow(base, exp) => { - if let Some(c) = base.constant_value() { - let vars = exp.variables(); - if c > 1.0 && !vars.is_empty() { - let size = unit_problem_size(&vars); - let exp_at_1 = exp.eval(&size); - Some(c.powf(exp_at_1)) - } else { - None - } - } else { - None - } - } - Expr::Mul(a, b) => match (effective_exp_base(a), effective_exp_base(b)) { - (Some(ba), Some(bb)) => Some(ba * bb), - (Some(b), None) | (None, Some(b)) => Some(b), - (None, None) => None, - }, - _ => None, - } -} - -/// Create a `ProblemSize` with all variables set to the given value. -fn make_problem_size( - vars: &std::collections::HashSet<&'static str>, - val: usize, -) -> crate::types::ProblemSize { - crate::types::ProblemSize::new(vars.iter().map(|&v| (v, val)).collect()) -} - -/// Create a `ProblemSize` with all variables set to 1. -fn unit_problem_size(vars: &std::collections::HashSet<&'static str>) -> crate::types::ProblemSize { - make_problem_size(vars, 1) -} - -/// Check dominance numerically by evaluating at two scales. -/// -/// Returns true if `big/small` ratio is > 1 and increasing between the two -/// evaluation points, indicating `big` grows asymptotically faster. -fn numerical_dominance_check( - small: &Expr, - big: &Expr, - vars: &std::collections::HashSet<&'static str>, -) -> bool { - let size1 = make_problem_size(vars, 100); - let size2 = make_problem_size(vars, 10_000); - - let s1 = small.eval(&size1); - let b1 = big.eval(&size1); - let s2 = small.eval(&size2); - let b2 = big.eval(&size2); - - // Both must be finite and positive at both points - if !s1.is_finite() || !b1.is_finite() || !s2.is_finite() || !b2.is_finite() { - return false; - } - if s1 <= 1e-300 || b1 <= 1e-300 || s2 <= 1e-300 || b2 <= 1e-300 { - return false; - } - - let ratio1 = b1 / s1; - let ratio2 = b2 / s2; - - // Dominance: ratio is > 1 at both points and strictly increasing - ratio1 > 1.0 + 1e-10 && ratio2 > ratio1 * (1.0 + 1e-6) + Growth::from_expr(expr) + .to_expr() + .ok_or_else(|| AsymptoticAnalysisError::Unsupported(expr.to_string())) } #[cfg(test)] diff --git a/src/canonical.rs b/src/canonical.rs deleted file mode 100644 index 4f8c73ca7..000000000 --- a/src/canonical.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Exact symbolic canonicalization for `Expr`. -//! -//! Normalizes expressions into a canonical sum-of-terms form with signed -//! coefficients and deterministic ordering, without losing algebraic precision. - -use std::collections::BTreeMap; - -use crate::expr::{CanonicalizationError, Expr}; - -/// Hard cap on the number of additive terms produced while expanding an -/// expression into canonical sum-of-monomials form. -/// -/// Expanding a nested `(sum)^2 * (sum)^2` structure is exponential in nesting -/// depth: composed-path overheads that traverse quadratic-overhead reductions -/// (e.g. `QuadraticAssignment`) blow up to multi-GB of monomials and OOM/hang. -/// When the intermediate term count would exceed this cap we abandon expansion -/// and report the expression as `Unsupported`; callers (e.g. `big_o_of`) fall -/// back to printing the compact, un-expanded expression. See issue #1069. -/// -/// Legitimate overhead expressions stay far below this bound (the worst -/// non-pathological case is a few hundred terms), so this never affects normal -/// output — it only stops pathological blowups. This is a stopgap guard; the -/// symbolic system is slated for a larger rework. -const MAX_CANONICAL_TERMS: usize = 50_000; - -/// An opaque non-polynomial factor (exp, log, fractional-power base). -/// -/// Stored by its canonical string representation for deterministic ordering. -#[derive(Clone, Debug, PartialEq)] -struct OpaqueFactor { - /// The canonical string form (used for equality and ordering). - key: String, - /// The original `Expr` for reconstruction. - expr: Expr, -} - -impl Eq for OpaqueFactor {} - -impl PartialOrd for OpaqueFactor { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for OpaqueFactor { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.key.cmp(&other.key) - } -} - -fn normalized_f64_bits(value: f64) -> u64 { - if value == 0.0 { - 0.0f64.to_bits() - } else { - value.to_bits() - } -} - -/// A single additive term: coefficient × product of canonical factors. -#[derive(Clone, Debug)] -struct CanonicalTerm { - /// Signed numeric coefficient. - coeff: f64, - /// Polynomial variable exponents (variable_name → exponent). - vars: BTreeMap<&'static str, f64>, - /// Non-polynomial opaque factors, sorted by key. - opaque: Vec, -} - -/// Try to merge a new opaque factor into an existing list using transcendental identities. -/// Returns `Some(updated_list)` if a merge happened, `None` if no identity applies. -fn try_merge_opaque(existing: &[OpaqueFactor], new: &OpaqueFactor) -> Option> { - for (i, existing_factor) in existing.iter().enumerate() { - // exp(a) * exp(b) -> exp(a + b) - if let (Expr::Exp(a), Expr::Exp(b)) = (&existing_factor.expr, &new.expr) { - let merged_arg = (**a).clone() + (**b).clone(); - let merged_expr = - Expr::Exp(Box::new(canonical_form(&merged_arg).unwrap_or(merged_arg))); - let mut result = existing.to_vec(); - result[i] = OpaqueFactor { - key: merged_expr.to_string(), - expr: merged_expr, - }; - return Some(result); - } - - // c^a * c^b -> c^(a+b) for matching positive constant base c - if let (Expr::Pow(base1, exp1), Expr::Pow(base2, exp2)) = (&existing_factor.expr, &new.expr) - { - if let (Some(c1), Some(c2)) = (base1.constant_value(), base2.constant_value()) { - if c1 > 0.0 && c2 > 0.0 && (c1 - c2).abs() < 1e-15 { - let merged_exp = (**exp1).clone() + (**exp2).clone(); - let canon_exp = canonical_form(&merged_exp).unwrap_or(merged_exp); - let merged_expr = Expr::Pow(base1.clone(), Box::new(canon_exp)); - let mut result = existing.to_vec(); - result[i] = OpaqueFactor { - key: merged_expr.to_string(), - expr: merged_expr, - }; - return Some(result); - } - } - } - } - None -} - -/// A canonical sum of terms: the exact normal form of an expression. -#[derive(Clone, Debug)] -pub(crate) struct CanonicalSum { - terms: Vec, -} - -impl CanonicalTerm { - fn constant(c: f64) -> Self { - Self { - coeff: c, - vars: BTreeMap::new(), - opaque: Vec::new(), - } - } - - fn variable(name: &'static str) -> Self { - let mut vars = BTreeMap::new(); - vars.insert(name, 1.0); - Self { - coeff: 1.0, - vars, - opaque: Vec::new(), - } - } - - fn opaque_factor(expr: Expr) -> Self { - let key = expr.to_string(); - Self { - coeff: 1.0, - vars: BTreeMap::new(), - opaque: vec![OpaqueFactor { key, expr }], - } - } - - /// Multiply two terms, applying transcendental identities: - /// - `exp(a) * exp(b) -> exp(a + b)` - /// - `c^a * c^b -> c^(a + b)` for matching constant base `c` - fn mul(&self, other: &CanonicalTerm) -> CanonicalTerm { - let coeff = self.coeff * other.coeff; - let mut vars = self.vars.clone(); - for (&v, &e) in &other.vars { - *vars.entry(v).or_insert(0.0) += e; - } - // Remove zero-exponent variables - vars.retain(|_, e| e.abs() > 1e-15); - - // Merge opaque factors with transcendental identities - let mut opaque = self.opaque.clone(); - for other_factor in &other.opaque { - if let Some(merged) = try_merge_opaque(&opaque, other_factor) { - opaque = merged; - } else { - opaque.push(other_factor.clone()); - } - } - opaque.sort(); - CanonicalTerm { - coeff, - vars, - opaque, - } - } - - /// Deterministic sort key for ordering terms in a sum. - fn sort_key(&self) -> (Vec<(&'static str, u64)>, Vec) { - let vars: Vec<_> = self - .vars - .iter() - .map(|(&k, &v)| (k, normalized_f64_bits(v))) - .collect(); - let opaque: Vec<_> = self.opaque.iter().map(|o| o.key.clone()).collect(); - (vars, opaque) - } -} - -impl CanonicalSum { - fn from_term(term: CanonicalTerm) -> Self { - Self { terms: vec![term] } - } - - fn add(mut self, other: CanonicalSum) -> Self { - self.terms.extend(other.terms); - self - } - - fn mul(&self, other: &CanonicalSum) -> CanonicalSum { - let mut terms = Vec::new(); - for a in &self.terms { - for b in &other.terms { - terms.push(a.mul(b)); - } - } - CanonicalSum { terms } - } - - /// Multiply with a guard against pathological expansion (see - /// [`MAX_CANONICAL_TERMS`]). The Cartesian product size is checked *before* - /// it is materialized, so this never allocates the blown-up vector. - fn try_mul(&self, other: &CanonicalSum) -> Result { - let product = self.terms.len().saturating_mul(other.terms.len()); - if product > MAX_CANONICAL_TERMS { - return Err(CanonicalizationError::Unsupported(format!( - "expression too large to canonicalize ({product} terms exceeds cap of {MAX_CANONICAL_TERMS})" - ))); - } - Ok(self.mul(other)) - } - - /// Merge terms with the same signature and drop zero-coefficient terms. - /// Sort the result deterministically. - fn simplify(self) -> Self { - type SortKey = (Vec<(&'static str, u64)>, Vec); - let mut groups: BTreeMap = BTreeMap::new(); - - for term in self.terms { - let key = term.sort_key(); - groups - .entry(key) - .and_modify(|existing| existing.coeff += term.coeff) - .or_insert(term); - } - - let mut terms: Vec<_> = groups - .into_values() - .filter(|t| t.coeff.abs() > 1e-15) - .collect(); - - terms.sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); - - CanonicalSum { terms } - } -} - -/// Normalize an expression into its exact canonical sum-of-terms form. -/// -/// This performs exact symbolic simplification: -/// - Flattens nested Add/Mul -/// - Merges duplicate additive terms by summing coefficients -/// - Merges repeated multiplicative factors into powers -/// - Preserves signed coefficients (supports subtraction) -/// - Preserves transcendental identities: exp(a)*exp(b)=exp(a+b), etc. -/// - Produces deterministic ordering -/// -/// Does NOT drop terms or constant factors — use `big_o_normal_form()` for that. -pub fn canonical_form(expr: &Expr) -> Result { - let sum = expr_to_canonical(expr)?; - let simplified = sum.simplify(); - Ok(canonical_sum_to_expr(&simplified)) -} - -fn expr_to_canonical(expr: &Expr) -> Result { - match expr { - Expr::Const(c) => Ok(CanonicalSum::from_term(CanonicalTerm::constant(*c))), - Expr::Var(name) => Ok(CanonicalSum::from_term(CanonicalTerm::variable(name))), - Expr::Add(a, b) => { - let ca = expr_to_canonical(a)?; - let cb = expr_to_canonical(b)?; - Ok(ca.add(cb)) - } - Expr::Mul(a, b) => { - let ca = expr_to_canonical(a)?; - let cb = expr_to_canonical(b)?; - ca.try_mul(&cb) - } - Expr::Pow(base, exp) => canonicalize_pow(base, exp), - Expr::Exp(arg) => { - // Treat exp(canonicalized_arg) as an opaque factor - let inner = canonical_form(arg)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Exp(Box::new(inner)), - ))) - } - Expr::Log(arg) => { - let inner = canonical_form(arg)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Log(Box::new(inner)), - ))) - } - Expr::Sqrt(arg) => { - // sqrt(x) = x^0.5 — canonicalize as power - canonicalize_pow(arg, &Expr::Const(0.5)) - } - Expr::Factorial(arg) => { - let inner = canonical_form(arg)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Factorial(Box::new(inner)), - ))) - } - } -} - -fn canonicalize_pow(base: &Expr, exp: &Expr) -> Result { - match (base, exp) { - // Constant base, constant exp → numeric constant - (_, _) if base.constant_value().is_some() && exp.constant_value().is_some() => { - let b = base.constant_value().unwrap(); - let e = exp.constant_value().unwrap(); - Ok(CanonicalSum::from_term(CanonicalTerm::constant(b.powf(e)))) - } - // Variable ^ constant exponent → vars map (supports fractional/negative exponents) - (Expr::Var(name), _) if exp.constant_value().is_some() => { - let e = exp.constant_value().unwrap(); - if e.abs() < 1e-15 { - return Ok(CanonicalSum::from_term(CanonicalTerm::constant(1.0))); - } - let mut vars = BTreeMap::new(); - vars.insert(*name, e); - Ok(CanonicalSum::from_term(CanonicalTerm { - coeff: 1.0, - vars, - opaque: Vec::new(), - })) - } - // Polynomial base ^ constant integer exponent → expand - (_, _) if exp.constant_value().is_some() => { - let e = exp.constant_value().unwrap(); - if e >= 0.0 && (e - e.round()).abs() < 1e-10 { - let n = e.round() as usize; - let base_sum = expr_to_canonical(base)?; - if n == 0 { - return Ok(CanonicalSum::from_term(CanonicalTerm::constant(1.0))); - } - let mut result = base_sum.clone(); - for _ in 1..n { - result = result.try_mul(&base_sum)?; - } - Ok(result) - } else { - // Fractional exponent with non-variable base → opaque - let canon_base = canonical_form(base)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Pow(Box::new(canon_base), Box::new(Expr::Const(e))), - ))) - } - } - // Constant base ^ variable exponent → opaque (exponential growth) - (_, _) if base.constant_value().is_some() => { - let c = base.constant_value().unwrap(); - if (c - 1.0).abs() < 1e-15 { - return Ok(CanonicalSum::from_term(CanonicalTerm::constant(1.0))); - } - if c <= 0.0 { - return Err(CanonicalizationError::Unsupported(format!( - "{}^{}", - base, exp - ))); - } - let canon_exp = canonical_form(exp)?; - Ok(CanonicalSum::from_term(CanonicalTerm::opaque_factor( - Expr::Pow(Box::new(base.clone()), Box::new(canon_exp)), - ))) - } - // Variable base ^ variable exponent → unsupported - _ => Err(CanonicalizationError::Unsupported(format!( - "{}^{}", - base, exp - ))), - } -} - -fn canonical_sum_to_expr(sum: &CanonicalSum) -> Expr { - if sum.terms.is_empty() { - return Expr::Const(0.0); - } - - let term_exprs: Vec = sum.terms.iter().map(canonical_term_to_expr).collect(); - - let mut result = term_exprs[0].clone(); - for term in &term_exprs[1..] { - result = result + term.clone(); - } - result -} - -fn canonical_term_to_expr(term: &CanonicalTerm) -> Expr { - let mut factors: Vec = Vec::new(); - - // Add coefficient if not 1.0 (or -1.0, handled specially) - let (coeff_factor, sign) = if term.coeff < 0.0 { - (term.coeff.abs(), true) - } else { - (term.coeff, false) - }; - - let has_other_factors = !term.vars.is_empty() || !term.opaque.is_empty(); - - if (coeff_factor - 1.0).abs() > 1e-15 || !has_other_factors { - factors.push(Expr::Const(coeff_factor)); - } - - // Add variable powers - for (&var, &exp) in &term.vars { - if (exp - 1.0).abs() < 1e-15 { - factors.push(Expr::Var(var)); - } else { - factors.push(Expr::pow(Expr::Var(var), Expr::Const(exp))); - } - } - - // Add opaque factors - for opaque in &term.opaque { - factors.push(opaque.expr.clone()); - } - - let mut result = if factors.is_empty() { - Expr::Const(1.0) - } else { - let mut r = factors[0].clone(); - for f in &factors[1..] { - r = r * f.clone(); - } - r - }; - - if sign { - result = -result; - } - - result -} - -#[cfg(test)] -#[path = "unit_tests/canonical.rs"] -mod tests; diff --git a/src/expr.rs b/src/expr.rs index a880b6a09..fccbab06c 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -312,32 +312,6 @@ impl fmt::Display for AsymptoticAnalysisError { impl std::error::Error for AsymptoticAnalysisError {} -/// Error returned when exact canonicalization fails. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CanonicalizationError { - /// Expression cannot be canonicalized (e.g., variable in both base and exponent). - Unsupported(String), -} - -impl fmt::Display for CanonicalizationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Unsupported(expr) => { - write!(f, "unsupported expression for canonicalization: {expr}") - } - } - } -} - -impl std::error::Error for CanonicalizationError {} - -/// Return a normalized `Expr` representing the asymptotic behavior of `expr`. -/// -/// This is now a compatibility wrapper for `big_o_normal_form()`. -pub fn asymptotic_normal_form(expr: &Expr) -> Result { - crate::big_o::big_o_normal_form(expr) -} - /// Compute factorial for non-negative values. /// /// For non-negative integers, returns the exact integer factorial. diff --git a/src/growth.rs b/src/growth.rs index f3306b578..14621d816 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -263,6 +263,79 @@ impl Growth { } } } + + /// Render this growth class back to a display [`Expr`] (a sum of monomials), + /// or `None` for [`Growth::Unknown`]. Terms are already in the deterministic + /// sort order, so the rendered expression is platform-stable. + /// + /// Exponential rates are de-normalized from base 2 back to a readable base + /// (`{n: 1} → 2^n`, `{n: log2 3} → 3^n`, `{n: log2 e} → exp(n)`). + pub fn to_expr(&self) -> Option { + match self { + Growth::Unknown => None, + Growth::Terms(terms) => { + if terms.is_empty() { + return Some(Expr::Const(1.0)); + } + let mut it = terms.iter().map(term_to_expr); + let mut acc = it.next().unwrap(); + for e in it { + acc = acc + e; + } + Some(acc) + } + } + } +} + +/// Render one monomial as a product of its factors (or `Const(1)` when empty). +fn term_to_expr(t: &GrowthTerm) -> Expr { + let mut factors: Vec = Vec::new(); + for (v, rate) in &t.exp { + factors.push(exp_factor(v, *rate)); + } + for (v, deg) in &t.poly { + factors.push(poly_factor(v, *deg)); + } + for (v, power) in &t.logs { + factors.push(log_factor(v, *power)); + } + let mut it = factors.into_iter(); + match it.next() { + None => Expr::Const(1.0), + Some(first) => it.fold(first, |acc, f| acc * f), + } +} + +/// Render `2^(rate·v)` with a readable base: `exp(v)` when the base is `e`, an +/// integer/decimal base otherwise (snapped to remove float round-trip noise). +fn exp_factor(v: &'static str, rate: f64) -> Expr { + let base = 2f64.powf(rate); + if (base - std::f64::consts::E).abs() < 1e-9 { + return Expr::Exp(Box::new(Expr::Var(v))); + } + // Snap away round-trip noise so `2^log2(3)` renders as `3^v`, not `3.0000…^v`. + let snapped = (base * 1e9).round() / 1e9; + Expr::pow(Expr::Const(snapped), Expr::Var(v)) +} + +/// Render `v^degree` (`Display` turns degree `0.5` into `sqrt(v)`). +fn poly_factor(v: &'static str, degree: f64) -> Expr { + if degree == 1.0 { + Expr::Var(v) + } else { + Expr::pow(Expr::Var(v), Expr::Const(degree)) + } +} + +/// Render `(log v)^power`. +fn log_factor(v: &'static str, power: u32) -> Expr { + let log = Expr::Log(Box::new(Expr::Var(v))); + if power == 1 { + log + } else { + Expr::pow(log, Expr::Const(power as f64)) + } } /// Prune a bag of terms to its maximal antichain: drop any term dominated by diff --git a/src/lib.rs b/src/lib.rs index 74e94267d..f77845aa9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,16 +20,14 @@ extern crate self as problemreductions; pub(crate) mod big_o; -pub(crate) mod canonical; pub mod config; pub mod error; #[cfg(feature = "example-db")] pub mod example_db; pub mod export; pub(crate) mod expr; -// The growth domain is consumed by later milestone issues (big_o.rs / search -// rewiring); nothing references it on `main` yet, so its public API is dead code -// for now. +// The growth domain backs `big_o_normal_form`; the search/analysis rewiring that +// consumes the rest of its API lands in later milestone issues. #[allow(dead_code)] pub(crate) mod growth; pub mod io; @@ -115,9 +113,8 @@ pub mod prelude { // Re-export commonly used items at crate root pub use big_o::big_o_normal_form; -pub use canonical::canonical_form; pub use error::{ProblemError, Result}; -pub use expr::{asymptotic_normal_form, AsymptoticAnalysisError, CanonicalizationError, Expr}; +pub use expr::{AsymptoticAnalysisError, Expr}; pub use registry::{ComplexityClass, ProblemInfo}; pub use solvers::{BruteForce, Solver}; pub use traits::Problem; diff --git a/src/rules/analysis.rs b/src/rules/analysis.rs index 6d616877d..2f31d1f9b 100644 --- a/src/rules/analysis.rs +++ b/src/rules/analysis.rs @@ -7,7 +7,6 @@ //! the symbolic comparison is trustworthy, and `Unknown` when metadata is too //! weak to compare safely. -use crate::canonical::canonical_form; use crate::expr::Expr; use crate::rules::graph::{ReductionGraph, ReductionPath}; use crate::rules::registry::ReductionOverhead; @@ -224,7 +223,9 @@ fn normalize_polynomial(expr: &Expr) -> Result { } fn prepare_expr_for_comparison(expr: &Expr) -> Expr { - canonical_form(expr).unwrap_or_else(|_| expr.clone()) + // The growth-dominance rewire of this comparison is a separate milestone + // issue; until then, compare the expressions as-is (no canonicalization). + expr.clone() } // ────────── Monomial-dominance comparison ────────── diff --git a/src/unit_tests/big_o.rs b/src/unit_tests/big_o.rs index 6dab26625..9ed1cefc8 100644 --- a/src/unit_tests/big_o.rs +++ b/src/unit_tests/big_o.rs @@ -109,9 +109,12 @@ fn test_big_o_rejects_division() { } #[test] -fn test_big_o_rejects_negative_dominant_term() { +fn test_big_o_drops_negative_constant_factor() { + // The growth domain drops constant multipliers, sign included, so `-1 * n` + // widens to `n` (an upper bound on its magnitude) instead of being rejected. let e = Expr::Const(-1.0) * Expr::Var("n"); - assert!(big_o_normal_form(&e).is_err()); + let result = big_o_normal_form(&e).unwrap(); + assert_eq!(result.to_string(), "n"); } #[test] @@ -219,11 +222,18 @@ fn test_big_o_multivar_exp_dominates_poly() { } #[test] -fn test_big_o_pathological_nesting_errors_instead_of_hanging() { - // Regression for issue #1069: a deeply-nested power that expands - // exponentially must return an error promptly (so callers like `big_o_of` - // fall back to the un-expanded expression) rather than OOM/hang. +fn test_big_o_pathological_nesting_returns_bound_instantly() { + // Regression for issue #1069: a deeply-nested power that the old expansion + // pipeline could not normalize (it OOM'd, then refused via the term cap). + // The growth domain answers it bottom-up: `((a+b+c+d)^4)^4` raises each + // variable term to degree 16, so it returns a real bound, instantly. let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d"); let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0)); - assert!(big_o_normal_form(&e).is_err()); + let start = std::time::Instant::now(); + let result = big_o_normal_form(&e).unwrap(); + assert!(start.elapsed().as_millis() < 50, "should be instant"); + let s = result.to_string(); + for v in ["a^16", "b^16", "c^16", "d^16"] { + assert!(s.contains(v), "expected {v} in {s}"); + } } diff --git a/src/unit_tests/canonical.rs b/src/unit_tests/canonical.rs deleted file mode 100644 index dcf3f8fd0..000000000 --- a/src/unit_tests/canonical.rs +++ /dev/null @@ -1,165 +0,0 @@ -use super::*; -use crate::expr::Expr; - -#[test] -fn test_canonical_identity() { - let e = Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "n"); -} - -#[test] -fn test_canonical_add_like_terms() { - // n + n → 2 * n - let e = Expr::Var("n") + Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "2 * n"); -} - -#[test] -fn test_canonical_subtract_to_zero() { - // n - n → 0 - let e = Expr::Var("n") - Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "0"); -} - -#[test] -fn test_canonical_mixed_addition() { - // n + n - m + 2*m → 2*n + m - let e = Expr::Var("n") + Expr::Var("n") - Expr::Var("m") + Expr::Const(2.0) * Expr::Var("m"); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "m + 2 * n"); -} - -#[test] -fn test_canonical_exp_product_identity() { - // exp(n) * exp(m) -> exp(m + n) (transcendental identity, alphabetical order) - let e = Expr::Exp(Box::new(Expr::Var("n"))) * Expr::Exp(Box::new(Expr::Var("m"))); - let c = canonical_form(&e).unwrap(); - // Verify numerical equivalence - let size = crate::types::ProblemSize::new(vec![("n", 2), ("m", 3)]); - assert!((c.eval(&size) - (2.0_f64.exp() * 3.0_f64.exp())).abs() < 1e-6); -} - -#[test] -fn test_canonical_constant_base_exp_identity() { - // 2^n * 2^m -> 2^(m + n) - let e = - Expr::pow(Expr::Const(2.0), Expr::Var("n")) * Expr::pow(Expr::Const(2.0), Expr::Var("m")); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 3), ("m", 4)]); - assert!((c.eval(&size) - 2.0_f64.powf(7.0)).abs() < 1e-6); -} - -#[test] -fn test_canonical_polynomial_expansion() { - // (n + m)^2 = n^2 + 2*n*m + m^2 - let e = Expr::pow(Expr::Var("n") + Expr::Var("m"), Expr::Const(2.0)); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 3), ("m", 4)]); - assert_eq!(c.eval(&size), 49.0); // (3+4)^2 = 49 -} - -#[test] -fn test_canonical_signed_polynomial() { - // n^3 - n^2 + 2*n + 4*n*m — should remain exact - let e = Expr::pow(Expr::Var("n"), Expr::Const(3.0)) - - Expr::pow(Expr::Var("n"), Expr::Const(2.0)) - + Expr::Const(2.0) * Expr::Var("n") - + Expr::Const(4.0) * Expr::Var("n") * Expr::Var("m"); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 3), ("m", 2)]); - // 27 - 9 + 6 + 24 = 48 - assert_eq!(c.eval(&size), 48.0); -} - -#[test] -fn test_canonical_division_becomes_negative_exponent() { - // n / m should canonicalize; the division is represented as m^(-1) - // which becomes an opaque factor (negative exponent) - let e = Expr::Var("n") / Expr::Var("m"); - let c = canonical_form(&e).unwrap(); - let size = crate::types::ProblemSize::new(vec![("n", 6), ("m", 3)]); - assert!((c.eval(&size) - 2.0).abs() < 1e-10); -} - -#[test] -fn test_canonical_distinct_fractional_exponents_do_not_merge() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(1.0004)) - Expr::Var("n"); - let c = canonical_form(&e).unwrap(); - assert_ne!(c.to_string(), "0"); - let size = crate::types::ProblemSize::new(vec![("n", 2)]); - assert_ne!(c.eval(&size), 0.0); -} - -#[test] -fn test_canonical_constant_base_one_folds_to_constant() { - let e = Expr::pow(Expr::Const(1.0), Expr::Var("n")); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "1"); -} - -#[test] -fn test_canonical_negative_constant_base_with_symbolic_exponent_is_rejected() { - let e = Expr::pow(Expr::Const(-2.0), Expr::Var("n")); - let err = canonical_form(&e).unwrap_err(); - assert!(matches!(err, CanonicalizationError::Unsupported(_))); -} - -#[test] -fn test_canonical_zero_constant_base_with_symbolic_exponent_is_rejected() { - let e = Expr::pow(Expr::Const(0.0), Expr::Var("n")); - let err = canonical_form(&e).unwrap_err(); - assert!(matches!(err, CanonicalizationError::Unsupported(_))); -} - -#[test] -fn test_canonical_deterministic_order() { - // m + n and n + m should produce the same canonical form - let a = canonical_form(&(Expr::Var("m") + Expr::Var("n"))).unwrap(); - let b = canonical_form(&(Expr::Var("n") + Expr::Var("m"))).unwrap(); - assert_eq!(a.to_string(), b.to_string()); -} - -#[test] -fn test_canonical_constant_folding() { - // 2 + 3 → 5 - let e = Expr::Const(2.0) + Expr::Const(3.0); - let c = canonical_form(&e).unwrap(); - assert_eq!(c.to_string(), "5"); -} - -#[test] -fn test_canonical_sqrt_as_power() { - // sqrt(n) should canonicalize the same as n^0.5 - let a = canonical_form(&Expr::Sqrt(Box::new(Expr::Var("n")))).unwrap(); - let b = canonical_form(&Expr::pow(Expr::Var("n"), Expr::Const(0.5))).unwrap(); - assert_eq!(a.to_string(), b.to_string()); -} - -#[test] -fn test_canonical_nested_power_blowup_is_capped() { - // Regression for issue #1069: a "square of a square of a sum" structure — - // the shape composed-path overheads take when they traverse - // quadratic-overhead reductions — expands exponentially. Before the cap - // this OOM'd / hung indefinitely; now it must fail fast with Unsupported - // rather than try to materialize the blown-up monomial expansion. - let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d"); - // ((a+b+c+d)^4)^4 expands to >50_000 intermediate terms. - let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0)); - let err = canonical_form(&e).unwrap_err(); - assert!(matches!(err, CanonicalizationError::Unsupported(_))); -} - -#[test] -fn test_canonical_moderate_power_still_expands() { - // The cap must not perturb legitimate, modestly-sized expressions: - // (a+b)^3 stays well under the cap and expands normally. - let e = Expr::pow(Expr::Var("a") + Expr::Var("b"), Expr::Const(3.0)); - let c = canonical_form(&e).unwrap(); - // a^3 + 3 a^2 b + 3 a b^2 + b^3 — compare against the same expansion - // written out flat (both go through canonical_form for identical ordering). - let expected = canonical_form(&Expr::parse("a^3 + 3*a^2*b + 3*a*b^2 + b^3")).unwrap(); - assert_eq!(c.to_string(), expected.to_string()); -} diff --git a/src/unit_tests/expr.rs b/src/unit_tests/expr.rs index 037f39c8c..fd1e217aa 100644 --- a/src/unit_tests/expr.rs +++ b/src/unit_tests/expr.rs @@ -168,95 +168,6 @@ fn test_expr_display_pow_with_complex_exponent() { assert_eq!(format!("{expr}"), "2^(m + n)"); } -#[test] -fn test_asymptotic_normal_form_drops_constant_factors() { - let expr = Expr::parse("3 * num_variables^2"); - let normalized = asymptotic_normal_form(&expr).unwrap(); - assert_eq!(normalized.to_string(), "num_variables^2"); -} - -#[test] -fn test_asymptotic_normal_form_drops_additive_constants() { - let expr = Expr::parse("num_variables + 1"); - let normalized = asymptotic_normal_form(&expr).unwrap(); - assert_eq!(normalized.to_string(), "num_variables"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_commutative_sum() { - let a = asymptotic_normal_form(&Expr::parse("n + m")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("m + n")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "m + n"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_commutative_product() { - let a = asymptotic_normal_form(&Expr::parse("n * m")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("m * n")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "m * n"); -} - -#[test] -fn test_asymptotic_normal_form_combines_repeated_factors() { - let normalized = asymptotic_normal_form(&Expr::parse("n * n^(1/2)")).unwrap(); - assert_eq!(normalized.to_string(), "n^1.5"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_exponential_product() { - let a = asymptotic_normal_form(&Expr::parse("exp(n) * exp(m)")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("exp(n + m)")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "exp(m + n)"); -} - -#[test] -fn test_asymptotic_normal_form_canonicalizes_constant_base_exponential_product() { - let a = asymptotic_normal_form(&Expr::parse("2^n * 2^m")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("2^(n + m)")).unwrap(); - assert_eq!(a, b); - assert_eq!(a.to_string(), "2^(m + n)"); -} - -#[test] -fn test_asymptotic_normal_form_sqrt_matches_fractional_power() { - let a = asymptotic_normal_form(&Expr::parse("sqrt(n * m)")).unwrap(); - let b = asymptotic_normal_form(&Expr::parse("(n * m)^(1/2)")).unwrap(); - assert_eq!(a, b); -} - -#[test] -fn test_asymptotic_normal_form_log_of_power() { - // log(n^2) = 2*log(n) — the new engine keeps log(n^2) which is O(log(n)) - let normalized = asymptotic_normal_form(&Expr::parse("log(n^2)")).unwrap(); - // Both log(n^2) and log(n) are asymptotically equivalent - let s = normalized.to_string(); - assert!(s.contains("log"), "expected log in result, got: {s}"); - assert!(s.contains("n"), "expected n in result, got: {s}"); -} - -#[test] -fn test_asymptotic_normal_form_substitution_is_closed() { - let notation = asymptotic_normal_form(&Expr::parse("n * m")).unwrap(); - let k = Expr::parse("k"); - let k_squared = Expr::parse("k^2"); - let mapping = HashMap::from([("n", &k), ("m", &k_squared)]); - let substituted = asymptotic_normal_form(¬ation.substitute(&mapping)).unwrap(); - assert_eq!(substituted.to_string(), "k^3"); -} - -#[test] -fn test_asymptotic_normal_form_handles_subtraction() { - // n - m: the -m term survives as a negative dominant term → unsupported - assert!(asymptotic_normal_form(&Expr::parse("n - m")).is_err()); - - // n^2 - n: -n is dominated by n^2 and eliminated → works - let result = asymptotic_normal_form(&Expr::parse("n^2 - n")).unwrap(); - assert_eq!(result.to_string(), "n^2"); -} - #[test] fn test_expr_display_fractional_constant() { assert_eq!(format!("{}", Expr::Const(2.75)), "2.75"); diff --git a/src/unit_tests/rules/analysis.rs b/src/unit_tests/rules/analysis.rs index a97f6060e..b67d62405 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -87,10 +87,15 @@ fn test_compare_overhead_unknown_log() { } #[test] -fn test_compare_overhead_exp_identity_after_asymptotic_normalization() { +fn test_compare_overhead_exp_identity_not_yet_normalized() { + // `exp(n + m)` and `exp(n) * exp(m)` are asymptotically equal, but the + // overhead comparator no longer canonicalizes (that engine was deleted), and + // its polynomial fallback does not handle exp, so it reports Unknown. + // Recognizing this identity again is the job of the analysis-to-growth + // rewire (a later milestone issue). let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("exp(n + m)"))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("exp(n) * exp(m)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); } #[test] @@ -104,10 +109,13 @@ fn test_compare_overhead_log_identity_after_asymptotic_normalization() { } #[test] -fn test_compare_overhead_sqrt_identity_after_asymptotic_normalization() { +fn test_compare_overhead_sqrt_identity_not_yet_normalized() { + // `sqrt(n * m)` and `(n * m)^(1/2)` are equal, but without canonicalization + // the comparator's polynomial fallback does not handle sqrt, so it reports + // Unknown until the analysis-to-growth rewire (a later milestone issue). let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("sqrt(n * m)"))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("(n * m)^(1/2)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); } #[test] From b4f07536bab6935ee022047f15ac5e24ffd66d21 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 16:29:04 +0800 Subject: [PATCH 03/31] Replace scalar Dijkstra with measured Pareto label-setting search (#1076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements M3 (F3b) of the Symbolic Growth Domain & Pareto Search milestone. The scalar Dijkstra in `ReductionGraph` had a path-dependent-cost hole: when two paths reached the same node, only the cheaper-so-far label's size was kept, so a cheaper-but-larger intermediate state could poison downstream choices (issue #788). This replaces it with a generic multi-label Pareto search plus a measured concrete-instance label domain. - New `PathLabel` trait (`extend` + `dominates` + `cost`) and a generic `pareto_search` kernel over per-node antichain bags with predecessor pointers, branch-and-bound, deterministic safety caps (hop cap 16, bag cap 32 with a deterministic tie-break), and a deterministically ordered Pareto front. - `CostLabel`: scalar formula label reproducing Dijkstra behavior for the existing `PathCostFn` cost functions; `find_cheapest_path*` keep their signatures and now run the kernel with it. - `MeasuredLabel` (`src/rules/pareto.rs`): the concrete-instance label. Its `extend` runs a four-part pruning stack in order — (1) symbolic pre-flight guard (evaluated in f64 so a `2^num_vertices` prediction is refused without executing, making OOM structurally impossible), (2) execute + measure the real target size, (3) branch-and-bound, (4) componentwise measured-size dominance (disabled by the `exhaustive` flag; guards 1-3 stay sound). A caught panic from a reduction whose preconditions the instance violates prunes that edge. - `MeasuredPath` carries the constructed reduction chain (via `Rc`) so downstream solve/witness extraction reuses it without re-executing. - `ILPSolver::best_path_to_ilp` now uses `find_measured_best_path_to_name`, ranking ILP variants by real measured size instead of step count / formula. Verification (all green): #788 known-answer test (HC on the prism graph selects the measured optimum), OOM pre-flight guard test (64-vertex HighlyConnectedDeletion refused in <1ms, exponential construction never started), and a hand-built diamond negative control where scalar cost selection commits to P1 while the Pareto search returns the strictly-better-final-size P2. Closes #788. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- src/rules/graph.rs | 437 +++++++++++++++++++++++++++++---- src/rules/mod.rs | 8 +- src/rules/pareto.rs | 317 ++++++++++++++++++++++++ src/rules/registry.rs | 13 + src/solvers/ilp/solver.rs | 109 ++++---- src/unit_tests/rules/pareto.rs | 287 ++++++++++++++++++++++ 6 files changed, 1071 insertions(+), 100 deletions(-) create mode 100644 src/rules/pareto.rs create mode 100644 src/unit_tests/rules/pareto.rs diff --git a/src/rules/graph.rs b/src/rules/graph.rs index ef8a27ff2..56ac164fe 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -13,6 +13,7 @@ //! - JSON export for documentation and visualization use crate::rules::cost::PathCostFn; +use crate::rules::pareto::{CostLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, HOP_CAP}; use crate::rules::registry::{ AggregateReduceFn, EdgeCapabilities, ReduceFn, ReductionEntry, ReductionOverhead, }; @@ -26,6 +27,7 @@ use serde::Serialize; use std::any::Any; use std::cmp::Reverse; use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet}; +use std::rc::Rc; /// A source/target pair from the reduction graph, returned by /// [`ReductionGraph::outgoing_reductions`] and [`ReductionGraph::incoming_reductions`]. @@ -464,6 +466,11 @@ impl ReductionGraph { /// Find the cheapest path between two specific problem variants while /// requiring a specific edge capability. + /// + /// Runs the generic [Pareto label-setting search](Self::pareto_search) with a + /// scalar [`CostLabel`], reproducing Dijkstra's single-objective behavior for the + /// given [`PathCostFn`]. Returns the front's best element under the deterministic + /// tie-break (smallest cost, then fewest hops, then lexicographic node names). #[allow(clippy::too_many_arguments)] pub fn find_cheapest_path_mode( &self, @@ -477,71 +484,233 @@ impl ReductionGraph { ) -> Option { let src = self.lookup_node(source, source_variant)?; let dst = self.lookup_node(target, target_variant)?; - let node_path = self.dijkstra(src, dst, mode, input_size, cost_fn)?; - Some(self.node_path_to_reduction_path(&node_path)) + let initial = CostLabel::new(input_size.clone(), cost_fn); + let mut front = self.pareto_search(src, dst, mode, initial, false); + self.pick_best_front(&mut front).map(|(path, _)| path) } - /// Core Dijkstra search on node indices. - fn dijkstra( + /// Generic Pareto label-setting search from `src` to `dst`. + /// + /// Maintains a per-node **bag** (an antichain of non-dominated labels); a label is + /// discarded only when another label at the same node [dominates](PathLabel::dominates) + /// it. Each surviving label carries a predecessor pointer for path reconstruction. + /// The frontier is explored in ascending [`cost`](PathLabel::cost) order, which gives + /// an early branch-and-bound bound. Deterministic safety caps apply: [`HOP_CAP`] + /// bounds path length, and [`BAG_CAP`] bounds each bag with a deterministic tie-break + /// (never iteration-order truncation). Edges are visited in a deterministic + /// (target-name, target-variant) order. + /// + /// When `exhaustive` is `true`, the componentwise dominance guard is disabled (bags + /// retain all labels up to the cap); the sound guards inside [`PathLabel::extend`] and + /// the branch-and-bound bound still apply. + /// + /// Returns the Pareto front at `dst`: `(path, label)` pairs, deterministically + /// ordered by (cost, hops, node-name path). + pub(crate) fn pareto_search( &self, src: NodeIndex, dst: NodeIndex, mode: ReductionMode, - input_size: &ProblemSize, - cost_fn: &C, - ) -> Option> { - let mut costs: HashMap = HashMap::new(); - let mut sizes: HashMap = HashMap::new(); - let mut prev: HashMap = HashMap::new(); - let mut heap = BinaryHeap::new(); + initial: L, + exhaustive: bool, + ) -> Vec<(ReductionPath, L)> { + struct Entry { + node: NodeIndex, + label: L, + pred: Option, + hops: usize, + } - costs.insert(src, 0.0); - sizes.insert(src, input_size.clone()); - heap.push(Reverse((OrderedFloat(0.0), src))); + let mut arena: Vec> = Vec::new(); + let mut bags: HashMap> = HashMap::new(); + let mut frontier: BinaryHeap, usize)>> = BinaryHeap::new(); + let mut best_final: Option = None; - while let Some(Reverse((cost, node))) = heap.pop() { - if node == dst { - let mut path = vec![dst]; - let mut current = dst; - while current != src { - let &prev_node = prev.get(¤t)?; - path.push(prev_node); - current = prev_node; - } - path.reverse(); - return Some(path); + arena.push(Entry { + node: src, + label: initial.clone(), + pred: None, + hops: 0, + }); + bags.entry(src).or_default().push(0); + frontier.push(Reverse((OrderedFloat(initial.cost()), 0))); + + // Reconstruct the node-name path for an arena entry (used for deterministic + // tie-breaks). Returns the sequence of node names from source to `idx`. + let name_path = |arena: &Vec>, idx: usize| -> Vec<&'static str> { + let mut names = Vec::new(); + let mut cur = Some(idx); + while let Some(i) = cur { + names.push(self.nodes[self.graph[arena[i].node]].name); + cur = arena[i].pred; } + names.reverse(); + names + }; - if cost.0 > *costs.get(&node).unwrap_or(&f64::INFINITY) { + while let Some(Reverse((cost, idx))) = frontier.pop() { + let node = arena[idx].node; + // Skip stale entries (removed from their bag because dominated / capped out). + if !bags.get(&node).is_some_and(|b| b.contains(&idx)) { + continue; + } + // The destination is terminal: keep it in the front, never expand it. + if node == dst { + continue; + } + if arena[idx].hops >= HOP_CAP { + continue; + } + // Branch-and-bound: a label already at least as costly as the best completed + // path cannot yield a cheaper destination (cost is non-decreasing). + if best_final.is_some_and(|bf| cost.0 >= bf) { continue; } - let current_size = match sizes.get(&node) { - Some(s) => s.clone(), - None => continue, - }; + // Deterministic edge order. + let mut edges: Vec<(NodeIndex, EdgeIndex)> = self + .graph + .edges(node) + .filter(|e| Self::edge_supports_mode(e.weight(), mode)) + .map(|e| (e.target(), e.id())) + .collect(); + edges.sort_by(|a, b| { + let na = &self.nodes[self.graph[a.0]]; + let nb = &self.nodes[self.graph[b.0]]; + (na.name, &na.variant).cmp(&(nb.name, &nb.variant)) + }); - for edge_ref in self.graph.edges(node) { - if !Self::edge_supports_mode(edge_ref.weight(), mode) { + let hops = arena[idx].hops; + for (target, edge_idx) in edges { + let weight = &self.graph[edge_idx]; + let target_node = &self.nodes[self.graph[target]]; + let redge = ReductionEdge { + overhead: &weight.overhead, + reduce_fn: weight.reduce_fn, + capabilities: weight.capabilities, + target_name: target_node.name, + target_variant: &target_node.variant, + }; + let Some(new_label) = arena[idx].label.extend(&redge) else { + continue; + }; + let new_cost = new_label.cost(); + // Branch-and-bound against the best completed path. + if best_final.is_some_and(|bf| new_cost >= bf) { continue; } - let overhead = &edge_ref.weight().overhead; - let next = edge_ref.target(); - - let edge_cost = cost_fn.edge_cost(overhead, ¤t_size); - let new_cost = cost.0 + edge_cost; - let new_size = overhead.evaluate_output_size(¤t_size); - - if new_cost < *costs.get(&next).unwrap_or(&f64::INFINITY) { - costs.insert(next, new_cost); - sizes.insert(next, new_size); - prev.insert(next, node); - heap.push(Reverse((OrderedFloat(new_cost), next))); + // Componentwise dominance against the target's bag. + if !exhaustive { + let bag = bags.entry(target).or_default(); + if bag.iter().any(|&j| arena[j].label.dominates(&new_label)) { + continue; + } + bag.retain(|&j| !new_label.dominates(&arena[j].label)); + } + let nidx = arena.len(); + arena.push(Entry { + node: target, + label: new_label, + pred: Some(idx), + hops: hops + 1, + }); + bags.entry(target).or_default().push(nidx); + frontier.push(Reverse((OrderedFloat(new_cost), nidx))); + if target == dst { + best_final = Some(match best_final { + Some(bf) => bf.min(new_cost), + None => new_cost, + }); + } + + // Enforce the per-node bag cap with a deterministic tie-break. + if bags[&target].len() > BAG_CAP { + let mut entries = bags[&target].clone(); + entries.sort_by(|&a, &b| { + arena[a] + .label + .cost() + .partial_cmp(&arena[b].label.cost()) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| arena[a].hops.cmp(&arena[b].hops)) + .then_with(|| name_path(&arena, a).cmp(&name_path(&arena, b))) + }); + entries.truncate(BAG_CAP); + bags.insert(target, entries); } } } - None + // The front is the (live) bag at the destination. + let mut front: Vec<(ReductionPath, L)> = bags + .get(&dst) + .map(|b| b.as_slice()) + .unwrap_or(&[]) + .iter() + .map(|&idx| { + let mut node_path = Vec::new(); + let mut cur = Some(idx); + while let Some(i) = cur { + node_path.push(arena[i].node); + cur = arena[i].pred; + } + node_path.reverse(); + ( + self.node_path_to_reduction_path(&node_path), + arena[idx].label.clone(), + ) + }) + .collect(); + + // Deterministic ordering of the front. + front.sort_by(|a, b| { + a.1.cost() + .partial_cmp(&b.1.cost()) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.len().cmp(&b.0.len())) + .then_with(|| a.0.type_names().cmp(&b.0.type_names())) + }); + front + } + + /// Name-keyed entry to [`pareto_search`](Self::pareto_search): resolves the source + /// and target variant nodes, then runs the generic search. Returns an empty vector + /// if either endpoint is not registered. Test-only: drives the generic kernel with a + /// custom label on a hand-built graph. + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + pub(crate) fn pareto_search_by_name( + &self, + source: &str, + source_variant: &BTreeMap, + target: &str, + target_variant: &BTreeMap, + mode: ReductionMode, + initial: L, + exhaustive: bool, + ) -> Vec<(ReductionPath, L)> { + let (Some(src), Some(dst)) = ( + self.lookup_node(source, source_variant), + self.lookup_node(target, target_variant), + ) else { + return vec![]; + }; + self.pareto_search(src, dst, mode, initial, exhaustive) + } + + /// Pick the best element of a Pareto front under the deterministic tie-break + /// (smallest cost, then fewest hops, then lexicographic node names). The front is + /// already sorted by [`pareto_search`](Self::pareto_search), so this returns the + /// first element. + fn pick_best_front( + &self, + front: &mut Vec<(ReductionPath, L)>, + ) -> Option<(ReductionPath, L)> { + if front.is_empty() { + None + } else { + Some(front.remove(0)) + } } /// Convert a node index path to a `ReductionPath`. @@ -1561,10 +1730,188 @@ impl ReductionGraph { } } +/// A concrete reduction path selected by the measured Pareto search. +/// +/// Holds the winning [`ReductionPath`], its **measured** final target +/// [`ProblemSize`], and the already-constructed reduction chain so downstream +/// solve/witness extraction reuses it without re-executing the reductions. +pub struct MeasuredPath { + /// The variant-level path. + pub path: ReductionPath, + /// Measured size of the final target problem. + pub size: ProblemSize, + /// The executed reduction steps (one per hop), shared via `Rc`. + steps: Vec>, +} + +impl MeasuredPath { + /// Get the final target problem as a type-erased reference. + pub fn target_problem_any(&self) -> &dyn Any { + self.steps + .last() + .expect("MeasuredPath has no steps") + .target_problem_any() + } + + /// Extract a solution from target space back to source space. + pub fn extract_solution(&self, target_solution: &[usize]) -> Vec { + self.steps + .iter() + .rev() + .fold(target_solution.to_vec(), |sol, step| { + step.extract_solution_dyn(&sol) + }) + } +} + +impl ReductionGraph { + /// Find the reduction path with the smallest **measured** final target size. + /// + /// Unlike [`find_cheapest_path_mode`](Self::find_cheapest_path_mode), which ranks + /// paths by overhead *formulas* (scaling upper bounds that can be arbitrarily loose + /// on structure-dependent constructions), this runs the [`MeasuredLabel`] domain: + /// it *actually executes* each reduction on `source_instance` and measures the real + /// constructed target size. Formulas are used only as a pre-flight guard against + /// catastrophic constructions (making OOM structurally impossible) — never to + /// arbitrate between concrete candidates. See design doc M3/F3b. + /// + /// `budget` is the hard total-size limit (sum of `ProblemSize` components); use + /// [`DEFAULT_SIZE_BUDGET`](crate::rules::DEFAULT_SIZE_BUDGET) for the default. + /// `exhaustive` disables only the heuristic componentwise-dominance guard (the sound + /// pre-flight, budget, and branch-and-bound guards still apply). + /// + /// Returns `None` if no in-budget witness-capable path exists (or `source == target`). + #[allow(clippy::too_many_arguments)] + pub fn find_measured_best_path( + &self, + source: &str, + source_variant: &BTreeMap, + target: &str, + target_variant: &BTreeMap, + mode: ReductionMode, + source_instance: &dyn Any, + budget: usize, + exhaustive: bool, + ) -> Option { + let src = self.lookup_node(source, source_variant)?; + let dst = self.lookup_node(target, target_variant)?; + if src == dst { + return None; + } + let source_size = Self::compute_source_size(source, source_instance); + let initial = MeasuredLabel::new(source_instance, source_size, budget); + let mut front = self.pareto_search(src, dst, mode, initial, exhaustive); + let (path, label) = self.pick_best_front(&mut front)?; + let steps: Vec> = label.chain().to_vec(); + if steps.is_empty() { + return None; + } + Some(MeasuredPath { + path, + size: label.measured_size().clone(), + steps, + }) + } + + /// Find the measured-smallest path from `source` to **any** variant of the target + /// problem name `target`. + /// + /// Runs [`find_measured_best_path`](Self::find_measured_best_path) once per target + /// variant and returns the overall measured-smallest result, with a deterministic + /// tie-break by (measured total size, hops, node-name path). + #[allow(clippy::too_many_arguments)] + pub fn find_measured_best_path_to_name( + &self, + source: &str, + source_variant: &BTreeMap, + target: &str, + mode: ReductionMode, + source_instance: &dyn Any, + budget: usize, + exhaustive: bool, + ) -> Option { + let mut best: Option = None; + for tv in self.variants_for(target) { + let Some(candidate) = self.find_measured_best_path( + source, + source_variant, + target, + &tv, + mode, + source_instance, + budget, + exhaustive, + ) else { + continue; + }; + let better = match &best { + None => true, + Some(cur) => { + let c = (candidate.size.total(), candidate.path.len()); + let b = (cur.size.total(), cur.path.len()); + c < b || (c == b && candidate.path.type_names() < cur.path.type_names()) + } + }; + if better { + best = Some(candidate); + } + } + best + } +} + +#[cfg(test)] +impl ReductionGraph { + /// Build a bare reduction graph from an explicit node/edge list (test-only). + /// + /// Nodes carry the empty variant and empty complexity; each edge carries a + /// [`ReductionEdgeData`]. This lets tests exercise the generic Pareto search on a + /// hand-built topology (e.g. the negative-control diamond) without depending on the + /// registered inventory. + pub(crate) fn from_test_edges( + node_names: &[&'static str], + edges: &[(&'static str, &'static str, ReductionEdgeData)], + ) -> Self { + let mut graph: DiGraph = DiGraph::new(); + let mut nodes: Vec = Vec::new(); + let mut name_to_nodes: HashMap<&'static str, Vec> = HashMap::new(); + let mut index_of: HashMap<&'static str, NodeIndex> = HashMap::new(); + + for &name in node_names { + let node_id = nodes.len(); + nodes.push(VariantNode { + name, + variant: BTreeMap::new(), + complexity: "", + }); + let idx = graph.add_node(node_id); + index_of.insert(name, idx); + name_to_nodes.entry(name).or_default().push(idx); + } + + for (src, dst, data) in edges { + let s = index_of[src]; + let d = index_of[dst]; + graph.add_edge(s, d, data.clone()); + } + + Self { + graph, + nodes, + name_to_nodes, + default_variants: HashMap::new(), + } + } +} + #[cfg(test)] #[path = "../unit_tests/rules/graph.rs"] mod tests; +#[cfg(test)] +#[path = "../unit_tests/rules/pareto.rs"] +mod pareto_tests; + #[cfg(test)] #[path = "../unit_tests/rules/reduction_path_parity.rs"] mod reduction_path_parity_tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e648997a4..90f577207 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -2,6 +2,7 @@ pub mod analysis; pub mod cost; +pub mod pareto; pub mod registry; pub use cost::{ CustomCost, Minimize, MinimizeOutputSize, MinimizeSteps, MinimizeStepsThenOverhead, PathCostFn, @@ -403,8 +404,11 @@ pub(crate) mod undirectedflowlowerbounds_ilp; pub(crate) mod undirectedtwocommodityintegralflow_ilp; pub use graph::{ - AggregateReductionChain, NeighborInfo, NeighborTree, ReductionChain, ReductionEdgeInfo, - ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, + AggregateReductionChain, MeasuredPath, NeighborInfo, NeighborTree, ReductionChain, + ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, +}; +pub use pareto::{ + CostLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, DEFAULT_SIZE_BUDGET, HOP_CAP, }; pub use traits::{ AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs new file mode 100644 index 000000000..78106526c --- /dev/null +++ b/src/rules/pareto.rs @@ -0,0 +1,317 @@ +//! Pareto label-setting search over the reduction graph. +//! +//! This module replaces the old scalar Dijkstra (`ReductionGraph::dijkstra`) with a +//! generic multi-label search. The core motivation (issue #788, design doc +//! `docs/design/symbolic-growth-domain.md`, section M3/F3b) is that edge costs are +//! **path-dependent**: the cost of a reduction depends on the size of the problem +//! accumulated along the path so far. Scalar Dijkstra keeps only the cheapest-so-far +//! label per node, so a cheaper-but-larger intermediate state can poison downstream +//! choices — it can miss the path whose *final* target is smallest. +//! +//! The fix is the standard algorithm for partial-order path costs — **multi-label +//! Pareto search** (Martins 1984; McRAPTOR-style per-node label bags). Each node keeps +//! an antichain of non-dominated labels (a "bag"); a label is only pruned when another +//! label at the same node dominates it. See [`ReductionGraph::pareto_search`]. +//! +//! Two label domains are provided: +//! - [`CostLabel`]: a scalar formula label that reproduces Dijkstra's behavior for the +//! existing `PathCostFn` cost functions (used by `find_cheapest_path*`). It carries the +//! accumulated `ProblemSize` (from overhead formulas) and an additive scalar cost. +//! - [`MeasuredLabel`]: the concrete-instance label. For a concrete source instance, it +//! *actually executes* each reduction and measures the real constructed target size. +//! Formulas are only used as a pre-flight guard, never to arbitrate between candidates. + +use crate::rules::cost::PathCostFn; +use crate::rules::registry::{EdgeCapabilities, ReduceFn, ReductionOverhead}; +use crate::rules::traits::DynReductionResult; +use crate::types::ProblemSize; +use std::any::Any; +use std::cell::Cell; +use std::collections::BTreeMap; +use std::panic; +use std::rc::Rc; +use std::sync::Once; + +thread_local! { + /// When set, the installed panic hook suppresses output on the current thread. + static SILENCE_PANIC: Cell = const { Cell::new(false) }; +} + +static HOOK_INIT: Once = Once::new(); + +/// Run `f`, catching any panic and returning `None`, without printing the panic to +/// stderr on this thread. +/// +/// During the measured search we deliberately execute candidate reductions to measure +/// their real output size. A reduction whose preconditions the current instance violates +/// panics (its macro-generated dispatch downcasts and unwraps); such an edge is simply +/// not a viable path, so we treat the panic as "edge infeasible" and prune it — the +/// design's guarantee that path selection never crashes. The thread-local silencer keeps +/// this expected, recovered panic from spamming stderr while leaving genuine panics on +/// other threads untouched. +fn catch_reduction(f: impl FnOnce() -> R) -> Option { + HOOK_INIT.call_once(|| { + let prev = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if SILENCE_PANIC.with(|s| s.get()) { + return; + } + prev(info); + })); + }); + SILENCE_PANIC.with(|s| s.set(true)); + let result = panic::catch_unwind(panic::AssertUnwindSafe(f)); + SILENCE_PANIC.with(|s| s.set(false)); + result.ok() +} + +/// Default hard total-size budget for the measured search (in "size units", i.e. the +/// sum of all `ProblemSize` components). Generous by design: the point is to refuse +/// astronomic constructions (e.g. a `2^num_vertices` blow-up), not to micro-manage. +pub const DEFAULT_SIZE_BUDGET: usize = 10_000_000; + +/// Maximum number of reduction steps (hops) explored along any path. +pub const HOP_CAP: usize = 16; + +/// Maximum number of non-dominated labels retained per node. On overflow, the bag is +/// truncated by a deterministic tie-break (never by iteration order). +pub const BAG_CAP: usize = 32; + +/// A borrowed view of one reduction edge, handed to [`PathLabel::extend`]. +/// +/// It exposes exactly what a label needs to advance: the overhead formula (for the +/// symbolic pre-flight guard and formula-based sizing), the executable reduction +/// function (for measured execution), the edge capabilities, and the target node's +/// identity (for measuring the constructed target's size by name). +pub struct ReductionEdge<'g> { + /// Overhead expressions mapping source size fields to target size fields. + pub overhead: &'g ReductionOverhead, + /// Type-erased witness reduction executor, if this edge supports witness/config mode. + pub reduce_fn: Option, + /// Capability metadata for the edge. + pub capabilities: EdgeCapabilities, + /// Target problem name (e.g. "ILP"). + pub target_name: &'static str, + /// Target problem variant. + pub target_variant: &'g BTreeMap, +} + +/// A path cost that composes along reduction edges under a partial order. +/// +/// **Isotonicity invariant (correctness condition for dominance pruning):** if label +/// `A` dominates label `B`, then for any edge `e`, `A.extend(e)` dominates `B.extend(e)` +/// (when both are `Some`). This follows from the monotonicity of overhead / reduction +/// size in the source size. The Pareto search relies on it to safely discard dominated +/// labels. +/// +/// **B&B soundness:** [`cost`](PathLabel::cost) must be non-decreasing along `extend` +/// (a reduction never shrinks the tracked cost below the current value). Every concrete +/// cost function and the measured-size total satisfy this. +pub trait PathLabel: Clone { + /// Advance this label across `edge`. Returns `None` when a guard prunes the edge + /// (e.g. the measured label's pre-flight size guard). A `None` must be *isotone*: + /// if `A` dominates `B` and `A.extend(e)` is `None`, that is fine, but a guard must + /// never prune a dominating label while keeping a dominated one. + fn extend(&self, edge: &ReductionEdge) -> Option; + + /// Partial order: `true` iff `self` is at least as good as `other` in every + /// component (and strictly better in at least one, or equal). Used to keep each + /// node's bag an antichain. + fn dominates(&self, other: &Self) -> bool; + + /// Scalar summary used for branch-and-bound pruning, frontier ordering, and the + /// deterministic final tie-break. Smaller is better. Must be non-decreasing along + /// `extend` (see trait docs). + fn cost(&self) -> f64; +} + +/// Formula-based scalar label reproducing Dijkstra behavior for a [`PathCostFn`]. +/// +/// Carries the accumulated `ProblemSize` (advanced through overhead formulas) and the +/// additive scalar cost. Dominance is scalar (`self.cost <= other.cost`), so each node +/// keeps only its minimum-cost label — exactly the classic single-objective shortest +/// path, but expressed in the generic kernel. +pub struct CostLabel<'c, C: PathCostFn> { + size: ProblemSize, + cost: f64, + cost_fn: &'c C, +} + +// Manual `Clone` (the derive would wrongly require `C: Clone`; `cost_fn` is a reference). +impl Clone for CostLabel<'_, C> { + fn clone(&self) -> Self { + Self { + size: self.size.clone(), + cost: self.cost, + cost_fn: self.cost_fn, + } + } +} + +impl<'c, C: PathCostFn> CostLabel<'c, C> { + /// Create the initial label at the source node. + pub fn new(input_size: ProblemSize, cost_fn: &'c C) -> Self { + Self { + size: input_size, + cost: 0.0, + cost_fn, + } + } +} + +impl PathLabel for CostLabel<'_, C> { + fn extend(&self, edge: &ReductionEdge) -> Option { + let increment = self.cost_fn.edge_cost(edge.overhead, &self.size); + let new_size = edge.overhead.evaluate_output_size(&self.size); + Some(Self { + size: new_size, + cost: self.cost + increment, + cost_fn: self.cost_fn, + }) + } + + fn dominates(&self, other: &Self) -> bool { + self.cost <= other.cost + } + + fn cost(&self) -> f64 { + self.cost + } +} + +/// The current constructed position of a [`MeasuredLabel`]. +#[derive(Clone)] +enum MeasuredPos<'a> { + /// At the source node: the original, un-reduced source instance. + Source(&'a dyn Any), + /// At a reduced node: the last reduction step's result. The current problem instance + /// is `result.target_problem_any()`. + Reduced(Rc), +} + +/// The concrete-instance measured label (design doc M3/F3b). +/// +/// For a concrete source instance, formulas are advisory — the **measured** target size +/// is authoritative. `extend` runs this four-part pruning stack, in order: +/// +/// 1. **Symbolic pre-flight guard:** evaluate the edge's overhead formula at the current +/// *measured* size. If the (upper-bound) prediction already exceeds the budget, return +/// `None` **without executing** — so a catastrophic construction (e.g. a +/// `2^num_vertices` blow-up) is never even started. This is what makes OOM +/// structurally impossible during path selection. +/// 2. **Execute + measure:** run `reduce_to()`, measure the real target size; over budget +/// → `None`. +/// 3. **Branch-and-bound:** handled by the kernel using [`cost`](PathLabel::cost) against +/// the best completed path's final size. +/// 4. **Componentwise measured-size dominance:** [`dominates`](PathLabel::dominates), a +/// heuristic under a documented size-monotone-future assumption. The kernel's +/// `exhaustive` flag disables *only* this guard, keeping 1–3 (which are sound). +#[derive(Clone)] +pub struct MeasuredLabel<'a> { + /// Measured size of the problem instance at the current node. + size: ProblemSize, + /// The reduction steps executed so far (empty at the source). Shared via `Rc` so + /// cloning a label is cheap and never re-executes a reduction. + chain: Vec>, + /// Current constructed position. + pos: MeasuredPos<'a>, + /// Hard total-size budget. + budget: usize, +} + +impl<'a> MeasuredLabel<'a> { + /// Create the initial measured label at the source node. + /// + /// `source_size` is the measured size of `source` (typically + /// `ReductionGraph::compute_source_size`). + pub fn new(source: &'a dyn Any, source_size: ProblemSize, budget: usize) -> Self { + Self { + size: source_size, + chain: Vec::new(), + pos: MeasuredPos::Source(source), + budget, + } + } + + /// The reduction chain executed to reach this label (one entry per hop). + pub(crate) fn chain(&self) -> &[Rc] { + &self.chain + } + + /// The measured problem size at this label's node. + pub(crate) fn measured_size(&self) -> &ProblemSize { + &self.size + } +} + +/// Componentwise "less-or-equal in every field" test between two measured sizes. +/// +/// `a` covers `b` iff every field of `b` is present in `a` with a value `>=` b's — i.e. +/// `a` is componentwise `<=` `b`. Missing fields are treated as `0`. +fn size_le(a: &ProblemSize, b: &ProblemSize) -> bool { + // a <= b componentwise: for each field in either, a[f] <= b[f]. + a.components.iter().all(|(name, av)| { + let bv = b.get(name).unwrap_or(0); + *av <= bv + }) && b.components.iter().all(|(name, bv)| { + let av = a.get(name).unwrap_or(0); + av <= *bv + }) +} + +impl PathLabel for MeasuredLabel<'_> { + fn extend(&self, edge: &ReductionEdge) -> Option { + // Guard 1: symbolic pre-flight. Predict the target size from the overhead + // formula evaluated at the *measured* current size. Because formulas are upper + // bounds, a prediction over budget means we must not even start the construction. + // Computed in `f64` so an astronomic prediction (e.g. `2^num_vertices`) is flagged + // rather than overflowing `usize`. + let predicted_total = edge.overhead.evaluate_output_total_f64(&self.size); + if predicted_total > self.budget as f64 { + return None; + } + + // Guard 2: execute the reduction and measure the real target size. Executing a + // reduction whose preconditions the current instance violates panics; such an + // edge is not a viable path, so a caught panic prunes it (returns `None`). The + // measurement (`compute_source_size`) probes every same-name size function, so + // mismatched-variant probes panic internally too — both are wrapped in one + // silenced `catch_reduction`. + let reduce_fn = edge.reduce_fn?; + let current: &dyn Any = match &self.pos { + MeasuredPos::Source(s) => *s, + MeasuredPos::Reduced(r) => r.target_problem_any(), + }; + let target_name = edge.target_name; + let (result, measured) = catch_reduction(|| { + let result: Rc = Rc::from(reduce_fn(current)); + let measured = crate::rules::ReductionGraph::compute_source_size( + target_name, + result.target_problem_any(), + ); + (result, measured) + })?; + if measured.total() > self.budget { + return None; + } + + let mut chain = self.chain.clone(); + chain.push(result.clone()); + Some(Self { + size: measured, + chain, + pos: MeasuredPos::Reduced(result), + budget: self.budget, + }) + } + + fn dominates(&self, other: &Self) -> bool { + // Componentwise measured-size dominance. Labels compared here are always at the + // same node (same problem variant), so their size fields coincide. + size_le(&self.size, &other.size) + } + + fn cost(&self) -> f64 { + self.size.total() as f64 + } +} diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 8048022da..0fea24d44 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -41,6 +41,19 @@ impl ReductionOverhead { ProblemSize::new(fields) } + /// Predicted total output size as an `f64`, summing every output field's formula. + /// + /// Unlike [`evaluate_output_size`](Self::evaluate_output_size), this never rounds to + /// `usize`, so an astronomic prediction (e.g. `2^num_vertices` on a large instance) + /// stays a large finite `f64` instead of overflowing. Used by the measured Pareto + /// search's pre-flight guard to refuse catastrophic constructions before executing. + pub fn evaluate_output_total_f64(&self, input: &ProblemSize) -> f64 { + self.output_size + .iter() + .map(|(_, expr)| expr.eval(input).max(0.0)) + .sum() + } + /// Collect all input variable names referenced by the overhead expressions. pub fn input_variable_names(&self) -> HashSet<&'static str> { self.output_size diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 51b2a0df2..c77b3e017 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -240,48 +240,36 @@ impl ILPSolver { any.is::>() || any.is::>() || any.is::() } - /// Two-level path selection: - /// 1. Dijkstra finds the cheapest path to each ILP variant using - /// `MinimizeStepsThenOverhead` (additive edge costs: step count + log overhead). - /// 2. Across ILP variants, we pick the path whose composed final output size - /// is smallest — this is the actual ILP problem size the solver will face. + /// Select the witness reduction path to ILP whose **measured** final ILP size is + /// smallest. + /// + /// Delegates to the measured Pareto search + /// ([`ReductionGraph::find_measured_best_path_to_name`]): it actually executes each + /// reduction on `instance` and measures the real constructed ILP size, choosing the + /// smallest across all ILP variants. Overhead formulas are used only as a pre-flight + /// guard against catastrophic constructions — never to arbitrate between concrete + /// candidates. This fixes issue #788 (formula/step ranking could miss the path with + /// the smallest real ILP) and makes OOM structurally impossible during selection. + /// + /// The returned [`MeasuredPath`](crate::rules::MeasuredPath) carries the already + /// constructed reduction chain, so the caller solves and extracts without + /// re-executing the reductions. fn best_path_to_ilp( &self, graph: &crate::rules::ReductionGraph, name: &str, variant: &std::collections::BTreeMap, - mode: ReductionMode, instance: &dyn std::any::Any, - ) -> Option { - let ilp_variants = graph.variants_for("ILP"); - let input_size = crate::rules::ReductionGraph::compute_source_size(name, instance); - let mut best_path: Option = None; - let mut best_cost = f64::INFINITY; - - for dv in &ilp_variants { - if let Some(path) = graph.find_cheapest_path_mode( - name, - variant, - "ILP", - dv, - mode, - &input_size, - &crate::rules::MinimizeStepsThenOverhead, - ) { - // Use composed final output size for cross-variant comparison, - // since this determines the actual ILP problem size. - let final_size = graph - .evaluate_path_overhead(&path, &input_size) - .unwrap_or_default(); - let cost = final_size.total() as f64; - if cost < best_cost { - best_cost = cost; - best_path = Some(path); - } - } - } - - best_path + ) -> Option { + graph.find_measured_best_path_to_name( + name, + variant, + "ILP", + ReductionMode::Witness, + instance, + crate::rules::DEFAULT_SIZE_BUDGET, + false, + ) } pub fn try_solve_via_reduction( @@ -300,13 +288,8 @@ impl ILPSolver { let graph = crate::rules::ReductionGraph::new(); - let Some(path) = - self.best_path_to_ilp(&graph, name, variant, ReductionMode::Witness, instance) - else { - if self - .best_path_to_ilp(&graph, name, variant, ReductionMode::Aggregate, instance) - .is_some() - { + let Some(measured) = self.best_path_to_ilp(&graph, name, variant, instance) else { + if self.has_aggregate_path_to_ilp(&graph, name, variant) { return Err(SolveViaReductionError::WitnessPathRequired { name: name.to_string(), }); @@ -317,17 +300,37 @@ impl ILPSolver { }); }; - let chain = graph.reduce_along_path(&path, instance).ok_or_else(|| { - SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - } - })?; - let ilp_solution = self.solve_dyn(chain.target_problem_any()).ok_or_else(|| { - SolveViaReductionError::NoSolution { + let ilp_solution = self + .solve_dyn(measured.target_problem_any()) + .ok_or_else(|| SolveViaReductionError::NoSolution { name: name.to_string(), - } - })?; - Ok(chain.extract_solution(&ilp_solution)) + })?; + Ok(measured.extract_solution(&ilp_solution)) + } + + /// Whether an aggregate-capable (but possibly not witness-capable) reduction path to + /// some ILP variant exists. Used only to distinguish "no path at all" from "a path + /// exists but cannot recover a witness" for error reporting. + fn has_aggregate_path_to_ilp( + &self, + graph: &crate::rules::ReductionGraph, + name: &str, + variant: &std::collections::BTreeMap, + ) -> bool { + let input_size = crate::types::ProblemSize::new(vec![]); + graph.variants_for("ILP").iter().any(|dv| { + graph + .find_cheapest_path_mode( + name, + variant, + "ILP", + dv, + ReductionMode::Aggregate, + &input_size, + &crate::rules::MinimizeSteps, + ) + .is_some() + }) } /// Solve a type-erased problem by finding a reduction path to ILP. diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs new file mode 100644 index 000000000..bd21f6294 --- /dev/null +++ b/src/unit_tests/rules/pareto.rs @@ -0,0 +1,287 @@ +//! Tests for the Pareto label-setting search (`src/rules/pareto.rs`) and its two label +//! domains. Covers: +//! - The measured concrete-instance label (issue #788 known-answer, OOM pre-flight guard). +//! - The generic kernel's correctness on a hand-built diamond (negative control): a +//! scalar-cost path selection commits to the wrong prefix, while the Pareto search +//! returns the path with the strictly-better final measured size. + +use super::*; +use crate::expr::Expr; +use crate::models::graph::{HamiltonianCircuit, HighlyConnectedDeletion}; +use crate::rules::cost::CustomCost; +use crate::rules::pareto::{PathLabel, ReductionEdge}; +use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; +use crate::rules::{ReductionGraph, ReductionMode, DEFAULT_SIZE_BUDGET}; +use crate::topology::SimpleGraph; +use crate::types::ProblemSize; +use std::any::Any; +use std::time::Instant; + +// --------------------------------------------------------------------------- +// Verification 1: issue #788 known-answer check. +// --------------------------------------------------------------------------- + +/// The prism (triangular-prism) graph from issue #788: 6 vertices, 9 edges. +fn prism_hamiltonian_circuit() -> HamiltonianCircuit { + let prism = SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 0), + (3, 4), + (4, 5), + (5, 3), + (0, 3), + (1, 4), + (2, 5), + ], + ); + HamiltonianCircuit::new(prism) +} + +/// #788: the measured Pareto search selects the path whose *measured* final ILP size is +/// smallest. +/// +/// The literal reduction chain quoted in issue #788 (HC → HP → ConsecutiveOnesSubmatrix → +/// ILP, total 60) no longer exists on the current reduction graph. The *current* measured +/// optimum is HC → LongestCircuit → ILP with a measured total of 232 +/// (num_constraints=127, num_vars=105); the next candidates are RuralPostman → ILP +/// (366) and TravelingSalesman → ILP (768). This test pins the measured optimum so +/// the selector is proven to rank by *measured* final size, not by step count or formula. +#[test] +fn test_hamiltoniancircuit_to_ilp_measured_optimum_788() { + let hc = prism_hamiltonian_circuit(); + let graph = ReductionGraph::new(); + let variant = ReductionGraph::variant_to_map(&[("graph", "SimpleGraph")]); + + let measured = graph + .find_measured_best_path_to_name( + "HamiltonianCircuit", + &variant, + "ILP", + ReductionMode::Witness, + &hc as &dyn Any, + DEFAULT_SIZE_BUDGET, + false, + ) + .expect("a measured witness path from HamiltonianCircuit to ILP"); + + // Measured final ILP size is the current-graph optimum. + assert_eq!( + measured.size.total(), + 232, + "measured optimum should be 232, got {:?}", + measured.size + ); + // Via LongestCircuit, to the bool ILP variant. + assert_eq!( + measured.path.type_names(), + vec!["HamiltonianCircuit", "LongestCircuit", "ILP"], + ); + + // The constructed chain is reusable: the final target is a genuine ILP. + use crate::models::algebraic::ILP; + let ilp = measured + .target_problem_any() + .downcast_ref::>() + .expect("final target is ILP"); + assert_eq!(ilp.num_vars, 105); +} + +// --------------------------------------------------------------------------- +// Verification 2: OOM pre-flight guard is real. +// --------------------------------------------------------------------------- + +/// Routing a 64-vertex instance through the `2^num_vertices` overhead edge +/// (`highlyconnecteddeletion_ilp`) must be refused by the symbolic pre-flight guard +/// *before* the exponential construction is ever started: the search completes near +/// instantly and returns no in-budget path (the sole HCD → ILP edge is pruned). +/// +/// The instance is a dense 64-vertex graph on purpose — if the guard were removed, the +/// reduction would enumerate ~2^64 feasible clusters and exhaust memory. Because guard 1 +/// evaluates the formula (`2^64 ≫ budget`) and skips without executing, the test is safe. +#[test] +fn test_oom_preflight_guard_highlyconnecteddeletion() { + // Dense 64-vertex graph (complete graph K_64): cheap to build, catastrophic to reduce. + let n = 64; + let mut edges = Vec::new(); + for u in 0..n { + for v in (u + 1)..n { + edges.push((u, v)); + } + } + let hcd = HighlyConnectedDeletion::new(SimpleGraph::new(n, edges)); + let graph = ReductionGraph::new(); + let variant = ReductionGraph::variant_to_map(&[("graph", "SimpleGraph")]); + + let start = Instant::now(); + let result = graph.find_measured_best_path_to_name( + "HighlyConnectedDeletion", + &variant, + "ILP", + ReductionMode::Witness, + &hcd as &dyn Any, + DEFAULT_SIZE_BUDGET, + false, + ); + let elapsed = start.elapsed(); + + // The only HCD -> ILP path is the 2^num_vertices edge; it is pre-flight-pruned. + assert!( + result.is_none(), + "the 2^num_vertices construction must be refused, not selected" + ); + // Structural proof the exponential enumeration was never started: it finishes fast. + assert!( + elapsed.as_secs_f64() < 1.0, + "search must complete in < 1s (never executes the exponential edge); took {:?}", + elapsed + ); +} + +// --------------------------------------------------------------------------- +// Verification 4: negative control on a hand-built diamond. +// --------------------------------------------------------------------------- + +/// A test label whose objective is the *final* measured size `s`, while carrying a +/// separate accumulated step cost `c`. Dominance is componentwise Pareto over `(c, s)`, +/// so two labels that trade off `c` against `s` are incomparable and both survive — the +/// exact structure a scalar Dijkstra collapses (keeping only the min-`c` label, and thus +/// its `s`). +#[derive(Clone)] +struct DiamondLabel { + /// Accumulated step cost. + c: f64, + /// Current (path-dependent) measured size. + s: f64, +} + +impl DiamondLabel { + fn ctx(&self) -> ProblemSize { + ProblemSize::new(vec![("s", self.s.round().max(0.0) as usize)]) + } +} + +impl PathLabel for DiamondLabel { + fn extend(&self, edge: &ReductionEdge) -> Option { + let ctx = self.ctx(); + let add_c = edge.overhead.get("c").map(|e| e.eval(&ctx)).unwrap_or(0.0); + let new_s = edge + .overhead + .get("s") + .map(|e| e.eval(&ctx)) + .unwrap_or(self.s); + Some(DiamondLabel { + c: self.c + add_c, + s: new_s, + }) + } + + fn dominates(&self, other: &Self) -> bool { + self.c <= other.c && self.s <= other.s + } + + fn cost(&self) -> f64 { + self.s + } +} + +fn diamond_edge(c: f64, s: Expr) -> ReductionEdgeData { + ReductionEdgeData { + overhead: ReductionOverhead::new(vec![("c", Expr::Const(c)), ("s", s)]), + reduce_fn: None, + reduce_aggregate_fn: None, + capabilities: EdgeCapabilities::witness_only(), + } +} + +/// Negative control: P1 (S→M→T) has the lower first-edge cost but a larger measured +/// intermediate size at M; P2 (S→P→M→T) has a higher first-edge cost but a strictly +/// smaller final measured size. A scalar-cost path selection (`find_cheapest_path` over +/// the additive step cost) commits to P1's prefix at M and returns P1; the measured +/// Pareto search keeps both routes into M (they are incomparable) and returns P2. +#[test] +fn test_negative_control_diamond_pareto_beats_scalar() { + let empty = std::collections::BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "M", "P", "T"], + &[ + // S -> M: cheap first edge (c=1), large intermediate size (s=100). + ("S", "M", diamond_edge(1.0, Expr::Const(100.0))), + // S -> P: pricier first edge (c=2), small size (s=5). + ("S", "P", diamond_edge(2.0, Expr::Const(5.0))), + // P -> M: small size (s=6). + ("P", "M", diamond_edge(1.0, Expr::Const(6.0))), + // M -> T: identity on size (final size = size at M). + ("M", "T", diamond_edge(1.0, Expr::Var("s"))), + ], + ); + + // (a) Scalar-cost selection (minimize additive step cost `c`) commits to P1. + let scalar = graph + .find_cheapest_path( + "S", + &empty, + "T", + &empty, + &ProblemSize::new(vec![]), + &CustomCost(|oh: &ReductionOverhead, sz: &ProblemSize| { + oh.get("c").map(|e| e.eval(sz)).unwrap_or(0.0) + }), + ) + .expect("scalar path S -> T"); + assert_eq!( + scalar.type_names(), + vec!["S", "M", "T"], + "scalar cost selection should commit to the cheap-prefix P1" + ); + + // (b) The measured Pareto search returns P2 (strictly smaller final size). + let initial = DiamondLabel { c: 0.0, s: 0.0 }; + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + false, + ); + assert!(!front.is_empty(), "front should reach T"); + let (best_path, best_label) = &front[0]; + assert_eq!( + best_path.type_names(), + vec!["S", "P", "M", "T"], + "Pareto search should return the better-final-size P2" + ); + assert_eq!(best_label.cost(), 6.0, "P2's final measured size is 6"); +} + +/// The `exhaustive` flag disables only the heuristic componentwise-dominance guard; the +/// front still contains the true optimum. On the diamond, both routes into M survive +/// regardless, so the answer is unchanged. +#[test] +fn test_diamond_exhaustive_matches_pruned() { + let empty = std::collections::BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "M", "P", "T"], + &[ + ("S", "M", diamond_edge(1.0, Expr::Const(100.0))), + ("S", "P", diamond_edge(2.0, Expr::Const(5.0))), + ("P", "M", diamond_edge(1.0, Expr::Const(6.0))), + ("M", "T", diamond_edge(1.0, Expr::Var("s"))), + ], + ); + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + DiamondLabel { c: 0.0, s: 0.0 }, + true, + ); + assert_eq!(front[0].0.type_names(), vec!["S", "P", "M", "T"]); + assert_eq!(front[0].1.cost(), 6.0); +} From 99fd0084ab2e5ab1f90f903a27d851955d307e6b Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 19:42:16 +0800 Subject: [PATCH 04/31] Add instance-free asymptotic Pareto path search (GrowthLabel) + CLI/MCP front (#1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M3 (F3a): the asymptotic, instance-free label domain `GrowthLabel` and its surfacing through `pred path` / MCP `find_path`. Closes the 2-year-old research issue #15 (multi-variable shortest path over polynomials). - `GrowthLabel` (src/rules/pareto.rs): each current-node size field mapped to a `Growth` in the source problem's size variables. `extend` composes an edge's overhead by substituting each current field's rendered growth (`Growth::to_expr`) into the overhead `Expr` and reducing via `Growth::from_expr` (reuses M1+M2, no new growth primitive). Fields depending on an `Unknown` growth stay `Unknown` — never a fabricated bound. `dominates` is componentwise search-sense (smaller growth better), with `Unknown` as top so any label with an `Unknown` field is dominated by a fully known one (undecidable paths rank last). `cost()` is a monotone magnitude scalar with a position tiebreak so the kernel's scalar branch-and-bound keeps incomparable, equal-magnitude front members. Plugs into the existing `pareto_search` kernel. - `Growth::magnitude` (src/growth.rs): deterministic monotone scalar for search ordering only (dominance stays exact). `Growth` re-exported for CLI/MCP. - `ReductionGraph::asymptotic_front`: builds the initial label from the source's size fields, runs `pareto_search`, orders the front by (hops, lexicographic node names). - CLI: bare `pred path S T` (no `--cost`/`--size`, no `--all`) now prints the asymptotic Pareto front, each path annotated with `O(...)` per target size field (`O(?)` for unbounded). `--cost` opts into the unchanged single-best mode; `--all` unchanged. MCP `find_path` returns the same front with structured `Growth` serde. - Fix `find_paths_up_to_mode_bounded` to apply the mode filter *before* `take(limit)` (was after), so `--all` truncation no longer depends on enumeration order. - Tests: GrowthLabel extend/dominance/Unknown/isotonicity, the incomparable-front negative control (O(n^2)/O(m) vs O(n)/O(m^2), both kept), CLI determinism/golden for `pred path KSatisfiability QUBO`, and an MCP asymptotic-front test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- Makefile | 5 +- problemreductions-cli/src/cli.rs | 11 +- problemreductions-cli/src/commands/graph.rs | 147 +++++++++- problemreductions-cli/src/main.rs | 2 +- problemreductions-cli/src/mcp/tests.rs | 24 +- problemreductions-cli/src/mcp/tools.rs | 81 +++++- problemreductions-cli/tests/cli_tests.rs | 104 ++++++- src/growth.rs | 25 ++ src/lib.rs | 9 +- src/rules/graph.rs | 74 ++++- src/rules/mod.rs | 3 +- src/rules/pareto.rs | 150 +++++++++- src/unit_tests/rules/pareto.rs | 288 +++++++++++++++++++- 13 files changed, 877 insertions(+), 46 deletions(-) diff --git a/Makefile b/Makefile index ce9056ca2..a854eba53 100644 --- a/Makefile +++ b/Makefile @@ -289,10 +289,11 @@ cli-demo: cli $$PRED from QUBO --hops 1; \ \ echo ""; \ - echo "--- 5. path: find reduction paths ---"; \ + echo "--- 5. path: asymptotic Pareto front (no --size) ---"; \ $$PRED path MIS QUBO; \ - $$PRED path MIS QUBO -o $(CLI_DEMO_DIR)/path_mis_qubo.json; \ $$PRED path Factoring SpinGlass; \ + echo "--- 5b. path --cost: single concrete path (for reduce --via) ---"; \ + $$PRED path MIS QUBO --cost minimize-steps -o $(CLI_DEMO_DIR)/path_mis_qubo.json; \ $$PRED path MIS QUBO --cost minimize:num_variables; \ \ echo ""; \ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 70fa1e5af..5b81761d1 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -112,11 +112,11 @@ Use `pred to ` for incoming neighbors (what reduces to this).")] /// Find the cheapest reduction path between two problems #[command(after_help = "\ Examples: - pred path MIS QUBO # cheapest path + pred path MIS QUBO # asymptotic Pareto front (Big-O per size field) pred path MIS QUBO --all # all paths pred path MIS QUBO -o path.json # save for `pred reduce --via` pred path MIS QUBO --all -o paths/ # save all paths to a folder - pred path MIS QUBO --cost minimize:num_variables + pred path MIS QUBO --cost minimize:num_variables # single cheapest path by a scalar cost Use `pred list` to see available problems.")] Path { @@ -126,9 +126,10 @@ Use `pred list` to see available problems.")] /// Target problem (e.g., QUBO) #[arg(value_parser = crate::problem_name::ProblemNameParser)] target: String, - /// Cost function [default: minimize-steps] - #[arg(long, default_value = "minimize-steps")] - cost: String, + /// Scalar cost function ('minimize-steps' or 'minimize:') for a single + /// best path. Omit to get the instance-free asymptotic Pareto front. + #[arg(long)] + cost: Option, /// Show all paths instead of just the cheapest #[arg(long)] all: bool, diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index f37940725..a35a0388c 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -2,9 +2,12 @@ use crate::output::OutputConfig; use crate::problem_name::{aliases_for, parse_problem_spec, resolve_problem_ref}; use anyhow::{Context, Result}; use problemreductions::registry::collect_schemas; -use problemreductions::rules::{Minimize, MinimizeSteps, ReductionGraph, TraversalFlow}; +use problemreductions::rules::{ + GrowthLabel, Minimize, MinimizeSteps, ReductionGraph, ReductionMode, ReductionPath, + TraversalFlow, +}; use problemreductions::types::ProblemSize; -use problemreductions::{big_o_normal_form, Expr}; +use problemreductions::{big_o_normal_form, Expr, Growth}; use std::collections::BTreeMap; pub fn list(out: &OutputConfig) -> Result<()> { @@ -487,10 +490,134 @@ fn format_path_json( }) } +/// Render one growth as a Big-O string: `O()`, or an explicit unbounded marker +/// for `Growth::Unknown` (nonlinear exponent / factorial) — never a fabricated bound. +fn growth_big_o(g: &Growth) -> String { + match g.to_expr() { + Some(e) => format!("O({e})"), + None => "O(?) [unbounded: nonlinear exponent / factorial]".to_string(), + } +} + +/// Node-arrow summary (`A → B → C`) for a reduction path, deduplicating consecutive +/// same-name variant-cast steps. +fn path_arrow_summary(graph: &ReductionGraph, reduction_path: &ReductionPath) -> String { + let mut parts = Vec::new(); + let mut prev_name = ""; + for step in &reduction_path.steps { + if step.name != prev_name { + parts.push(fmt_node(graph, &step.name, &step.variant)); + prev_name = &step.name; + } + } + parts.join(&format!(" {} ", crate::output::fmt_outgoing("→"))) +} + +/// Text rendering of the asymptotic Pareto front: each path's step chain annotated +/// with a normalized `O(...)` per target size field (in the source's variables). +fn format_front_text( + graph: &ReductionGraph, + src_name: &str, + dst_name: &str, + front: &[(ReductionPath, GrowthLabel)], +) -> String { + let mut text = format!( + "Asymptotic Pareto front: {} path{} from {} to {}\n\ + (no --size given; each path shows its composed O(...) per {} size field)\n", + front.len(), + if front.len() == 1 { "" } else { "s" }, + src_name, + dst_name, + dst_name, + ); + for (idx, (reduction_path, label)) in front.iter().enumerate() { + text.push_str(&format!( + "\n--- {} ({} steps) ---\n{}\n", + crate::output::fmt_section(&format!("Path {}", idx + 1)), + reduction_path.len(), + path_arrow_summary(graph, reduction_path), + )); + for (field, growth) in label.fields() { + text.push_str(&format!(" {field} = {}\n", growth_big_o(growth))); + } + } + text +} + +/// JSON rendering of the asymptotic Pareto front. Growth is emitted both as the +/// structured `Growth` serialization (issue #1075) and as a rendered `O(...)` string. +fn format_front_json( + src_name: &str, + dst_name: &str, + front: &[(ReductionPath, GrowthLabel)], +) -> serde_json::Value { + let paths: Vec = front + .iter() + .map(|(reduction_path, label)| { + let big_o: BTreeMap<&str, String> = label + .fields() + .iter() + .map(|(f, g)| (*f, growth_big_o(g))) + .collect(); + serde_json::json!({ + "steps": reduction_path.len(), + "path": reduction_path.type_names(), + "growth": label.fields(), + "big_o": big_o, + }) + }) + .collect(); + serde_json::json!({ + "source": src_name, + "target": dst_name, + "mode": "asymptotic", + "front": paths, + }) +} + +/// Asymptotic Pareto-front mode of `pred path` (no `--size`/`--cost`): print the +/// front of asymptotically optimal reduction paths, each annotated with its composed +/// Big-O per target size field. See issue #1080 / design doc M3/F3a. +fn path_front( + graph: &ReductionGraph, + src_name: &str, + src_variant: &BTreeMap, + dst_name: &str, + dst_variant: &BTreeMap, + out: &OutputConfig, +) -> Result<()> { + let front = graph.asymptotic_front( + src_name, + src_variant, + dst_name, + dst_variant, + ReductionMode::Witness, + ); + + if front.is_empty() { + let variant_hint = variant_hint_for(graph, dst_name); + anyhow::bail!( + "No reduction path from {} to {}\n\ + {variant_hint}\n\ + Usage: pred path \n\ + Example: pred path MIS QUBO\n\n\ + Run `pred show {}` and `pred show {}` to check available reductions.", + src_name, + dst_name, + src_name, + dst_name, + ); + } + + let text = format_front_text(graph, src_name, dst_name, &front); + let json = format_front_json(src_name, dst_name, &front); + out.emit_with_default_name("", &text, &json) +} + pub fn path( source: &str, target: &str, - cost: &str, + cost: Option<&str>, all: bool, max_paths: usize, out: &OutputConfig, @@ -531,6 +658,20 @@ pub fn path( ); } + // No `--cost` (and no `--all`): run the instance-free asymptotic Pareto search and + // print the front of asymptotically optimal paths (issue #1080 / design M3/F3a). + // Passing `--cost` opts into the single-best scalar mode (unchanged from #1076). + let Some(cost) = cost else { + return path_front( + &graph, + &src_ref.name, + &src_ref.variant, + &dst_ref.name, + &dst_ref.variant, + out, + ); + }; + let input_size = ProblemSize::new(vec![]); // Parse cost function once (validate before the search loop) diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 702199e49..5dcec2850 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -65,7 +65,7 @@ fn main() -> anyhow::Result<()> { cost, all, max_paths, - } => commands::graph::path(&source, &target, &cost, all, max_paths, &out), + } => commands::graph::path(&source, &target, cost.as_deref(), all, max_paths, &out), Commands::ExportGraph => commands::graph::export(&out), Commands::Inspect(args) => commands::inspect::inspect(&args.input, &out), Commands::Create(args) => commands::create::create(&args, &out), diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index f03e93dda..65c6bf9cd 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -32,16 +32,31 @@ mod tests { #[test] fn test_find_path() { let server = McpServer::new(); - let result = server.find_path_inner("MIS", "QUBO", "minimize-steps", false, 20); + let result = server.find_path_inner("MIS", "QUBO", Some("minimize-steps"), false, 20); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert!(json["path"].as_array().unwrap().len() > 0); } + #[test] + fn test_find_path_asymptotic_front() { + // No `cost` and not `all` → the asymptotic Pareto front with structured Growth. + let server = McpServer::new(); + let result = server.find_path_inner("KSatisfiability", "QUBO", None, false, 20); + assert!(result.is_ok(), "err: {:?}", result.err()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["mode"], "asymptotic"); + let front = json["front"].as_array().unwrap(); + assert!(!front.is_empty()); + // Structured Growth serialization from issue #1075. + assert!(front[0]["growth"]["num_vars"]["Terms"].is_array()); + assert!(front[0]["big_o"]["num_vars"].is_string()); + } + #[test] fn test_find_path_all() { let server = McpServer::new(); - let result = server.find_path_inner("MIS", "QUBO", "minimize-steps", true, 20); + let result = server.find_path_inner("MIS", "QUBO", Some("minimize-steps"), true, 20); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); // --all returns a structured envelope @@ -54,7 +69,7 @@ mod tests { #[test] fn test_find_path_all_structured_response() { let server = McpServer::new(); - let result = server.find_path_inner("MIS", "QUBO", "minimize-steps", true, 20); + let result = server.find_path_inner("MIS", "QUBO", Some("minimize-steps"), true, 20); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); // Verify the structured envelope fields @@ -74,7 +89,8 @@ mod tests { fn test_find_path_no_route() { let server = McpServer::new(); // Pick two problems with no path (if any). Use an unknown problem to trigger an error. - let result = server.find_path_inner("NonExistent", "QUBO", "minimize-steps", false, 20); + let result = + server.find_path_inner("NonExistent", "QUBO", Some("minimize-steps"), false, 20); assert!(result.is_err()); } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index a0e2f1135..67c762de7 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -252,7 +252,7 @@ impl McpServer { &self, source: &str, target: &str, - cost: &str, + cost: Option<&str>, all: bool, max_paths: usize, ) -> anyhow::Result { @@ -260,6 +260,30 @@ impl McpServer { let src_ref = resolve_problem_ref(source, &graph)?; let dst_ref = resolve_problem_ref(target, &graph)?; + // No `cost` and not `all`: return the instance-free asymptotic Pareto front + // (issue #1080), using the structured `Growth` serialization from #1075. + if cost.is_none() && !all { + let front = graph.asymptotic_front( + &src_ref.name, + &src_ref.variant, + &dst_ref.name, + &dst_ref.variant, + ReductionMode::Witness, + ); + if front.is_empty() { + anyhow::bail!( + "No reduction path from {} to {}", + src_ref.name, + dst_ref.name + ); + } + return Ok(serde_json::to_string_pretty(&format_front_json( + &src_ref.name, + &dst_ref.name, + &front, + ))?); + } + if all { // Fetch one extra to detect truncation let mut all_paths = graph.find_paths_up_to( @@ -298,8 +322,9 @@ impl McpServer { return Ok(serde_json::to_string_pretty(&json)?); } - // Single best path + // Single best path (an explicit `cost` was given; `all` is handled above). let input_size = ProblemSize::new(vec![]); + let cost = cost.expect("cost is Some in the single-best branch"); let cost_field: Option = if cost == "minimize-steps" { None @@ -965,11 +990,16 @@ impl McpServer { annotations(read_only_hint = true, open_world_hint = false) )] fn find_path(&self, Parameters(params): Parameters) -> Result { - let cost = params.cost.as_deref().unwrap_or("minimize-steps"); let all = params.all.unwrap_or(false); let max_paths = params.max_paths.unwrap_or(20); - self.find_path_inner(¶ms.source, ¶ms.target, cost, all, max_paths) - .map_err(|e| e.to_string()) + self.find_path_inner( + ¶ms.source, + ¶ms.target, + params.cost.as_deref(), + all, + max_paths, + ) + .map_err(|e| e.to_string()) } /// Export the full reduction graph as JSON @@ -1137,6 +1167,47 @@ fn format_path_json( }) } +/// JSON rendering of the asymptotic Pareto front for the `find_path` tool. Each path +/// carries the structured `Growth` serialization (issue #1075) plus a rendered +/// `O(...)` string per target size field. `Unknown` growth renders `O(?)`. +fn format_front_json( + source: &str, + target: &str, + front: &[( + problemreductions::rules::ReductionPath, + problemreductions::rules::GrowthLabel, + )], +) -> serde_json::Value { + let paths: Vec = front + .iter() + .map(|(reduction_path, label)| { + let big_o: BTreeMap<&str, String> = label + .fields() + .iter() + .map(|(f, g)| { + let rendered = match g.to_expr() { + Some(e) => format!("O({e})"), + None => "O(?)".to_string(), + }; + (*f, rendered) + }) + .collect(); + serde_json::json!({ + "steps": reduction_path.len(), + "path": reduction_path.type_names(), + "growth": label.fields(), + "big_o": big_o, + }) + }) + .collect(); + serde_json::json!({ + "source": source, + "target": target, + "mode": "asymptotic", + "front": paths, + }) +} + // --------------------------------------------------------------------------- // Instance tool helpers // --------------------------------------------------------------------------- diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7bc3f386a..43611a25d 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -204,18 +204,81 @@ fn test_solve_balanced_complete_bipartite_subgraph_default_solver_uses_ilp() { #[test] fn test_path() { + // Bare `pred path` (no --cost / --size / --all) now prints the asymptotic Pareto + // front, each path annotated with O(...) per target size field. let output = pred().args(["path", "MIS", "QUBO"]).output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Asymptotic Pareto front"), "got: {stdout}"); assert!(stdout.contains("Path")); assert!(stdout.contains("step")); + assert!( + stdout.contains("O("), + "front should show Big-O per field, got: {stdout}" + ); +} + +/// Issue #1080 verification 1: `pred path KSatisfiability QUBO` (no `--size`) prints +/// ≥ 1 path, annotated with a normalized `O(...)` per QUBO size field, and the output +/// is byte-identical across two consecutive runs (determinism / golden behavior). +#[test] +fn test_path_asymptotic_front_deterministic() { + let run = || { + let output = pred() + .args(["path", "KSatisfiability", "QUBO"]) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + }; + let first = run(); + let second = run(); + assert_eq!( + first, second, + "asymptotic front output must be deterministic" + ); + + // At least one path, with a normalized Big-O for QUBO's `num_vars` size field. + assert!(first.contains("Asymptotic Pareto front")); + assert!(first.contains("--- Path 1")); + assert!( + first.contains("num_vars = O("), + "each path must annotate QUBO's num_vars with O(...), got: {first}" + ); + + // The JSON surface carries the structured Growth serialization (issue #1075). + let json_out = pred() + .args(["path", "KSatisfiability", "QUBO", "--json"]) + .output() + .unwrap(); + assert!(json_out.status.success()); + let json: serde_json::Value = + serde_json::from_str(&String::from_utf8(json_out.stdout).unwrap()).unwrap(); + assert_eq!(json["mode"], "asymptotic"); + let front = json["front"].as_array().expect("front array"); + assert!(!front.is_empty(), "front must have ≥ 1 path"); + assert!( + front[0]["growth"]["num_vars"]["Terms"].is_array(), + "growth must serialize as structured Terms, got: {}", + front[0]["growth"] + ); + assert!(front[0]["big_o"]["num_vars"].is_string()); } #[test] fn test_path_save() { let tmp = std::env::temp_dir().join("pred_test_path.json"); + // `--cost` selects the single-path save format (consumed by `reduce --via`). let output = pred() - .args(["path", "MIS", "QUBO", "-o", tmp.to_str().unwrap()]) + .args([ + "path", + "MIS", + "QUBO", + "--cost", + "minimize-steps", + "-o", + tmp.to_str().unwrap(), + ]) .output() .unwrap(); assert!(output.status.success()); @@ -1177,6 +1240,9 @@ fn test_reduce_via_path() { "path", "MIS/SimpleGraph/i32", "QUBO", + // A single concrete path (not the asymptotic front) for `reduce --via`. + "--cost", + "minimize-steps", "-o", path_file.to_str().unwrap(), ]) @@ -1241,6 +1307,9 @@ fn test_reduce_via_infer_target() { "path", "MIS/SimpleGraph/i32", "QUBO", + // A single concrete path (not the asymptotic front) for `reduce --via`. + "--cost", + "minimize-steps", "-o", path_file.to_str().unwrap(), ]) @@ -1300,6 +1369,9 @@ fn test_reduce_via_rejects_target_variant_mismatch() { "path", "MIS/SimpleGraph/i32", "ILP/bool", + // A single concrete path (not the asymptotic front) for `reduce --via`. + "--cost", + "minimize-steps", "-o", path_file.to_str().unwrap(), ]) @@ -4805,8 +4877,12 @@ fn test_path_unknown_cost() { #[test] fn test_path_overall_overhead_text() { - // Use a multi-step path so the "Overall" section appears - let output = pred().args(["path", "KSAT/K3", "MIS"]).output().unwrap(); + // Use a multi-step path so the "Overall" section appears. `--cost` selects the + // single-best mode (the asymptotic front default does not render "Overall"). + let output = pred() + .args(["path", "KSAT/K3", "MIS", "--cost", "minimize-steps"]) + .output() + .unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( @@ -4819,7 +4895,15 @@ fn test_path_overall_overhead_text() { fn test_path_overall_overhead_json() { let tmp = std::env::temp_dir().join("pred_test_path_overall.json"); let output = pred() - .args(["path", "KSAT/K3", "MIS", "-o", tmp.to_str().unwrap()]) + .args([ + "path", + "KSAT/K3", + "MIS", + "--cost", + "minimize-steps", + "-o", + tmp.to_str().unwrap(), + ]) .output() .unwrap(); assert!(output.status.success()); @@ -4847,7 +4931,15 @@ fn test_path_overall_overhead_composition() { // Step 2 (SAT→MIS): num_vertices = num_literals, num_edges = num_literals^2 // Overall: num_vertices = num_literals, num_edges = num_literals^2 let output = pred() - .args(["path", "KSAT/K3", "MIS", "-o", tmp.to_str().unwrap()]) + .args([ + "path", + "KSAT/K3", + "MIS", + "--cost", + "minimize-steps", + "-o", + tmp.to_str().unwrap(), + ]) .output() .unwrap(); assert!(output.status.success()); @@ -4932,7 +5024,7 @@ fn test_path_single_step_no_overall_text() { // Single-step path should NOT show the Overall section // MaxCut -> SpinGlass is a genuine 1-step path with matching default variants let output = pred() - .args(["path", "MaxCut", "SpinGlass"]) + .args(["path", "MaxCut", "SpinGlass", "--cost", "minimize-steps"]) .output() .unwrap(); assert!(output.status.success()); diff --git a/src/growth.rs b/src/growth.rs index 14621d816..a64d76d97 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -210,6 +210,17 @@ impl GrowthTerm { Some(Ordering::Greater) | Some(Ordering::Equal) ) } + + /// A monotone scalar summary of this monomial's growth rate. Exponential rate + /// dominates polynomial degree, which dominates log power. Bigger ⇒ grows + /// faster. Used only as a search-ordering / branch-and-bound heuristic, never + /// for asymptotic dominance decisions (those go through [`GrowthTerm::cmp`]). + fn magnitude(&self) -> f64 { + let e: f64 = self.exp.values().sum(); + let p: f64 = self.poly.values().sum(); + let l: f64 = self.logs.values().map(|&x| x as f64).sum(); + 1e6 * e + p + 1e-3 * l + } } /// Lexicographic comparison of `(exp rate, poly degree, log power)` triples. @@ -264,6 +275,20 @@ impl Growth { } } + /// A deterministic, monotone scalar summary of this growth class (the maximum + /// over its antichain terms). Exponential rate ≫ polynomial degree ≫ log + /// power; [`Growth::Unknown`] maps to a very large finite value so undecidable + /// growth sorts last. This is a *search-ordering* heuristic only (frontier + /// order, branch-and-bound bound); asymptotic dominance is decided exactly by + /// [`Growth::dominates`], never by this scalar. + pub fn magnitude(&self) -> f64 { + match self { + // Large but finite (and well below f64::MAX so sums stay finite). + Growth::Unknown => 1e18, + Growth::Terms(terms) => terms.iter().map(GrowthTerm::magnitude).fold(0.0, f64::max), + } + } + /// Render this growth class back to a display [`Expr`] (a sum of monomials), /// or `None` for [`Growth::Unknown`]. Terms are already in the deterministic /// sort order, so the rendered expression is platform-stable. diff --git a/src/lib.rs b/src/lib.rs index f77845aa9..4cad72e55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,10 +26,10 @@ pub mod error; pub mod example_db; pub mod export; pub(crate) mod expr; -// The growth domain backs `big_o_normal_form`; the search/analysis rewiring that -// consumes the rest of its API lands in later milestone issues. -#[allow(dead_code)] -pub(crate) mod growth; +// The growth domain backs `big_o_normal_form` (M2) and the asymptotic Pareto path +// search (`GrowthLabel`, M3/F3a). `Growth` is re-exported for CLI/MCP consumers that +// render or serialize the asymptotic front. +pub mod growth; pub mod io; pub mod models; pub mod registry; @@ -115,6 +115,7 @@ pub mod prelude { pub use big_o::big_o_normal_form; pub use error::{ProblemError, Result}; pub use expr::{AsymptoticAnalysisError, Expr}; +pub use growth::Growth; pub use registry::{ComplexityClass, ProblemInfo}; pub use solvers::{BruteForce, Solver}; pub use traits::Problem; diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 56ac164fe..802e7a44a 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -13,7 +13,9 @@ //! - JSON export for documentation and visualization use crate::rules::cost::PathCostFn; -use crate::rules::pareto::{CostLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, HOP_CAP}; +use crate::rules::pareto::{ + CostLabel, GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, HOP_CAP, +}; use crate::rules::registry::{ AggregateReduceFn, EdgeCapabilities, ReduceFn, ReductionEntry, ReductionOverhead, }; @@ -848,19 +850,22 @@ impl ReductionGraph { None => return vec![], }; - let paths: Vec> = all_simple_paths::< - Vec, - _, - std::hash::RandomState, - >(&self.graph, src, dst, 0, max_intermediate_nodes) + // Apply the mode filter *during* lazy enumeration, then take `limit`. Taking + // before filtering (the previous order) undercounts whenever an early simple + // path fails the mode check, which in turn made `--all` truncation detection + // depend on enumeration order. Filtering first yields up to `limit` genuinely + // usable paths and short-circuits once `limit` are found. + all_simple_paths::, _, std::hash::RandomState>( + &self.graph, + src, + dst, + 0, + max_intermediate_nodes, + ) + .filter(|p| self.node_path_supports_mode(p, mode)) .take(limit) - .collect(); - - paths - .iter() - .filter(|p| self.node_path_supports_mode(p, mode)) - .map(|p| self.node_path_to_reduction_path(p)) - .collect() + .map(|p| self.node_path_to_reduction_path(&p)) + .collect() } /// Check if a direct reduction exists from S to T. @@ -1813,6 +1818,49 @@ impl ReductionGraph { }) } + /// Compute the **asymptotic Pareto front** of reduction paths from `source` to + /// `target` — the instance-free path search (design doc M3/F3a). + /// + /// Runs the generic [Pareto label-setting search](Self::pareto_search) with the + /// [`GrowthLabel`] domain: no concrete instance is needed, and each returned path + /// carries its composed Big-O per target size field (in the source problem's size + /// variables), read off the returned label. Because asymptotic growth over several + /// size variables is a *partial* order, the answer is a front: possibly several + /// mutually incomparable optimal paths (one better in one size field, another in a + /// different one). Paths whose composed growth is [`Growth::Unknown`] (nonlinear + /// exponent, factorial) are still returned, with those fields marked `Unknown` — + /// never a fabricated bound. + /// + /// The front is ordered deterministically by (hops, lexicographic node names), so + /// the output is byte-identical across runs and platforms. Returns an empty vector + /// if either endpoint is unregistered or no path exists. + pub fn asymptotic_front( + &self, + source: &str, + source_variant: &BTreeMap, + target: &str, + target_variant: &BTreeMap, + mode: ReductionMode, + ) -> Vec<(ReductionPath, GrowthLabel)> { + let (Some(src), Some(dst)) = ( + self.lookup_node(source, source_variant), + self.lookup_node(target, target_variant), + ) else { + return vec![]; + }; + let source_fields = self.size_field_names(source); + let initial = GrowthLabel::source(&source_fields); + let mut front = self.pareto_search(src, dst, mode, initial, false); + // Re-order per the issue's contract: (hops, lexicographic node names). The + // kernel's own ordering leads with `cost()`, which is only a search heuristic. + front.sort_by(|a, b| { + a.0.len() + .cmp(&b.0.len()) + .then_with(|| a.0.type_names().cmp(&b.0.type_names())) + }); + front + } + /// Find the measured-smallest path from `source` to **any** variant of the target /// problem name `target`. /// diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 90f577207..d75bd3183 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -408,7 +408,8 @@ pub use graph::{ ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, }; pub use pareto::{ - CostLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, DEFAULT_SIZE_BUDGET, HOP_CAP, + CostLabel, GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, DEFAULT_SIZE_BUDGET, + HOP_CAP, }; pub use traits::{ AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 78106526c..755d58194 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -21,13 +21,15 @@ //! *actually executes* each reduction and measures the real constructed target size. //! Formulas are only used as a pre-flight guard, never to arbitrate between candidates. +use crate::expr::Expr; +use crate::growth::Growth; use crate::rules::cost::PathCostFn; use crate::rules::registry::{EdgeCapabilities, ReduceFn, ReductionOverhead}; use crate::rules::traits::DynReductionResult; use crate::types::ProblemSize; use std::any::Any; use std::cell::Cell; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::panic; use std::rc::Rc; use std::sync::Once; @@ -315,3 +317,149 @@ impl PathLabel for MeasuredLabel<'_> { self.size.total() as f64 } } + +/// Asymptotic, **instance-free** label domain (design doc M3/F3a). +/// +/// Each entry maps one size field of the **current** node to its +/// [`Growth`](crate::growth::Growth) expressed in the **source problem's** size +/// variables. The initial label at source `S` maps every one of `S`'s size fields +/// `f` to `Growth::from_expr(Var(f))` — "field `f` grows like itself". +/// +/// [`extend`](PathLabel::extend) composes an edge's overhead into the label: each +/// target size-field's overhead `Expr` is written over the *current* node's field +/// names, so we substitute each current field's rendered growth +/// ([`Growth::to_expr`](crate::growth::Growth::to_expr)) into it and run +/// [`Growth::from_expr`](crate::growth::Growth::from_expr) on the result. This reuses +/// the whole M1+M2 growth pipeline and needs no new growth-domain primitive. A field +/// whose growth is [`Growth::Unknown`](crate::growth::Growth::Unknown) (nonlinear +/// exponent, factorial) has no `Expr`; any target field depending on it becomes +/// `Unknown` too — the bound is never fabricated. +/// +/// [`dominates`](PathLabel::dominates) is componentwise in the **search** sense +/// (smaller growth = better): `self` dominates `other` iff for *every* field `self` +/// grows no faster than `other`, and strictly slower on at least one. Because +/// `Unknown` is the top of the growth order, a label with an `Unknown` field is +/// dominated by any fully-known label — undecidable paths rank last, the honest +/// ranking. +/// +/// **Isotonicity** (the correctness condition for the kernel's dominance pruning) +/// follows from the growth domain's monotonicity axiom: `from_expr` composed with +/// substitution into weakly-monotone overhead expressions preserves the growth +/// order, so `A ⪰ B ⇒ extend(A,e) ⪰ extend(B,e)`. +#[derive(Clone, Debug, PartialEq)] +pub struct GrowthLabel { + /// Current node's size fields → growth in the source problem's variables. + fields: BTreeMap<&'static str, Growth>, +} + +impl GrowthLabel { + /// The initial label at a source node: each size field grows like itself. + /// + /// `source_fields` is the source problem's list of size-field names (e.g. from + /// [`ReductionGraph::size_field_names`](crate::rules::ReductionGraph::size_field_names)). + pub fn source(source_fields: &[&'static str]) -> Self { + let fields = source_fields + .iter() + .map(|&f| (f, Growth::from_expr(&Expr::Var(f)))) + .collect(); + GrowthLabel { fields } + } + + /// Construct directly from a field → growth map (test/introspection helper). + pub fn from_fields(fields: BTreeMap<&'static str, Growth>) -> Self { + GrowthLabel { fields } + } + + /// The current node's size fields mapped to their growth in source variables. + pub fn fields(&self) -> &BTreeMap<&'static str, Growth> { + &self.fields + } +} + +impl PathLabel for GrowthLabel { + fn extend(&self, edge: &ReductionEdge) -> Option { + // Render each current field's growth back to a display `Expr` in the source + // variables. `Unknown` growth has no `Expr` (`None`) and taints any target + // field that references it. + let rendered: BTreeMap<&'static str, Option> = + self.fields.iter().map(|(k, g)| (*k, g.to_expr())).collect(); + + let mut new_fields: BTreeMap<&'static str, Growth> = BTreeMap::new(); + for (target_field, expr) in &edge.overhead.output_size { + // If this overhead references a current field whose growth is `Unknown`, + // we cannot honestly bound the target field: propagate `Unknown`. + let taints = expr + .variables() + .iter() + .any(|v| matches!(rendered.get(v), Some(None))); + if taints { + new_fields.insert(target_field, Growth::Unknown); + continue; + } + // Substitute each current field name with its rendered growth (in source + // variables), then reduce in the growth domain. Overhead variables not in + // the label pass through unchanged (mirrors `ReductionOverhead::compose`). + let mapping: HashMap<&str, &Expr> = rendered + .iter() + .filter_map(|(k, opt)| opt.as_ref().map(|e| (*k, e))) + .collect(); + let substituted = expr.substitute(&mapping); + new_fields.insert(target_field, Growth::from_expr(&substituted)); + } + // Asymptotic mode has no budget, so `extend` never prunes. + Some(GrowthLabel { fields: new_fields }) + } + + fn dominates(&self, other: &Self) -> bool { + // Search-sense componentwise dominance over the union of fields (labels + // compared are at the same node, so their field sets coincide; the union is + // defensive). `self` dominates `other` iff `self` grows no faster on every + // field and strictly slower on at least one. + // + // `Growth::dominates(a, b)` means "a grows ≥ b", with `Unknown` as top. So: + // self ≤ other on field f ⟺ other_f.dominates(self_f) + // and self is strictly better on f iff additionally NOT self_f.dominates(other_f). + let o1 = Growth::Terms(Vec::new()); // O(1): the bottom, for absent fields. + let keys: BTreeSet<&'static str> = self + .fields + .keys() + .chain(other.fields.keys()) + .copied() + .collect(); + let mut strict = false; + for k in keys { + let s = self.fields.get(k).unwrap_or(&o1); + let o = other.fields.get(k).unwrap_or(&o1); + if !o.dominates(s) { + // self grows strictly faster than other here → self does not dominate. + return false; + } + if !s.dominates(o) { + // other ≥ self but self ⋡ other ⇒ self strictly slower on this field. + strict = true; + } + } + strict + } + + fn cost(&self) -> f64 { + // Monotone scalar summary for frontier ordering / branch-and-bound. Not used + // for dominance (that is the exact partial order above). Summed over fields so + // a path that inflates any field ranks higher; `Unknown` fields dominate the + // sum, ranking undecidable paths last. + // + // The kernel's branch-and-bound compares this scalar with `>=`, which would + // collapse two *incomparable* front members whose raw magnitudes happen to be + // equal (e.g. `O(n^2)`/`O(m)` vs `O(n)`/`O(m^2)`). To keep such genuinely + // distinct front members separable, later-sorted fields get an infinitesimal + // extra weight, giving tied-magnitude labels distinct costs. This is a + // deterministic, monotone perturbation (ε ≪ any real magnitude gap), so it can + // only *preserve* front members, never prune one the raw magnitude would keep. + const EPS: f64 = 1e-9; + self.fields + .values() + .enumerate() + .map(|(i, g)| g.magnitude() * (1.0 + (i as f64) * EPS)) + .sum() + } +} diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index bd21f6294..1c0c166e1 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -7,14 +7,16 @@ use super::*; use crate::expr::Expr; +use crate::growth::Growth; use crate::models::graph::{HamiltonianCircuit, HighlyConnectedDeletion}; use crate::rules::cost::CustomCost; -use crate::rules::pareto::{PathLabel, ReductionEdge}; +use crate::rules::pareto::{GrowthLabel, PathLabel, ReductionEdge}; use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; use crate::rules::{ReductionGraph, ReductionMode, DEFAULT_SIZE_BUDGET}; use crate::topology::SimpleGraph; use crate::types::ProblemSize; use std::any::Any; +use std::collections::BTreeMap; use std::time::Instant; // --------------------------------------------------------------------------- @@ -285,3 +287,287 @@ fn test_diamond_exhaustive_matches_pruned() { assert_eq!(front[0].0.type_names(), vec!["S", "P", "M", "T"]); assert_eq!(front[0].1.cost(), 6.0); } + +// --------------------------------------------------------------------------- +// GrowthLabel (asymptotic, instance-free) domain — issue #1080 / design M3/F3a. +// --------------------------------------------------------------------------- + +/// A power `Var(v)^k`. +fn powk(v: &'static str, k: f64) -> Expr { + Expr::pow(Expr::Var(v), Expr::Const(k)) +} + +/// A test edge carrying only a symbolic overhead (target field → Expr over the +/// current node's fields), no executable reduction. +fn growth_edge(fields: Vec<(&'static str, Expr)>) -> ReductionEdgeData { + ReductionEdgeData { + overhead: ReductionOverhead::new(fields), + reduce_fn: None, + reduce_aggregate_fn: None, + capabilities: EdgeCapabilities::witness_only(), + } +} + +/// The rendered Big-O string for one field of a growth label (or `"?"` for +/// `Unknown`), for compact assertions. +fn field_big_o(label: &GrowthLabel, field: &str) -> String { + match label.fields().get(field) { + Some(g) => match g.to_expr() { + Some(e) => e.to_string(), + None => "?".to_string(), + }, + None => "".to_string(), + } +} + +/// `extend` substitutes the current label's growth into an edge's overhead and +/// reduces in the growth domain, yielding the target field's growth in source vars. +#[test] +fn test_growth_label_extend_composes_overhead() { + // Source S has fields n, m; edge maps a = n^2, b = m (in the source's variables). + let edge_data = growth_edge(vec![("a", powk("n", 2.0)), ("b", Expr::Var("m"))]); + let target_variant = BTreeMap::new(); + let redge = ReductionEdge { + overhead: &edge_data.overhead, + reduce_fn: None, + capabilities: EdgeCapabilities::witness_only(), + target_name: "Target", + target_variant: &target_variant, + }; + + let initial = GrowthLabel::source(&["n", "m"]); + let next = initial + .extend(&redge) + .expect("asymptotic extend never prunes"); + assert_eq!(field_big_o(&next, "a"), "n^2"); + assert_eq!(field_big_o(&next, "b"), "m"); + + // A second hop composes: c = a * b substitutes a→n^2, b→m ⇒ n^2 * m. + let edge2 = growth_edge(vec![("c", Expr::Var("a") * Expr::Var("b"))]); + let redge2 = ReductionEdge { + overhead: &edge2.overhead, + reduce_fn: None, + capabilities: EdgeCapabilities::witness_only(), + target_name: "Target2", + target_variant: &target_variant, + }; + let composed = next.extend(&redge2).expect("extend"); + assert_eq!(field_big_o(&composed, "c"), "m * n^2"); +} + +/// An overhead field that depends on an `Unknown`-growth current field stays +/// `Unknown` — the bound is never fabricated. +#[test] +fn test_growth_label_propagates_unknown() { + // Build a label whose field `x` is Unknown (factorial growth). + let mut fields = BTreeMap::new(); + fields.insert( + "x", + Growth::from_expr(&Expr::Factorial(Box::new(Expr::Var("n")))), + ); + fields.insert("y", Growth::from_expr(&Expr::Var("n"))); + let label = GrowthLabel::from_fields(fields); + assert!(matches!(label.fields().get("x"), Some(Growth::Unknown))); + + // out1 uses x (Unknown) → Unknown; out2 uses only y → bounded. + let edge = growth_edge(vec![ + ("out1", Expr::Var("x") * Expr::Var("y")), + ("out2", powk("y", 2.0)), + ]); + let tv = BTreeMap::new(); + let redge = ReductionEdge { + overhead: &edge.overhead, + reduce_fn: None, + capabilities: EdgeCapabilities::witness_only(), + target_name: "T", + target_variant: &tv, + }; + let next = label.extend(&redge).expect("extend"); + assert_eq!(field_big_o(&next, "out1"), "?"); + assert_eq!(field_big_o(&next, "out2"), "n^2"); +} + +/// A label with an `Unknown` field is dominated by any fully-known label, and never +/// dominates one — undecidable paths rank last. +#[test] +fn test_growth_label_unknown_ranks_last() { + let known = GrowthLabel::from_fields({ + let mut m = BTreeMap::new(); + m.insert("a", Growth::from_expr(&powk("n", 2.0))); + m.insert("b", Growth::from_expr(&Expr::Var("m"))); + m + }); + let with_unknown = GrowthLabel::from_fields({ + let mut m = BTreeMap::new(); + m.insert("a", Growth::from_expr(&powk("n", 2.0))); + m.insert("b", Growth::Unknown); + m + }); + // Known is strictly better on field b (n^0? no: bounded vs Unknown) ⇒ known dominates. + assert!(known.dominates(&with_unknown)); + assert!(!with_unknown.dominates(&known)); +} + +/// Componentwise search-sense dominance: `self` dominates `other` iff it grows no +/// faster on every field and strictly slower on at least one. +#[test] +fn test_growth_label_dominance_partial_order() { + let a = GrowthLabel::from_fields({ + let mut m = BTreeMap::new(); + m.insert("v", Growth::from_expr(&Expr::Var("n"))); // n + m.insert("e", Growth::from_expr(&Expr::Var("m"))); // m + m + }); + let b = GrowthLabel::from_fields({ + let mut m = BTreeMap::new(); + m.insert("v", Growth::from_expr(&powk("n", 2.0))); // n^2 + m.insert("e", Growth::from_expr(&Expr::Var("m"))); // m + m + }); + // a (n, m) grows slower in v, equal in e ⇒ a dominates b; b does not dominate a. + assert!(a.dominates(&b)); + assert!(!b.dominates(&a)); + // Reflexivity is *not* strict dominance: equal labels do not dominate each other. + assert!(!a.dominates(&a.clone())); + + // Incomparable pair: one better in v, the other better in e. + let c = GrowthLabel::from_fields({ + let mut m = BTreeMap::new(); + m.insert("v", Growth::from_expr(&powk("n", 2.0))); // n^2 + m.insert("e", Growth::from_expr(&Expr::Var("m"))); // m + m + }); + let d = GrowthLabel::from_fields({ + let mut m = BTreeMap::new(); + m.insert("v", Growth::from_expr(&Expr::Var("n"))); // n + m.insert("e", Growth::from_expr(&powk("m", 2.0))); // m^2 + m + }); + assert!(!c.dominates(&d)); + assert!(!d.dominates(&c)); +} + +/// **Negative control (issue #1080):** two S→T paths whose composed growths are +/// incomparable — path A costs `O(n^2)` in `vertices` / `O(m)` in `edges`, path B +/// costs `O(n)` / `O(m^2)` — must *both* appear in the asymptotic Pareto front. An +/// implementation that scalarizes or keeps a single representative fails this. +#[test] +fn test_growth_negative_control_incomparable_front() { + let empty = BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "A", "B", "T"], + &[ + // Both prefixes just carry the source fields n, m through unchanged. + ( + "S", + "A", + growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + ), + ( + "S", + "B", + growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + ), + // Path A: vertices = n^2, edges = m. + ( + "A", + "T", + growth_edge(vec![ + ("vertices", powk("n", 2.0)), + ("edges", Expr::Var("m")), + ]), + ), + // Path B: vertices = n, edges = m^2. + ( + "B", + "T", + growth_edge(vec![ + ("vertices", Expr::Var("n")), + ("edges", powk("m", 2.0)), + ]), + ), + ], + ); + + let initial = GrowthLabel::source(&["n", "m"]); + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + false, + ); + + // The front must contain BOTH incomparable paths — not one representative. + assert_eq!( + front.len(), + 2, + "front should keep both incomparable paths, got {:?}", + front + .iter() + .map(|(p, _)| p.type_names()) + .collect::>() + ); + let mut seen: Vec<(String, String)> = front + .iter() + .map(|(p, label)| { + ( + p.type_names().join("→"), + format!( + "v={} e={}", + field_big_o(label, "vertices"), + field_big_o(label, "edges") + ), + ) + }) + .collect(); + seen.sort(); + assert_eq!( + seen, + vec![ + ("S→A→T".to_string(), "v=n^2 e=m".to_string()), + ("S→B→T".to_string(), "v=n e=m^2".to_string()), + ], + ); +} + +/// Isotonicity of `extend` (design invariant): if `A` dominates `B`, then +/// `extend(A, e)` dominates `extend(B, e)` for the same edge — the correctness +/// condition for the kernel's dominance pruning. +#[test] +fn test_growth_label_extend_isotone() { + // A = (n, m) dominates B = (n^2, m^2) componentwise. + let a = GrowthLabel::source(&["n", "m"]); + let b = GrowthLabel::from_fields({ + let mut mm = BTreeMap::new(); + mm.insert("n", Growth::from_expr(&powk("n", 2.0))); + mm.insert("m", Growth::from_expr(&powk("m", 2.0))); + mm + }); + assert!(a.dominates(&b)); + + let tv = BTreeMap::new(); + // A monotone overhead in both fields. + for overhead in [ + growth_edge(vec![("x", Expr::Var("n") * Expr::Var("m"))]), + growth_edge(vec![("x", powk("n", 3.0)), ("y", Expr::Var("m"))]), + ] { + let redge = ReductionEdge { + overhead: &overhead.overhead, + reduce_fn: None, + capabilities: EdgeCapabilities::witness_only(), + target_name: "T", + target_variant: &tv, + }; + let ea = a.extend(&redge).unwrap(); + let eb = b.extend(&redge).unwrap(); + // A ⪰ B ⇒ extend(A) ⪰ extend(B) (dominates-or-equal). Equality is possible + // when the overhead collapses the difference, so accept dominate-or-equal. + assert!( + ea.dominates(&eb) || ea == eb, + "isotonicity violated: {ea:?} vs {eb:?}" + ); + } +} From 8944ae8feea4e2ff55aaffb9d32781384254973a Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 19:55:09 +0800 Subject: [PATCH 05/31] Fix asymptotic Pareto front completeness: opt out of scalar B&B (#1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared Pareto kernel prunes any label whose scalar `cost()` already meets the best completed path's cost (branch-and-bound). That is sound for the scalar measured/formula labels, but WRONG for the asymptotic `GrowthLabel`: growth over multiple size fields is a partial order, so a scalar summary can rank one genuinely incomparable Pareto-optimal path above another and prune it — silently under-reporting the front, which is the exact thing this feature must not do. The previous mitigation (an epsilon tie-break in `GrowthLabel::cost`) only rescued the *equal-magnitude* case; asymmetric incomparable fronts (e.g. one path O(n^2)/O(m), another O(n)/O(m^3), magnitudes 3 vs 4) still lost the larger one whenever the smaller completed first. Root-cause fix: add `PathLabel::BRANCH_AND_BOUND` (default true; false for `GrowthLabel`) and gate the kernel's two B&B checks on it. The asymptotic search now relies solely on the exact `dominates` partial-order pruning (plus the hop and bag caps), so incomparable paths always survive. Removed the epsilon crutch from `cost()`. New test `test_growth_asymmetric_incomparable_front_complete` pins the asymmetric case; verified it fails with B&B re-enabled (front drops path B) and passes with the fix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- src/rules/graph.rs | 11 +++-- src/rules/pareto.rs | 46 +++++++++++--------- src/unit_tests/rules/pareto.rs | 78 ++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 802e7a44a..868c4c49c 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -564,8 +564,10 @@ impl ReductionGraph { continue; } // Branch-and-bound: a label already at least as costly as the best completed - // path cannot yield a cheaper destination (cost is non-decreasing). - if best_final.is_some_and(|bf| cost.0 >= bf) { + // path cannot yield a cheaper destination (cost is non-decreasing). Sound + // only for scalar objectives; the asymptotic partial order opts out (see + // `PathLabel::BRANCH_AND_BOUND`). + if L::BRANCH_AND_BOUND && best_final.is_some_and(|bf| cost.0 >= bf) { continue; } @@ -597,8 +599,9 @@ impl ReductionGraph { continue; }; let new_cost = new_label.cost(); - // Branch-and-bound against the best completed path. - if best_final.is_some_and(|bf| new_cost >= bf) { + // Branch-and-bound against the best completed path (scalar objectives + // only; the asymptotic partial order opts out). + if L::BRANCH_AND_BOUND && best_final.is_some_and(|bf| new_cost >= bf) { continue; } // Componentwise dominance against the target's bag. diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 755d58194..0036ef624 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -121,10 +121,22 @@ pub trait PathLabel: Clone { /// node's bag an antichain. fn dominates(&self, other: &Self) -> bool; - /// Scalar summary used for branch-and-bound pruning, frontier ordering, and the - /// deterministic final tie-break. Smaller is better. Must be non-decreasing along - /// `extend` (see trait docs). + /// Scalar summary used for frontier ordering, the deterministic final tie-break, + /// and (when [`BRANCH_AND_BOUND`](PathLabel::BRANCH_AND_BOUND) is set) branch-and- + /// bound pruning. Smaller is better. Must be non-decreasing along `extend`. fn cost(&self) -> f64; + + /// Whether scalar branch-and-bound pruning — discarding a label whose `cost` + /// already meets or exceeds the best completed path's `cost` — is sound for this + /// label. + /// + /// `true` (default) for scalar objectives (measured size, formula cost), where + /// `cost` *is* the objective. `false` for the partial-order asymptotic label: + /// there `cost` is only a heuristic summary of a multi-field growth vector, so + /// pruning by it would drop genuinely *incomparable* Pareto-optimal paths (one + /// cheaper in `num_vertices`, another in `num_edges`). Such labels rely on + /// [`dominates`](PathLabel::dominates) pruning alone, which is exact. + const BRANCH_AND_BOUND: bool = true; } /// Formula-based scalar label reproducing Dijkstra behavior for a [`PathCostFn`]. @@ -442,24 +454,16 @@ impl PathLabel for GrowthLabel { strict } + // Asymptotic growth is a partial order, so a scalar `cost` can never separate + // incomparable front members; branch-and-bound on it would drop them. Disable it + // and rely on the exact `dominates` pruning above. + const BRANCH_AND_BOUND: bool = false; + fn cost(&self) -> f64 { - // Monotone scalar summary for frontier ordering / branch-and-bound. Not used - // for dominance (that is the exact partial order above). Summed over fields so - // a path that inflates any field ranks higher; `Unknown` fields dominate the - // sum, ranking undecidable paths last. - // - // The kernel's branch-and-bound compares this scalar with `>=`, which would - // collapse two *incomparable* front members whose raw magnitudes happen to be - // equal (e.g. `O(n^2)`/`O(m)` vs `O(n)`/`O(m^2)`). To keep such genuinely - // distinct front members separable, later-sorted fields get an infinitesimal - // extra weight, giving tied-magnitude labels distinct costs. This is a - // deterministic, monotone perturbation (ε ≪ any real magnitude gap), so it can - // only *preserve* front members, never prune one the raw magnitude would keep. - const EPS: f64 = 1e-9; - self.fields - .values() - .enumerate() - .map(|(i, g)| g.magnitude() * (1.0 + (i as f64) * EPS)) - .sum() + // Heuristic scalar summary for frontier ordering and the deterministic final + // tie-break ONLY — never for pruning (see `BRANCH_AND_BOUND` above; dominance + // is the exact partial order). Summed field magnitudes; `Unknown` fields + // dominate the sum, ranking undecidable paths last. + self.fields.values().map(|g| g.magnitude()).sum() } } diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 1c0c166e1..88807e776 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -533,6 +533,84 @@ fn test_growth_negative_control_incomparable_front() { ); } +// Completeness under ASYMMETRIC magnitudes: the two incomparable paths have +// different scalar `cost` summaries (A: n^2 + m ⇒ magnitude 3; B: n + m^3 ⇒ +// magnitude 4). Scalar branch-and-bound would let the cheaper path A complete first +// and then prune B (cost 4 ≥ 3), silently dropping a Pareto-optimal path. This is +// the case the equal-magnitude negative control above does NOT catch; it passes only +// because `GrowthLabel` opts out of branch-and-bound (`BRANCH_AND_BOUND = false`) and +// relies on exact dominance pruning. +#[test] +fn test_growth_asymmetric_incomparable_front_complete() { + let empty = BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "A", "B", "T"], + &[ + ( + "S", + "A", + growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + ), + ( + "S", + "B", + growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + ), + // Path A: vertices = n^2, edges = m (magnitude 2 + 1 = 3). + ( + "A", + "T", + growth_edge(vec![ + ("vertices", powk("n", 2.0)), + ("edges", Expr::Var("m")), + ]), + ), + // Path B: vertices = n, edges = m^3 (magnitude 1 + 3 = 4). + ( + "B", + "T", + growth_edge(vec![ + ("vertices", Expr::Var("n")), + ("edges", powk("m", 3.0)), + ]), + ), + ], + ); + + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + GrowthLabel::source(&["n", "m"]), + false, + ); + + let mut seen: Vec<(String, String)> = front + .iter() + .map(|(p, label)| { + ( + p.type_names().join("→"), + format!( + "v={} e={}", + field_big_o(label, "vertices"), + field_big_o(label, "edges") + ), + ) + }) + .collect(); + seen.sort(); + assert_eq!( + seen, + vec![ + ("S→A→T".to_string(), "v=n^2 e=m".to_string()), + ("S→B→T".to_string(), "v=n e=m^3".to_string()), + ], + "both incomparable paths must survive despite different scalar magnitudes", + ); +} + /// Isotonicity of `extend` (design invariant): if `A` dominates `B`, then /// `extend(A, e)` dominates `extend(B, e)` for the same edge — the correctness /// condition for the kernel's dominance pruning. From ad2c050a4620d35729f3f73def7a5d8934afa7a6 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 20:15:28 +0800 Subject: [PATCH 06/31] Dedup asymptotic front to one path per distinct growth vector (#1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After removing the scalar branch-and-bound from `GrowthLabel` search (8944ae8f), the asymptotic front over-reported: `GrowthLabel::dominates` requires a strictly-better field, so two paths with *equal* growth vectors never prune each other, and the front accumulated every redundant route (e.g. `pred path MVC ILP` printed 32 paths, most with identical Big-O — only ~3 distinct growth profiles among them). Fix: `ReductionGraph::asymptotic_front` now collapses the front to one representative per distinct growth vector. `GrowthLabel` derives `PartialEq` over its field → growth map, so the front is sorted by (hops, lexicographic node names) — putting each equal-growth group's deterministic best first — then linearly deduplicated keeping the first (best) of each group. Deduplication is purely by the growth vector, so paths reaching different target variants (e.g. `ILP/bool` vs `ILP/i32`) with the same composed Big-O collapse to a single representative — the endpoint variant is not part of the asymptotic identity. Documented on the method. Completeness is preserved: genuinely incomparable growth vectors are never equal, so they all survive (both `test_growth_asymmetric_incomparable_front_complete` and `test_growth_negative_control_incomparable_front` still pass, using the raw kernel). `pred path MVC ILP` now prints 3 paths (one per distinct Big-O profile). Tests: `test_asymptotic_front_dedups_by_growth_vector` (real graph: no duplicate growth vectors, ≤ 4 entries, and the raw kernel front is strictly larger — proving collapse) and `test_path_front_dedups_by_growth_vector` (CLI: MVC → ILP small handful, no dup vectors). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- problemreductions-cli/tests/cli_tests.rs | 33 ++++++++++++ src/rules/graph.rs | 31 +++++++++-- src/unit_tests/rules/pareto.rs | 65 ++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 43611a25d..2bfecc338 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -265,6 +265,39 @@ fn test_path_asymptotic_front_deterministic() { assert!(front[0]["big_o"]["num_vars"].is_string()); } +/// The asymptotic front reports one path per distinct growth vector, not per route. +/// `MVC → ILP` has dozens of reduction chains that compose to only a few Big-O +/// profiles; the front must collapse to that small handful with no duplicate growth +/// vectors. (Regression: before dedup this printed 32 paths, most identical.) +#[test] +fn test_path_front_dedups_by_growth_vector() { + let output = pred() + .args(["path", "MVC", "ILP", "--json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: serde_json::Value = + serde_json::from_str(&String::from_utf8(output.stdout).unwrap()).unwrap(); + let front = json["front"].as_array().expect("front array"); + + // A proper Pareto front is a small handful (issue #1080: "typically 1–3 paths"). + assert!( + (1..=4).contains(&front.len()), + "expected 1..=4 distinct growth vectors, got {}", + front.len() + ); + // No two entries share a growth vector (the Big-O per size field). + let vectors: Vec = front.iter().map(|p| p["big_o"].to_string()).collect(); + let mut unique = vectors.clone(); + unique.sort(); + unique.dedup(); + assert_eq!( + unique.len(), + vectors.len(), + "front must not contain two entries with identical growth vectors: {vectors:?}" + ); +} + #[test] fn test_path_save() { let tmp = std::env::temp_dir().join("pred_test_path.json"); diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 868c4c49c..d478b833e 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -1834,6 +1834,19 @@ impl ReductionGraph { /// exponent, factorial) are still returned, with those fields marked `Unknown` — /// never a fabricated bound. /// + /// The front reports **one representative path per distinct growth vector**: the + /// asymptotic front is a Pareto set over *growth vectors*, not routes. Many + /// syntactically different reduction chains compose to the exact same Big-O per size + /// field (e.g. dozens of `MinimumVertexCover → … → ILP` routes all yield + /// `num_constraints = O(num_edges), num_vars = O(num_vertices)`); reporting each + /// route would drown the ~1–3 genuinely distinct trade-offs the user cares about. + /// So equal-growth paths are deduplicated ([`GrowthLabel`] derives `PartialEq`), + /// keeping the deterministic best per group: fewest hops, then lexicographic + /// node-name path. Deduplication is purely by the growth vector, so two paths that + /// reach *different* target variants (e.g. `ILP/bool` vs `ILP/i32`) with the same + /// composed Big-O collapse to a single representative — the endpoint variant is not + /// part of the asymptotic identity. + /// /// The front is ordered deterministically by (hops, lexicographic node names), so /// the output is byte-identical across runs and platforms. Returns an empty vector /// if either endpoint is unregistered or no path exists. @@ -1854,14 +1867,26 @@ impl ReductionGraph { let source_fields = self.size_field_names(source); let initial = GrowthLabel::source(&source_fields); let mut front = self.pareto_search(src, dst, mode, initial, false); - // Re-order per the issue's contract: (hops, lexicographic node names). The - // kernel's own ordering leads with `cost()`, which is only a search heuristic. + // Order per the issue's contract: (hops, lexicographic node names). The kernel's + // own ordering leads with `cost()`, which is only a search heuristic. Sorting + // first also puts the deterministic best route of each equal-growth group ahead + // of its duplicates, so the dedup below keeps the right representative. front.sort_by(|a, b| { a.0.len() .cmp(&b.0.len()) .then_with(|| a.0.type_names().cmp(&b.0.type_names())) }); - front + // Collapse to one representative per distinct growth vector. `GrowthLabel`'s + // `PartialEq` compares the field → growth map, i.e. the composed Big-O per size + // field; genuinely incomparable vectors are never equal, so they all survive. + // O(n^2), but a front is a handful of entries. + let mut deduped: Vec<(ReductionPath, GrowthLabel)> = Vec::new(); + for entry in front { + if !deduped.iter().any(|(_, label)| *label == entry.1) { + deduped.push(entry); + } + } + deduped } /// Find the measured-smallest path from `source` to **any** variant of the target diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 88807e776..d41e65de8 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -649,3 +649,68 @@ fn test_growth_label_extend_isotone() { ); } } + +/// `asymptotic_front` reports **one representative per distinct growth vector**, not +/// one per route. On the real graph, `MinimumVertexCover → ILP` has dozens of +/// syntactically distinct reduction chains that compose to only a handful of Big-O +/// profiles; the front must (a) contain no two entries with identical growth vectors +/// and (b) collapse to that small handful — while the raw kernel front (same search, +/// no dedup) still holds the many redundant routes. +#[test] +fn test_asymptotic_front_dedups_by_growth_vector() { + let graph = ReductionGraph::new(); + let src_v = graph + .default_variant_for("MinimumVertexCover") + .or_else(|| graph.variants_for("MinimumVertexCover").into_iter().next()) + .expect("MinimumVertexCover registered"); + let dst_v = graph + .default_variant_for("ILP") + .or_else(|| graph.variants_for("ILP").into_iter().next()) + .expect("ILP registered"); + + let front = graph.asymptotic_front( + "MinimumVertexCover", + &src_v, + "ILP", + &dst_v, + ReductionMode::Witness, + ); + assert!(!front.is_empty(), "MVC -> ILP must have a path"); + + // (a) No two front entries share a growth vector (GrowthLabel PartialEq). + for i in 0..front.len() { + for j in (i + 1)..front.len() { + assert!( + front[i].1 != front[j].1, + "duplicate growth vector in front:\n {}\n {}", + front[i].0.type_names().join("→"), + front[j].0.type_names().join("→"), + ); + } + } + // (b) A proper Pareto front is a small handful, not the dozens of redundant routes. + assert!( + (1..=4).contains(&front.len()), + "expected 1..=4 distinct growth vectors, got {}", + front.len() + ); + + // The dedup genuinely collapsed routes: the raw kernel front (same search, no + // dedup) is strictly larger and does contain repeated growth vectors. + let src_fields = graph.size_field_names("MinimumVertexCover"); + let raw = graph.pareto_search_by_name( + "MinimumVertexCover", + &src_v, + "ILP", + &dst_v, + ReductionMode::Witness, + GrowthLabel::source(&src_fields), + false, + ); + assert!( + raw.len() > front.len(), + "dedup should collapse redundant routes: raw {} vs deduped {}", + raw.len(), + front.len() + ); +} From 015b1c6e5a07577d64f3a20a048d9efeb525f58d Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 20:39:07 +0800 Subject: [PATCH 07/31] Fix ILP i32->bool cast overhead: use size-field name num_vars, not getter alias (#1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the `pred path MinimumFeedbackVertexSet ILP` bug (asymptotic `num_vars` composed to `O(num_variables)` instead of `O(num_vertices)`): the `ILP → ILP` binary-encoding cast declared its overhead as `num_vars = "31 * num_variables"`. ILP's size field is named `num_vars`; `num_variables()` is only a getter *alias* for it. The `#[reduction]` macro validates overhead variables against getter *methods*, so the alias compiled, and both instance-mode sizing (`evaluate_output_size` calls the getter) and raw-overhead Big-O rendering resolve it — the mistake was invisible there. But asymptotic growth composition (`GrowthLabel::extend`) threads size-field *names*: the label key at the ILP node is `num_vars`, so the variable `num_variables` was unmapped and leaked through unchanged as `num_vars = O(num_variables)`. The path's arrow display had also masked this by collapsing the hidden `ILP/i32 → ILP/bool` cast step. Fix: `31 * num_variables` → `31 * num_vars` (semantically identical — 31 bits per integer variable — and numerically unchanged, since both getters return `num_vars`). Effects: `MFVS → ILP` now composes `num_vars = O(num_vertices)`. `MVC → ILP` drops from 3 to 2 front paths — the corrected feedback-vertex-set route (`num_constraints = O(num_edges + num_vertices), num_vars = O(num_vertices)`) is now correctly Pareto-dominated by the direct route and pruned. Test: `test_asymptotic_front_uses_only_source_variables_mfvs_ilp` pins `num_vars = O(num_vertices)` and asserts every composed field's growth references only MinimumFeedbackVertexSet's own size variables — a general "source-variables-only" invariant on the front label. Note: this is one instance of a getter-alias-vs-field-name mismatch. A separate, broader class of leaks remains in other reductions (e.g. `ClosestVectorProblem`'s `num_encoding_bits` and `CircuitSAT`'s `tseitin_num_vars`/`tseitin_num_clauses`, which surface in KSat→QUBO); those are genuine field-name inconsistencies between a node's incoming and outgoing reductions, outside the growth-composition logic, and are left for separate scoping. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- src/rules/ilp_i32_ilp_bool.rs | 2 +- src/unit_tests/rules/pareto.rs | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/rules/ilp_i32_ilp_bool.rs b/src/rules/ilp_i32_ilp_bool.rs index 6577cb5e5..98460be44 100644 --- a/src/rules/ilp_i32_ilp_bool.rs +++ b/src/rules/ilp_i32_ilp_bool.rs @@ -264,7 +264,7 @@ impl ReductionResult for ReductionIntILPToBinaryILP { } #[reduction(overhead = { - num_vars = "31 * num_variables", + num_vars = "31 * num_vars", num_constraints = "num_constraints", })] impl ReduceTo> for ILP { diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index d41e65de8..b36ff08e2 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -714,3 +714,74 @@ fn test_asymptotic_front_dedups_by_growth_vector() { front.len() ); } + +/// A composed front label must express every size field's growth purely in the +/// **source problem's** own size variables — never in a downstream getter alias or an +/// intermediate node's field name. +/// +/// Regression for the `MinimumFeedbackVertexSet → ILP` bug: the `ILP → ILP` +/// binary-encoding cast declared its overhead as `num_vars = "31 * num_variables"`, +/// referencing the getter *alias* `num_variables()` instead of ILP's size-field *name* +/// `num_vars`. Instance mode and raw-overhead rendering both resolve the getter, so the +/// mistake was invisible there — but growth composition threads field *names*, so the +/// alias was unmapped and leaked through as `num_vars = O(num_variables)` instead of +/// the correct `O(num_vertices)`. +#[test] +fn test_asymptotic_front_uses_only_source_variables_mfvs_ilp() { + let graph = ReductionGraph::new(); + let src_v = graph + .default_variant_for("MinimumFeedbackVertexSet") + .or_else(|| { + graph + .variants_for("MinimumFeedbackVertexSet") + .into_iter() + .next() + }) + .expect("MinimumFeedbackVertexSet registered"); + let dst_v = graph + .default_variant_for("ILP") + .or_else(|| graph.variants_for("ILP").into_iter().next()) + .expect("ILP registered"); + + let front = graph.asymptotic_front( + "MinimumFeedbackVertexSet", + &src_v, + "ILP", + &dst_v, + ReductionMode::Witness, + ); + + // The direct route (MFVS → ILP/i32 → ILP/bool; the ILP variants collapse in the + // deduplicated node-name view) is the one exercised by the fixed cast. + let (_, label) = front + .iter() + .find(|(p, _)| p.type_names() == ["MinimumFeedbackVertexSet", "ILP"]) + .expect("direct MinimumFeedbackVertexSet -> ILP path"); + + // The size fields of MinimumFeedbackVertexSet — the only variables any composed + // growth is allowed to mention. + let allowed = ["num_arcs", "num_vertices"]; + for (field, growth) in label.fields() { + let expr = growth + .to_expr() + .unwrap_or_else(|| panic!("field {field} should have a bounded growth")); + for var in expr.variables() { + assert!( + allowed.contains(&var), + "field `{field}` growth O({expr}) references `{var}`, which is not a \ + MinimumFeedbackVertexSet source variable {allowed:?}", + ); + } + } + + // The previously-buggy field, pinned to the correct source-variable Big-O. + let num_vars = label + .fields() + .get("num_vars") + .expect("ILP has a num_vars size field"); + assert_eq!( + num_vars.to_expr().unwrap().to_string(), + "num_vertices", + "ILP num_vars must compose to O(num_vertices), not the getter alias num_variables" + ); +} From 106ca13e568257dbb5e15cbf6477ca0bc8cd9ae8 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 21:09:52 +0800 Subject: [PATCH 08/31] Silence expected reduction-probe panics in compute_source_size (#1076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compute_source_size` iterates every same-source-name reduction and calls each one's `source_size_fn` on the instance to merge all size fields. A reduction for a different source variant downcasts and panics (`Option::unwrap` on a type-mismatch); these are expected and were already caught — but by a plain `catch_unwind` that does not suppress the panic hook, so `pred solve` on a simple instance (e.g. MIS on a sparse graph, with several unmatched i32-variant reductions) printed ~8 scary "thread 'main' panicked" lines to stderr while succeeding. Route the probe through the measured search's existing thread-local panic silencer (`pareto::catch_reduction`, now `pub(crate)`), so these caught, expected panics stay off stderr. No behavior change beyond suppressing the noise. Surfaced by an end-to-end `pred solve` use case; the measured-search `extend` path was already silenced, this was the one remaining unsilenced probe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- src/rules/graph.rs | 12 ++++++++---- src/rules/pareto.rs | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/rules/graph.rs b/src/rules/graph.rs index d478b833e..2b9d1c6ae 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -1145,10 +1145,14 @@ impl ReductionGraph { for entry in inventory::iter:: { if entry.source_name == name { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - (entry.source_size_fn)(instance) - })); - if let Ok(size) = result { + // A reduction's `source_size_fn` downcasts `instance` to its own + // source variant and panics on a mismatch; iterating every + // same-name entry means the non-matching variants panic-and-recover. + // Route through the silencer so these expected, caught panics do not + // spam stderr (the plain `catch_unwind` here did). + let result = + crate::rules::pareto::catch_reduction(|| (entry.source_size_fn)(instance)); + if let Some(size) = result { for (k, v) in size.components { if seen.insert(k.clone()) { merged.push((k, v)); diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 0036ef624..29a0bd94e 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -51,7 +51,7 @@ static HOOK_INIT: Once = Once::new(); /// design's guarantee that path selection never crashes. The thread-local silencer keeps /// this expected, recovered panic from spamming stderr while leaving genuine panics on /// other threads untouched. -fn catch_reduction(f: impl FnOnce() -> R) -> Option { +pub(crate) fn catch_reduction(f: impl FnOnce() -> R) -> Option { HOOK_INIT.call_once(|| { let prev = panic::take_hook(); panic::set_hook(Box::new(move |info| { From 9849e4b37422770715a8418fd7d9c1f06ceee978 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 13 Jul 2026 22:08:48 +0800 Subject: [PATCH 09/31] Add randomized property tests for growth domain (#1077) Cross-validate the symbolic growth domain (src/growth.rs) against numeric evaluation (Expr::eval) over a large seeded input space, in the spirit of the repo's verify-reduction adversarial culture. Three contracts, each exercised well past 5000 meaningful checks with a hand-rolled deterministic SplitMix64 RNG (no wall-clock/entropy, byte-reproducible across platforms): - Upper-bound soundness: eval(e,s) <= C*eval(render(growth(e)),s) at sizes larger than the 2^6 anchor from which C is calibrated (14500 checks, 0 violations). Numeric artifacts (inf from exp-under-log; negative values outside the nonnegativity axiom) are skipped as indeterminate, not flagged. - Idempotence: growth(render(growth(e))) == growth(e), compared up to rendering float precision (18960 checks). - Dominance soundness: when dominates(b,a), the numeric ratio does not shrink and exceeds 1 at the larger size (36050 single-term dominating pairs). The evaluation window is chosen per pair from the exponent-gap regime so the crossover is numerically reachable, independent of the assertion outcome. Negative control: the same upper-bound harness run against a deliberately broken transfer (Add keeping only its first operand) detects 3343 violations, proving the harness can fail. Findings surfaced and handled precisely (not weakened): to_expr base-snapping makes idempotence hold only up to ~1e-10 float drift; log-nested exponentials overflow eval mid-computation though the true growth is tame; and the domain's nonnegativity precondition must be respected when generating inputs. Runs in <2s. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- src/unit_tests/growth.rs | 564 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 564 insertions(+) diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 1461f7bd8..c1779cf73 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -226,3 +226,567 @@ fn test_growth_serde_roundtrip() { Growth::Unknown ); } + +// --- Randomized property tests (#1077) --- +// +// These cross-validate the symbolic growth domain against the numeric ground +// truth (`Expr::eval`) over a large, seeded input space, in the spirit of the +// repo's `/verify-reduction` adversarial culture. Three contracts are exercised +// ≥ 5000 times each with a hand-rolled, deterministic RNG (no wall-clock, no +// entropy — CI must be byte-reproducible across platforms): +// +// 1. Upper-bound soundness: `eval(e, s) ≤ C·eval(render(growth(e)), s)` at +// sizes larger than the anchor from which `C` was calibrated. +// 2. Idempotence: `growth(render(growth(e))) == growth(e)`. +// 3. Dominance soundness: when `dominates(b, a)`, the numeric ratio +// `eval(b)/eval(a)` does not shrink and exceeds 1 at the larger size. +// +// A #[test] negative control runs the same upper-bound harness against a +// deliberately broken transfer function and asserts the harness catches it, so +// the property tests are demonstrably capable of failing. +// +// Why the domain exists at all is *why* some numeric checks are unreachable: +// crossovers like `2^n ≻ n^100` lie far beyond f64 range. The harnesses handle +// this honestly — they skip (and count) samples where numerics are +// indeterminate (both sides overflow to `inf`), never by hiding a failing +// assertion. The dominance contract additionally restricts its numeric +// cross-check to single-term, in-band growths, the regime where the crossover +// is reachable; that regime targets exactly the lexicographic per-variable +// comparison (`GrowthTerm::cmp`) at the heart of the order, so the restriction +// is well-aimed, not vacuous. + +use super::{exponential, log_growth, pow_const}; +use crate::types::ProblemSize; +use std::collections::BTreeMap; + +/// Fixed master seed. Every contract derives its own stream by offsetting this, +/// so the whole suite is deterministic and reproducible on any platform. +const MASTER_SEED: u64 = 0xD1CE_2026_1077_ABCD; + +/// SplitMix64 — a tiny, fully specified PRNG. Hand-rolled (rather than +/// `rand::StdRng`) precisely because its output must be identical across crate +/// versions and platforms; the constants below are the published SplitMix64 +/// mixing constants and will never change. +struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + fn new(seed: u64) -> Self { + SplitMix64 { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform integer in `[0, n)`. + fn below(&mut self, n: u64) -> u64 { + self.next_u64() % n + } +} + +fn b(e: Expr) -> Box { + Box::new(e) +} + +/// Variable pool — `&'static str` literals so they satisfy `Expr::Var` and match +/// the `ProblemSize` keys built by [`joint_size`]. +const VARS: [&str; 3] = ["n", "m", "k"]; + +fn gen_var(rng: &mut SplitMix64) -> Expr { + Expr::Var(VARS[rng.below(VARS.len() as u64) as usize]) +} + +/// All variables set jointly to `s` (the contracts evaluate on the diagonal). +fn joint_size(s: usize) -> ProblemSize { + ProblemSize::new(vec![("n", s), ("m", s), ("k", s)]) +} + +// --- General expression generator (contracts 1 and 2) --- +// +// Bounded depth, variables {n, m, k}, constructors Const/Var/Add/Mul/Pow(const)/ +// Sqrt/Log plus linear `2^x` and `exp(x)` forms. A small (~1% per node) branch +// emits a nonlinear exponent (`2^(n*m)`, `2^sqrt(n)`) so the `Unknown` widening +// path is genuinely exercised while staying a minority of whole trees. + +const MAX_DEPTH: u32 = 5; + +fn gen_leaf(rng: &mut SplitMix64) -> Expr { + // Bias toward variables; keep constants small and positive. + if rng.below(4) == 0 { + Expr::Const((1 + rng.below(4)) as f64) + } else { + gen_var(rng) + } +} + +/// A linear expression in the variables (so `2^x` stays first-class in the +/// domain): a sum of 1..=3 terms `c·v` with small positive integer coefficients. +fn gen_linear(rng: &mut SplitMix64) -> Expr { + let nterms = 1 + rng.below(3); + let mut e = gen_lin_term(rng); + for _ in 1..nterms { + e = e + gen_lin_term(rng); + } + e +} + +fn gen_lin_term(rng: &mut SplitMix64) -> Expr { + let v = gen_var(rng); + let c = 1 + rng.below(3); + if c == 1 { + v + } else { + Expr::Const(c as f64) * v + } +} + +/// A deliberately nonlinear exponent, driving `2^(·)` to `Growth::Unknown`. +fn gen_nonlinear(rng: &mut SplitMix64) -> Expr { + if rng.below(2) == 0 { + Expr::Mul(b(gen_var(rng)), b(gen_var(rng))) + } else { + Expr::Sqrt(b(gen_var(rng))) + } +} + +fn gen_expr(rng: &mut SplitMix64, depth: u32) -> Expr { + if depth == 0 { + return gen_leaf(rng); + } + match rng.below(100) { + 0..=19 => gen_leaf(rng), + 20..=39 => Expr::Add(b(gen_expr(rng, depth - 1)), b(gen_expr(rng, depth - 1))), + 40..=54 => Expr::Mul(b(gen_expr(rng, depth - 1)), b(gen_expr(rng, depth - 1))), + 55..=69 => Expr::pow( + gen_expr(rng, depth - 1), + Expr::Const((1 + rng.below(3)) as f64), + ), + 70..=79 => Expr::Sqrt(b(gen_expr(rng, depth - 1))), + 80..=89 => Expr::Log(b(gen_expr(rng, depth - 1))), + 90..=96 => Expr::pow(Expr::Const(2.0), gen_linear(rng)), + 97..=98 => Expr::Exp(b(gen_var(rng))), + // ~1% per node: a nonlinear exponent → Unknown (a minority of trees). + _ => Expr::pow(Expr::Const(2.0), gen_nonlinear(rng)), + } +} + +// --- Monomial generator (contract 3) --- +// +// A product of single-term factors, so its growth is always a single antichain +// term. This isolates the lexicographic per-variable dominance decision. + +fn gen_factor(rng: &mut SplitMix64) -> Expr { + let v = gen_var(rng); + match rng.below(6) { + 0 => v, + 1 => Expr::pow(v, Expr::Const((1 + rng.below(3)) as f64)), + 2 => Expr::Sqrt(b(v)), + 3 => Expr::Log(b(v)), + 4 => Expr::pow(Expr::Const(2.0), v), + _ => Expr::pow(Expr::Const(2.0), Expr::Const((1 + rng.below(3)) as f64) * v), + } +} + +fn gen_monomial(rng: &mut SplitMix64) -> Expr { + let nf = 1 + rng.below(4); + let mut e = gen_factor(rng); + for _ in 1..nf { + e = e * gen_factor(rng); + } + e +} + +// --- Contract 1: upper-bound soundness --- + +/// The number of independent `#[test]`-level iterations for the upper-bound and +/// idempotence contracts (each well above the 5000-meaningful-check floor after +/// `Unknown`/overflow skips). +const UB_ITERS: usize = 20_000; + +/// Outcome tallies for the upper-bound harness. `meaningful` counts samples that +/// produced at least one *conclusive* large-size comparison. +#[derive(Default)] +struct UbResult { + meaningful: usize, + unknown: usize, + skipped: usize, + violations: usize, + first_violation: Option, +} + +/// Run the upper-bound harness against an arbitrary transfer function. The real +/// test passes `Growth::from_expr`; the negative control passes +/// `broken_from_expr`. Parameterizing here is what gives the harness teeth: the +/// exact same code must accept the sound transfer and reject the broken one. +fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> UbResult { + // Anchor 2^6; check at 2^8, 2^10, 2^12 — all *larger* than the anchor. + let anchor = 64.0_f64; + let large = [256.0_f64, 1024.0, 4096.0]; + let slack = 16.0_f64; + + let mut rng = SplitMix64::new(seed); + let mut r = UbResult::default(); + + for _ in 0..iters { + let e = gen_expr(&mut rng, MAX_DEPTH); + let g = transfer(&e); + let gexpr = match g.to_expr() { + Some(x) => x, + None => { + r.unknown += 1; + continue; + } + }; + + // Calibrate C from the observed ratio at the (smaller) anchor. + let sz0 = joint_size(anchor as usize); + let ve0 = e.eval(&sz0); + let vg0 = gexpr.eval(&sz0); + // Nonnegativity is a domain precondition. A negative anchor value means + // the generated expression is outside the domain's contract (e.g. deeply + // nested `log`s that are negative at these sizes) — skip it, don't hold + // the domain to a bound it never promised for such inputs. + if !ve0.is_finite() || !vg0.is_finite() || ve0 <= 0.0 || vg0 <= 0.0 { + r.skipped += 1; + continue; + } + let c = (ve0 / vg0) * slack; + + let mut conclusive = false; + for &s in &large { + let sz = joint_size(s as usize); + let ve = e.eval(&sz); + let vg = gexpr.eval(&sz); + if ve.is_nan() || vg.is_nan() { + continue; + } + if vg.is_infinite() { + // The bound overestimates. Holds trivially unless `e` also blew + // up, in which case the comparison is indeterminate — skip it. + if ve.is_finite() { + conclusive = true; + } + continue; + } + if ve.is_infinite() { + // `eval(e)` can overflow to `inf` at intermediate steps even + // when the true value is finite (e.g. `log(n^2 * exp(n))` blows + // up at the inner `exp` before the outer `log` tames it back to + // `n`). Such a numeric artifact is indeterminate, not a genuine + // violation of a finite bound — skip this size. + continue; + } + if ve <= 0.0 || vg <= 0.0 { + // Out of the nonnegative domain at this size — indeterminate. + continue; + } + // Both finite and positive: a real, decidable comparison. + conclusive = true; + let bound = c * vg; + if ve > bound { + r.violations += 1; + if r.first_violation.is_none() { + r.first_violation = Some(format!( + "e = {e} | g = {gexpr} | s = {s}: eval(e) = {ve} > {c} * {vg} = {bound}" + )); + } + } + } + + if conclusive { + r.meaningful += 1; + } else { + r.skipped += 1; + } + } + r +} + +/// A deliberately broken transfer function: `Add` keeps only its *first* +/// operand's growth, dropping the second. This is an under-approximation — it +/// can miss the dominant summand — so the upper bound must fail somewhere. +/// Every other node mirrors the real `Growth::from_expr` (reusing its private +/// transfer helpers), so the only defect is the seeded `Add` bug. +fn broken_from_expr(e: &Expr) -> Growth { + if e.constant_value().is_some() { + return Growth::Terms(vec![GrowthTerm::one()]); + } + match e { + Expr::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), + Expr::Var(v) => { + let mut t = GrowthTerm::one(); + t.poly.insert(v, 1.0); + Growth::Terms(vec![t]) + } + // The seeded bug: drop the second summand. + Expr::Add(a, _b) => broken_from_expr(a), + Expr::Mul(a, b) => mul(broken_from_expr(a), broken_from_expr(b)), + Expr::Pow(base, exp) => { + if let Some(k) = exp.constant_value() { + if k < 0.0 { + Growth::Unknown + } else if k == 0.0 { + Growth::Terms(vec![GrowthTerm::one()]) + } else { + pow_const(broken_from_expr(base), k) + } + } else if let Some(c) = base.constant_value() { + exponential(c, exp) + } else { + Growth::Unknown + } + } + Expr::Exp(a) => exponential(std::f64::consts::E, a), + Expr::Log(a) => log_growth(broken_from_expr(a)), + Expr::Sqrt(a) => pow_const(broken_from_expr(a), 0.5), + Expr::Factorial(_) => Growth::Unknown, + } +} + +#[test] +fn test_growth_property_upper_bound_sound() { + let r = run_upper_bound(Growth::from_expr, MASTER_SEED ^ 0x01, UB_ITERS); + + assert_eq!( + r.violations, + 0, + "upper-bound violation ({} total); first: {}", + r.violations, + r.first_violation.as_deref().unwrap_or("") + ); + assert!( + r.meaningful >= 5000, + "need >= 5000 meaningful checks, got {} (unknown {}, skipped {})", + r.meaningful, + r.unknown, + r.skipped + ); + // The generator must actually exercise the domain, not mostly produce Unknown. + let total = r.meaningful + r.unknown + r.skipped; + assert!( + r.unknown * 2 < total, + "Unknown must be a minority: {}/{}", + r.unknown, + total + ); + assert!(r.unknown > 0, "generator never exercised the Unknown path"); +} + +#[test] +fn test_growth_property_upper_bound_negative_control() { + // The SAME harness, run against the broken transfer, must detect a + // violation. If it cannot, the property tests have no teeth and this fails. + let r = run_upper_bound(broken_from_expr, MASTER_SEED ^ 0x01, UB_ITERS); + assert!( + r.violations > 0, + "harness failed to catch the seeded Add bug (meaningful {}, violations {})", + r.meaningful, + r.violations + ); +} + +// --- Contract 2: idempotence --- + +/// Approximate `GrowthTerm` equality: exact variable sets and log powers, +/// tolerance on exp rates and poly degrees. Exact f64 `==` is too brittle here +/// because `to_expr` snaps exponential bases to 1e-9 for readable rendering +/// (`exp{n:2.5}` → `5.656854249^n`), and re-deriving the rate via `log2` of the +/// snapped base drifts by ~1e-10. Idempotence therefore holds *structurally* +/// and up to rendering precision, which is what this compares. The tolerance is +/// far tighter than any semantic exponent gap, so structural regressions +/// (changed variable, dropped term, wrong log power, altered degree) still fail. +fn map_approx_eq(a: &BTreeMap<&'static str, f64>, b: &BTreeMap<&'static str, f64>) -> bool { + a.len() == b.len() + && a.iter() + .all(|(k, v)| b.get(k).is_some_and(|w| (v - w).abs() < 1e-6)) +} + +fn term_approx_eq(x: &GrowthTerm, y: &GrowthTerm) -> bool { + map_approx_eq(&x.exp, &y.exp) && map_approx_eq(&x.poly, &y.poly) && x.logs == y.logs +} + +fn growth_approx_eq(a: &Growth, b: &Growth) -> bool { + match (a, b) { + (Growth::Unknown, Growth::Unknown) => true, + (Growth::Terms(ta), Growth::Terms(tb)) => { + ta.len() == tb.len() + && ta.iter().all(|t| tb.iter().any(|u| term_approx_eq(t, u))) + && tb.iter().all(|u| ta.iter().any(|t| term_approx_eq(t, u))) + } + _ => false, + } +} + +#[test] +fn test_growth_property_idempotence() { + let mut rng = SplitMix64::new(MASTER_SEED ^ 0x02); + let mut meaningful = 0usize; + let mut unknown = 0usize; + + for _ in 0..UB_ITERS { + let e = gen_expr(&mut rng, MAX_DEPTH); + let g = Growth::from_expr(&e); + let rendered = match g.to_expr() { + Some(x) => x, + None => { + unknown += 1; + continue; + } + }; + let g2 = Growth::from_expr(&rendered); + assert!( + growth_approx_eq(&g, &g2), + "growth not idempotent: e = {e} | render = {rendered}\n g = {g:?}\n g2 = {g2:?}" + ); + meaningful += 1; + } + + assert!( + meaningful >= 5000, + "need >= 5000 meaningful checks, got {meaningful} (unknown {unknown})" + ); +} + +// --- Contract 3: dominance soundness --- + +const DOM_ITERS: usize = 120_000; + +/// A single antichain term, or `None` if the growth is `Unknown` or a +/// multi-term antichain. Restricting to single terms keeps the numeric ratio a +/// pure monomial ratio: multi-term dominance can add a *lower-order* summand +/// (`{n^2, m}` dominates `{n^2}`) whose ratio shrinks toward 1 — a real feature +/// of the antichain order, but not what this monomial cross-check targets. The +/// single-term regime isolates the lexicographic per-variable comparison +/// (`GrowthTerm::cmp`) that is the heart of the order. +fn single_term(g: &Growth) -> Option<&GrowthTerm> { + match g { + Growth::Terms(ts) if ts.len() == 1 => Some(&ts[0]), + _ => None, + } +} + +/// `(total exp rate, total poly degree, total log power)` on the joint diagonal. +fn totals(t: &GrowthTerm) -> (f64, f64, f64) { + ( + t.exp.values().sum(), + t.poly.values().sum(), + t.logs.values().map(|&x| x as f64).sum(), + ) +} + +#[test] +fn test_growth_property_dominance_sound() { + let mut rng = SplitMix64::new(MASTER_SEED ^ 0x03); + let mut meaningful = 0usize; + let mut skipped = 0usize; + let mut unreachable = 0usize; + const LN2: f64 = std::f64::consts::LN_2; + + for _ in 0..DOM_ITERS { + let ga = Growth::from_expr(&gen_monomial(&mut rng)); + let gb = Growth::from_expr(&gen_monomial(&mut rng)); + + let (ta, tb) = match (single_term(&ga), single_term(&gb)) { + (Some(a), Some(b)) => (a.clone(), b.clone()), + _ => { + skipped += 1; + continue; + } + }; + + // Orient to the strict dominator; skip incomparable or asymptotically + // equal pairs (a flat ratio has nothing to assert). + let ab = ga.dominates(&gb); + let ba = gb.dominates(&ga); + let (hi, lo) = if ba && !ab { + (&tb, &ta) + } else if ab && !ba { + (&ta, &tb) + } else { + skipped += 1; + continue; + }; + + // Choose the evaluation window from the *magnitude* of the exponent gap + // — a structural property of the two terms, computed independently of + // which direction `dominates` picked. This places the check in the + // numerically-informative regime (past the ratio's minimum, past the + // crossover, below f64 overflow) so the assertions are meaningful; it + // does NOT peek at the assertion outcome, so a mis-ordering by + // `dominates` still fails the signed check below. + let (eh, ph, lh) = totals(hi); + let (el, pl, ll) = totals(lo); + let (de, dp, dl) = (eh - el, ph - pl, lh - ll); + const EPS: f64 = 1e-9; + let exp_max = eh.max(el); + + let (s1, s2): (usize, usize) = if de.abs() > EPS { + // Exponential gap: crossover is at moderate size; keep exp finite. + (16, 64) + } else if dp.abs() > EPS { + // Polynomial gap under a *common* exponent: the crossover (e.g. + // sqrt(n) vs (log n)^3 at n≈2.4e7) needs large sizes where any + // shared exponential would overflow. Reachable only with no + // exponential — and then poly values stay finite to astronomical + // sizes, so a wide window clears even the fractional-poly-vs-high- + // log-power crossovers our generator can produce (dp≥0.5, |dl|≤4). + if exp_max > EPS { + unreachable += 1; + continue; + } + (8192, 1usize << 42) + } else if dl.abs() > EPS { + // Log-power gap only: manifest at any modest size. + (16, 64) + } else { + // No gap on the diagonal (strict domination on an off-diagonal + // variable that collapses here) — nothing to assert numerically. + skipped += 1; + continue; + }; + + // Overflow guard for the (in-principle reachable) exponential cases. + if exp_max * (s2 as f64) * LN2 > 700.0 { + unreachable += 1; + continue; + } + + let a = Growth::Terms(vec![lo.clone()]).to_expr().unwrap(); + let bx = Growth::Terms(vec![hi.clone()]).to_expr().unwrap(); + let (z1, z2) = (joint_size(s1), joint_size(s2)); + let (a1, a2) = (a.eval(&z1), a.eval(&z2)); + let (b1, b2) = (bx.eval(&z1), bx.eval(&z2)); + if [a1, a2, b1, b2].iter().any(|v| !v.is_finite() || *v <= 0.0) { + skipped += 1; + continue; + } + + let r1 = b1 / a1; + let r2 = b2 / a2; + meaningful += 1; + + // The ratio does not shrink from s1 to s2 (tiny tolerance for float + // noise), and it exceeds 1 at the larger size. A wrong-direction + // dominance decision flips the signed gap and fails both. + assert!( + r2 >= r1 * (1.0 - 1e-9), + "dominance ratio shrank: {bx} over {a}; r({s1}) = {r1}, r({s2}) = {r2}" + ); + assert!( + r2 > 1.0, + "dominator not numerically ahead at s2: {bx} over {a}; r({s2}) = {r2}" + ); + } + + assert!( + meaningful >= 5000, + "need >= 5000 meaningful dominating pairs, got {meaningful} \ + (skipped {skipped}, unreachable {unreachable})" + ); +} From 8fd4fa813ace9a45de0414e6869026c1117bf588 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 00:04:02 +0800 Subject: [PATCH 10/31] Simplify growth/Pareto rendering and dominance (#1083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the milestone diff (no behavior change): - Add canonical `Growth::to_big_o()` in the lib; both the CLI (`commands/graph.rs`) and MCP (`mcp/tools.rs`) front renderers now call it instead of each hand-rolling the `Growth -> "O(...)"` mapping. The two had already drifted on the `Unknown` string; they now agree on `O(?)`. Adds a `to_big_o` unit test. - `size_le` (MeasuredLabel dominance): drop the provably redundant second `.all()` pass — nonnegative sizes with missing-field-as-0 make one pass sufficient. - `GrowthLabel::extend`: hoist the loop-invariant substitution map out of the per-output-field loop; it depends only on the rendered source label. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- problemreductions-cli/src/commands/graph.rs | 15 ++-------- problemreductions-cli/src/mcp/tools.rs | 8 +----- src/growth.rs | 12 ++++++++ src/rules/pareto.rs | 32 +++++++++++---------- src/unit_tests/growth.rs | 16 +++++++++++ 5 files changed, 49 insertions(+), 34 deletions(-) diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index a35a0388c..b3cda755f 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -7,7 +7,7 @@ use problemreductions::rules::{ TraversalFlow, }; use problemreductions::types::ProblemSize; -use problemreductions::{big_o_normal_form, Expr, Growth}; +use problemreductions::{big_o_normal_form, Expr}; use std::collections::BTreeMap; pub fn list(out: &OutputConfig) -> Result<()> { @@ -490,15 +490,6 @@ fn format_path_json( }) } -/// Render one growth as a Big-O string: `O()`, or an explicit unbounded marker -/// for `Growth::Unknown` (nonlinear exponent / factorial) — never a fabricated bound. -fn growth_big_o(g: &Growth) -> String { - match g.to_expr() { - Some(e) => format!("O({e})"), - None => "O(?) [unbounded: nonlinear exponent / factorial]".to_string(), - } -} - /// Node-arrow summary (`A → B → C`) for a reduction path, deduplicating consecutive /// same-name variant-cast steps. fn path_arrow_summary(graph: &ReductionGraph, reduction_path: &ReductionPath) -> String { @@ -538,7 +529,7 @@ fn format_front_text( path_arrow_summary(graph, reduction_path), )); for (field, growth) in label.fields() { - text.push_str(&format!(" {field} = {}\n", growth_big_o(growth))); + text.push_str(&format!(" {field} = {}\n", growth.to_big_o())); } } text @@ -557,7 +548,7 @@ fn format_front_json( let big_o: BTreeMap<&str, String> = label .fields() .iter() - .map(|(f, g)| (*f, growth_big_o(g))) + .map(|(f, g)| (*f, g.to_big_o())) .collect(); serde_json::json!({ "steps": reduction_path.len(), diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 67c762de7..286ddb1ac 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -1184,13 +1184,7 @@ fn format_front_json( let big_o: BTreeMap<&str, String> = label .fields() .iter() - .map(|(f, g)| { - let rendered = match g.to_expr() { - Some(e) => format!("O({e})"), - None => "O(?)".to_string(), - }; - (*f, rendered) - }) + .map(|(f, g)| (*f, g.to_big_o())) .collect(); serde_json::json!({ "steps": reduction_path.len(), diff --git a/src/growth.rs b/src/growth.rs index a64d76d97..13b711d95 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -311,6 +311,18 @@ impl Growth { } } } + + /// Canonical Big-O string for this growth class: `O()` for a bounded + /// class, or `O(?)` for [`Growth::Unknown`] (no honest asymptotic bound — + /// nonlinear exponent or factorial). This is the single source of truth for + /// how a growth is displayed as Big-O; presentation layers must call it rather + /// than re-deriving the mapping (and the `Unknown` spelling) themselves. + pub fn to_big_o(&self) -> String { + match self.to_expr() { + Some(e) => format!("O({e})"), + None => "O(?)".to_string(), + } + } } /// Render one monomial as a product of its factors (or `Const(1)` when empty). diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 29a0bd94e..ae2e6ddc3 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -263,14 +263,12 @@ impl<'a> MeasuredLabel<'a> { /// `a` covers `b` iff every field of `b` is present in `a` with a value `>=` b's — i.e. /// `a` is componentwise `<=` `b`. Missing fields are treated as `0`. fn size_le(a: &ProblemSize, b: &ProblemSize) -> bool { - // a <= b componentwise: for each field in either, a[f] <= b[f]. - a.components.iter().all(|(name, av)| { - let bv = b.get(name).unwrap_or(0); - *av <= bv - }) && b.components.iter().all(|(name, bv)| { - let av = a.get(name).unwrap_or(0); - av <= *bv - }) + // a <= b componentwise. Sizes are nonnegative and missing fields default to 0, + // so only a's own fields can violate the bound: a b-only field gives `0 <= b`, + // which always holds. Checking a's fields against b is therefore sufficient. + a.components + .iter() + .all(|(name, av)| *av <= b.get(name).unwrap_or(0)) } impl PathLabel for MeasuredLabel<'_> { @@ -396,6 +394,15 @@ impl PathLabel for GrowthLabel { let rendered: BTreeMap<&'static str, Option> = self.fields.iter().map(|(k, g)| (*k, g.to_expr())).collect(); + // Substitution map from current field name to its rendered growth `Expr` (in + // source variables). Depends only on `rendered`, so build it once for all edges' + // output fields rather than per target field. Overhead variables not in the + // label pass through unchanged (mirrors `ReductionOverhead::compose`). + let mapping: HashMap<&str, &Expr> = rendered + .iter() + .filter_map(|(k, opt)| opt.as_ref().map(|e| (*k, e))) + .collect(); + let mut new_fields: BTreeMap<&'static str, Growth> = BTreeMap::new(); for (target_field, expr) in &edge.overhead.output_size { // If this overhead references a current field whose growth is `Unknown`, @@ -408,13 +415,8 @@ impl PathLabel for GrowthLabel { new_fields.insert(target_field, Growth::Unknown); continue; } - // Substitute each current field name with its rendered growth (in source - // variables), then reduce in the growth domain. Overhead variables not in - // the label pass through unchanged (mirrors `ReductionOverhead::compose`). - let mapping: HashMap<&str, &Expr> = rendered - .iter() - .filter_map(|(k, opt)| opt.as_ref().map(|e| (*k, e))) - .collect(); + // Substitute rendered growths into the overhead, then reduce in the growth + // domain. let substituted = expr.substitute(&mapping); new_fields.insert(target_field, Growth::from_expr(&substituted)); } diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index c1779cf73..4ca570c86 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -152,6 +152,22 @@ fn test_growth_pow_special_cases() { assert_eq!(g("n^m"), Growth::Unknown); } +/// Canonical Big-O rendering: bounded classes get `O()`, `Unknown` gets `O(?)`. +#[test] +fn test_growth_to_big_o() { + // The dominated `n` summand is dropped by the antichain, leaving just `n^2`. + assert_eq!(g("n^2 + n").to_big_o(), "O(n^2)"); + assert_eq!(g("2^n").to_big_o(), "O(2^n)"); + assert_eq!(g("5").to_big_o(), "O(1)"); + assert_eq!(Growth::Unknown.to_big_o(), "O(?)"); + // Renders exactly `O()` for bounded classes. + let bounded = g("n * m"); + assert_eq!( + bounded.to_big_o(), + format!("O({})", bounded.to_expr().unwrap()) + ); +} + /// `exp(n)` uses base e; a decaying/unit base is bounded by O(1). #[test] fn test_growth_exponential_variants() { From a01caef08a1a24a6c6340285ad8570c9aed25b54 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 00:20:31 +0800 Subject: [PATCH 11/31] Rewire redundancy analysis to growth dominance (#1081) Replace the bespoke polynomial comparison engine in analysis.rs (Monomial, NormalizedPoly, normalize_polynomial, poly_leq, monomial_dominated_by, prepare_expr_for_comparison) with the shared symbolic growth domain. compare_overhead now decides each common field via Growth::from_expr + Growth::dominates (reflexive, so equal fields pass), returning Unknown only when a field's growth is Growth::Unknown. Outer semantics of find_dominated_rules are unchanged. The rewire loses no prior detection (all 9 previously-dominated rules retained) and gains one newly-decided pair: PartitionIntoPathsOfLength2 -> ILP{bool}, whose composite path carried a num_vertices/3 constant divisor that the old engine rejected as a negative-exponent power (Unknown); the growth domain drops constant divisors, making both fields asymptotically equal to the direct edge. Unknown comparisons dropped from 89 to 0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- src/rules/analysis.rs | 228 ++++--------------------------- src/unit_tests/rules/analysis.rs | 115 +++++++++++----- 2 files changed, 107 insertions(+), 236 deletions(-) diff --git a/src/rules/analysis.rs b/src/rules/analysis.rs index 2f31d1f9b..a54d2dc5f 100644 --- a/src/rules/analysis.rs +++ b/src/rules/analysis.rs @@ -1,13 +1,15 @@ //! Analysis utilities for the reduction graph. //! //! Detects primitive reduction rules that are dominated by composite paths, -//! using asymptotic normalization plus monomial-dominance comparison. +//! comparing overhead expressions through the shared symbolic growth domain +//! ([`crate::growth::Growth`]). //! //! This analysis is **sound but incomplete**: it reports `Dominated` only when -//! the symbolic comparison is trustworthy, and `Unknown` when metadata is too -//! weak to compare safely. +//! the growth comparison is trustworthy, and `Unknown` when a field's growth is +//! [`Growth::Unknown`] (nonlinear exponent, factorial, …). use crate::expr::Expr; +use crate::growth::Growth; use crate::rules::graph::{ReductionGraph, ReductionPath}; use crate::rules::registry::ReductionOverhead; use std::collections::{BTreeMap, BTreeSet}; @@ -93,186 +95,22 @@ pub fn format_problem_variant(name: &str, variant: &BTreeMap) -> format!("{name} {{{vars}}}") } -// ────────── Polynomial normalization ────────── - -/// A monomial: coefficient × ∏(variable ^ exponent). -#[derive(Debug, Clone)] -struct Monomial { - coeff: f64, - /// Variable name → exponent. Only non-zero exponents stored. - vars: BTreeMap<&'static str, f64>, -} - -impl Monomial { - fn constant(c: f64) -> Self { - Self { - coeff: c, - vars: BTreeMap::new(), - } - } - - fn variable(name: &'static str) -> Self { - let mut vars = BTreeMap::new(); - vars.insert(name, 1.0); - Self { coeff: 1.0, vars } - } - - /// Multiply two monomials. - fn mul(&self, other: &Monomial) -> Monomial { - let coeff = self.coeff * other.coeff; - let mut vars = self.vars.clone(); - for (&v, &e) in &other.vars { - *vars.entry(v).or_insert(0.0) += e; - } - Monomial { coeff, vars } - } -} - -/// A polynomial (sum of monomials) in normal form. -#[derive(Debug, Clone)] -struct NormalizedPoly { - terms: Vec, -} - -impl NormalizedPoly { - fn add(mut self, other: NormalizedPoly) -> NormalizedPoly { - self.terms.extend(other.terms); - self - } - - fn mul(&self, other: &NormalizedPoly) -> NormalizedPoly { - let mut terms = Vec::new(); - for a in &self.terms { - for b in &other.terms { - terms.push(a.mul(b)); - } - } - NormalizedPoly { terms } - } - - /// True if any monomial has a negative coefficient. - fn has_negative_coefficients(&self) -> bool { - self.terms.iter().any(|m| m.coeff < -1e-15) - } -} - -/// Normalize an expression into a sum of monomials. -/// -/// Supports: constants, variables, addition, multiplication, -/// and powers with non-negative constant exponents. -/// Returns `Err` for exp, log, sqrt, division, and negative exponents. -fn normalize_polynomial(expr: &Expr) -> Result { - match expr { - Expr::Const(c) => Ok(NormalizedPoly { - terms: vec![Monomial::constant(*c)], - }), - Expr::Var(v) => Ok(NormalizedPoly { - terms: vec![Monomial::variable(v)], - }), - Expr::Add(a, b) => { - let pa = normalize_polynomial(a)?; - let pb = normalize_polynomial(b)?; - Ok(pa.add(pb)) - } - Expr::Mul(a, b) => { - let pa = normalize_polynomial(a)?; - let pb = normalize_polynomial(b)?; - Ok(pa.mul(&pb)) - } - Expr::Pow(base, exp) => { - if let Expr::Const(c) = exp.as_ref() { - if *c < 0.0 { - return Err(format!("negative exponent: {c}")); - } - let pb = normalize_polynomial(base)?; - // Single monomial: multiply exponents - if pb.terms.len() == 1 { - let m = &pb.terms[0]; - let coeff = m.coeff.powf(*c); - let vars: BTreeMap<_, _> = m.vars.iter().map(|(&v, &e)| (v, e * c)).collect(); - return Ok(NormalizedPoly { - terms: vec![Monomial { coeff, vars }], - }); - } - // Multi-term polynomial raised to non-negative integer power - let n = *c as usize; - if c.fract().abs() < 1e-10 { - if n == 0 { - return Ok(NormalizedPoly { - terms: vec![Monomial::constant(1.0)], - }); - } - let mut result = pb.clone(); - for _ in 1..n { - result = result.mul(&pb); - } - return Ok(result); - } - Err(format!( - "non-integer power of multi-term polynomial: ({base})^{c}" - )) - } else { - Err(format!("variable exponent: ({base})^({exp})")) - } - } - Expr::Exp(_) => Err("exp() not supported".into()), - Expr::Log(_) => Err("log() not supported".into()), - Expr::Sqrt(_) => Err("sqrt() not supported".into()), - Expr::Factorial(_) => Err("factorial() not supported".into()), - } -} - -fn prepare_expr_for_comparison(expr: &Expr) -> Expr { - // The growth-dominance rewire of this comparison is a separate milestone - // issue; until then, compare the expressions as-is (no canonicalization). - expr.clone() -} - -// ────────── Monomial-dominance comparison ────────── - -/// Check if monomial `small` is asymptotically dominated by monomial `big`. -/// -/// True iff for every variable in `small`, `big` has at least as large an exponent. -/// This means `small` grows no faster than `big` as all variables → ∞. -fn monomial_dominated_by(small: &Monomial, big: &Monomial) -> bool { - for (&var, &exp_small) in &small.vars { - let exp_big = big.vars.get(var).copied().unwrap_or(0.0); - if exp_small > exp_big + 1e-10 { - return false; - } - } - true -} - -/// Check if polynomial `a` is asymptotically ≤ polynomial `b`. -/// -/// True iff every positive-coefficient monomial in `a` is dominated by -/// some positive-coefficient monomial in `b`. -fn poly_leq(a: &NormalizedPoly, b: &NormalizedPoly) -> bool { - let b_positive: Vec<&Monomial> = b.terms.iter().filter(|m| m.coeff > 1e-15).collect(); - - for a_term in &a.terms { - if a_term.coeff <= 1e-15 { - continue; // zero or negative — can only make `a` smaller - } - let dominated = b_positive - .iter() - .any(|b_term| monomial_dominated_by(a_term, b_term)); - if !dominated { - return false; - } - } - true -} - // ────────── Overhead comparison ────────── -/// Compare two overheads across all common fields. +/// Compare two overheads across all common fields, using the shared symbolic +/// growth domain ([`Growth`]) as the single dominance order. /// -/// Returns `Dominated` if composite ≤ primitive on all common fields. -/// Returns `NotDominated` if composite is worse on any common field. -/// Returns `Unknown` if any common field's expressions cannot be normalized -/// into a comparable polynomial form or contain negative coefficients. +/// Fields present in only one overhead are skipped (common-field semantics). +/// For each common field with primitive growth `pg` and composite growth `cg`: +/// - if either is [`Growth::Unknown`] the whole comparison is `Unknown`; +/// - otherwise the field is fine iff the composite is dominated-or-equal by the +/// primitive (`pg` grows ≥ `cg`, i.e. `pg.dominates(&cg)` — reflexive, so an +/// equal field counts as fine); +/// - otherwise (composite strictly worse, or the two growths incomparable) the +/// comparison is `NotDominated`. +/// +/// Returns `Dominated` when every common field is fine and at least one common +/// field exists; `NotDominated` when there is no common field. pub fn compare_overhead( primitive: &ReductionOverhead, composite: &ReductionOverhead, @@ -291,30 +129,20 @@ pub fn compare_overhead( }; any_common = true; - let primitive_prepared = prepare_expr_for_comparison(prim_expr); - let composite_prepared = prepare_expr_for_comparison(comp_expr); - - if primitive_prepared == composite_prepared { - continue; - } - - let primitive_poly = match normalize_polynomial(&primitive_prepared) { - Ok(p) => p, - Err(_) => return ComparisonStatus::Unknown, - }; - let composite_poly = match normalize_polynomial(&composite_prepared) { - Ok(p) => p, - Err(_) => return ComparisonStatus::Unknown, - }; + let pg = Growth::from_expr(prim_expr); + let cg = Growth::from_expr(comp_expr); - // Reject expressions with negative coefficients - if primitive_poly.has_negative_coefficients() || composite_poly.has_negative_coefficients() - { + // A field whose growth we cannot bound symbolically makes the whole + // comparison undecidable. + if matches!(pg, Growth::Unknown) || matches!(cg, Growth::Unknown) { return ComparisonStatus::Unknown; } - // Check: composite ≤ primitive on this field - if !poly_leq(&composite_poly, &primitive_poly) { + // `pg.dominates(&cg)` means the primitive grows at least as fast as the + // composite on this field (composite ≤ primitive). `dominates` is + // reflexive, so asymptotically-equal fields pass here. Anything else — + // composite strictly worse, or the two growths incomparable — fails. + if !pg.dominates(&cg) { return ComparisonStatus::NotDominated; } } diff --git a/src/unit_tests/rules/analysis.rs b/src/unit_tests/rules/analysis.rs index b67d62405..54cff218b 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -71,51 +71,85 @@ fn test_compare_overhead_no_common_fields() { } #[test] -fn test_compare_overhead_unknown_exp() { - // Different exponential-vs-polynomial growth is still not decided by the - // monomial comparison fallback. +fn test_compare_overhead_exp_dominates_poly() { + // primitive exp(n) grows faster than composite n, so composite ≤ primitive + // on the only common field → dominated. (The old polynomial engine rejected + // exp outright and returned Unknown; the growth domain decides it.) let prim = ReductionOverhead::new(vec![("num_vars", Expr::Exp(Box::new(Expr::Var("n"))))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } #[test] -fn test_compare_overhead_unknown_log() { +fn test_compare_overhead_poly_dominates_log() { + // primitive n vs composite log(n): n grows faster than log(n), so the + // composite is dominated. Previously Unknown (the polynomial engine could + // not normalize `log`); now decided by the growth domain. let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::Log(Box::new(Expr::Var("n"))))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } #[test] -fn test_compare_overhead_exp_identity_not_yet_normalized() { - // `exp(n + m)` and `exp(n) * exp(m)` are asymptotically equal, but the - // overhead comparator no longer canonicalizes (that engine was deleted), and - // its polynomial fallback does not handle exp, so it reports Unknown. - // Recognizing this identity again is the job of the analysis-to-growth - // rewire (a later milestone issue). +fn test_compare_overhead_exp_identity_decided() { + // `exp(n + m)` and `exp(n) * exp(m)` are asymptotically equal. The growth + // domain normalizes both to the same exponential term, so the (reflexive) + // dominance holds → dominated. (Was temporarily asserted Unknown while the + // bespoke engine — which could not handle exp — was still in place.) let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("exp(n + m)"))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("exp(n) * exp(m)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } #[test] -fn test_compare_overhead_log_identity_after_asymptotic_normalization() { - // log(n) vs log(n^2): the new canonicalization engine keeps log(n^2) as-is - // (it doesn't simplify log(x^k) = k*log(x)), so polynomial comparison - // returns Unknown for non-polynomial log terms. +fn test_compare_overhead_log_identity_decided() { + // log(n) vs log(n^2): the growth domain uses log(n^k) ≍ log(n), so both + // fields collapse to the same growth → dominated. (Was temporarily Unknown + // because the polynomial engine could not normalize `log`.) let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("log(n)"))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("log(n^2)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } #[test] -fn test_compare_overhead_sqrt_identity_not_yet_normalized() { - // `sqrt(n * m)` and `(n * m)^(1/2)` are equal, but without canonicalization - // the comparator's polynomial fallback does not handle sqrt, so it reports - // Unknown until the analysis-to-growth rewire (a later milestone issue). +fn test_compare_overhead_sqrt_identity_decided() { + // `sqrt(n * m)` and `(n * m)^(1/2)` are equal; the growth domain maps both + // to poly degree 0.5 in n and m → dominated. (Was temporarily Unknown while + // the sqrt-rejecting polynomial engine was in place.) let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("sqrt(n * m)"))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("(n * m)^(1/2)"))]); - assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Unknown); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); +} + +#[test] +fn test_compare_overhead_subtraction_now_decided() { + // Subtraction: primitive n^2 vs composite n^2 - n. The growth domain widens + // `a - b ⇝ a + b` so n^2 - n ≍ n^2, asymptotically equal to the primitive → + // dominated. The old polynomial engine rejected negative coefficients and + // returned Unknown. + let prim = ReductionOverhead::new(vec![("num_vars", Expr::parse("n^2"))]); + let comp = ReductionOverhead::new(vec![("num_vars", Expr::parse("n^2 - n"))]); + assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); +} + +#[test] +fn test_compare_overhead_negative_control_cubic_worse() { + // Negative control: primitive num_vertices = n^2 vs composite num_vertices = + // n^3, all other common fields equal. The composite grows strictly faster on + // the differing field, so this MUST be NotDominated — a direction inversion + // or an ignored field would flip it to Dominated. + let prim = ReductionOverhead::new(vec![ + ("num_vertices", Expr::pow(Expr::Var("n"), Expr::Const(2.0))), + ("num_edges", Expr::Var("n")), + ]); + let comp = ReductionOverhead::new(vec![ + ("num_vertices", Expr::pow(Expr::Var("n"), Expr::Const(3.0))), + ("num_edges", Expr::Var("n")), + ]); + assert_eq!( + compare_overhead(&prim, &comp), + ComparisonStatus::NotDominated + ); } #[test] @@ -127,10 +161,9 @@ fn test_compare_overhead_additive_constant_after_asymptotic_normalization() { #[test] fn test_compare_overhead_multivariate_product_vs_sum() { - // n * m (degree 2) vs n + m (degree 1): - // monomial n*m has exponents {n:1, m:1} - // monomials n, m each have exponent 1 in one variable - // n*m is NOT dominated by either n or m → composite is worse + // primitive n + m ≍ {n, m} (two incomparable terms) vs composite n * m ≍ + // {n·m}. The single composite term n·m is dominated by neither n nor m, so + // the primitive does not dominate the composite → not dominated. let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") + Expr::Var("m"))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") * Expr::Var("m"))]); assert_eq!( @@ -140,10 +173,10 @@ fn test_compare_overhead_multivariate_product_vs_sum() { } #[test] -fn test_compare_overhead_multivariate_product_vs_square() { - // n * m (has m) vs n^2 (no m): incomparable - // n*m monomial {n:1, m:1} — dominated by n^2 {n:2}? - // exponent_n: 1 <= 2 ✓, exponent_m: 1 <= 0 ✗ → not dominated +fn test_compare_overhead_incomparable_field_not_dominated() { + // Incomparable growths on a field: primitive n^2 vs composite n * m. n^2 has + // degree 2 in n and 0 in m; n·m has degree 1 in each. Neither dominates the + // other (n^2 wins on n, n·m wins on m) → not dominated. let prim = ReductionOverhead::new(vec![( "num_vars", Expr::pow(Expr::Var("n"), Expr::Const(2.0)), @@ -173,11 +206,11 @@ fn test_compare_overhead_constant_factor() { #[test] fn test_compare_overhead_polynomial_expansion() { - // (n + m)^2 = n^2 + 2nm + m^2 (degree 2) vs n^3 (degree 3) - // Each monomial of composite has total degree ≤ 2, primitive has degree 3 - // n^2 dominated by n^3? exponent_n: 2 ≤ 3 ✓ → yes - // 2*n*m dominated by n^3? exponent_n: 1 ≤ 3 ✓, exponent_m: 1 ≤ 0 ✗ → no! - // So composite is NOT dominated — (n+m)^2 can exceed n^3 when m is large + // Composite (n + m)^2 ≍ max(n, m)^2 = {n^2, m^2} in the growth domain (no + // binomial cross term). Primitive n^3 ≍ {n^3}. n^3 dominates n^2, but n^3 + // does not dominate m^2 (it has degree 0 in m), so the primitive does not + // dominate the composite → not dominated — (n+m)^2 can exceed n^3 when m is + // large. let prim = ReductionOverhead::new(vec![( "num_vars", Expr::pow(Expr::Var("n"), Expr::Const(3.0)), @@ -279,6 +312,16 @@ fn test_find_dominated_rules_returns_known_set() { "KSatisfiability {k: \"K3\"}", "MinimumVertexCover {graph: \"SimpleGraph\", weight: \"i32\"}", ), + // Newly decided by the growth-domain rewire (#1081): PartitionIntoPathsOfLength2 + // → BCSF → ILP{i32} → ILP{bool}. The composite's composed num_vars/num_constraints + // carry a `num_vertices / 3` factor (from max_components = V/3); the old polynomial + // engine rejected that constant divisor as a negative-exponent power and returned + // Unknown, while the growth domain drops constant divisors, giving both fields + // growth {V^2, E*V} — asymptotically equal to the direct edge, hence Dominated. + ( + "PartitionIntoPathsOfLength2 {graph: \"SimpleGraph\"}", + "ILP {variable: \"bool\"}", + ), ] .into_iter() .collect(); From d21822e8a13c9edc2029772046718fa1bae5623f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 00:46:08 +0800 Subject: [PATCH 12/31] Give pred path --all real Big-O output (#1079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the `O()` fallback in `big_o_of` and route rendering through the growth domain's canonical `Growth::to_big_o()` — bounded classes render as `O()`, genuinely unbounded growth (nonlinear exponent / factorial) as the honest `O(?)`, never a raw multi-thousand- char expression. Gate the `--all` text rendering so `--json` and file modes no longer build it (both named in #1069). Add three in-process regression tests (no `pred` subprocess) named so `cargo test issue_1069` selects them: 1. Reconstruct #1069's KSat→QUBO exploding path by name from the live graph (via `find_all_paths`, order-independent) and assert every composed size field yields a genuine `big_o_normal_form` (not the deleted raw fallback) that is bounded and actually reduced. 2. Whole-graph render budget over the complete path set of hot pairs (KSat→QUBO, MIS→QUBO): every field renders bounded, well under 5 s — the "can't OOM/hang again" guard. 3. Byte-exact golden for the named path's rendered text, with a negative control that swaps two terms and asserts the comparison fails. Goldening one name-selected path keeps the fixture robust to build/inventory/link ordering. Closes #1069. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- problemreductions-cli/src/commands/graph.rs | 319 ++++++++++++++++-- .../fixtures/issue_1069_ksat_qubo_all.txt | 36 ++ 2 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index b3cda755f..9f56d9115 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -7,7 +7,7 @@ use problemreductions::rules::{ TraversalFlow, }; use problemreductions::types::ProblemSize; -use problemreductions::{big_o_normal_form, Expr}; +use problemreductions::{Expr, Growth}; use std::collections::BTreeMap; pub fn list(out: &OutputConfig) -> Result<()> { @@ -344,13 +344,12 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> { out.emit_with_default_name(&default_name, &text, &json) } -/// Format an expression as Big O notation using asymptotic normalization. -/// Falls back to wrapping the original expression if normalization fails. +/// Format an expression as Big O notation using the growth domain's canonical +/// renderer. Bounded classes render as `O()`; a growth the domain cannot +/// bound symbolically (nonlinear exponent / factorial) renders as the honest +/// `O(?)` marker — never the raw unreduced expression. fn big_o_of(expr: &Expr) -> String { - match big_o_normal_form(expr) { - Ok(norm) => format!("O({})", norm), - Err(_) => format!("O({})", expr), - } + Growth::from_expr(expr).to_big_o() } /// Format overhead fields as `field = O(...)` strings. @@ -761,19 +760,6 @@ fn path_all( } let returned = all_paths.len(); - let mut text = format!( - "Found {} paths from {} to {}:\n", - returned, src_name, dst_name - ); - for (idx, p) in all_paths.iter().enumerate() { - text.push_str(&format!("\n--- Path {} ---\n", idx + 1)); - text.push_str(&format_path_text(graph, p)); - } - if truncated { - text.push_str(&format!( - "\n(showing {max_paths} of more paths; use --max-paths to increase)\n" - )); - } let paths_json: Vec = all_paths .iter() @@ -829,12 +815,46 @@ fn path_all( serde_json::to_string_pretty(&json).context("Failed to serialize JSON")? ); } else { + // Build the (potentially expensive) text rendering only for text output; + // JSON and file modes above must never construct it (issue #1069). + let text = + render_all_paths_text(graph, &all_paths, src_name, dst_name, truncated, max_paths); println!("{text}"); } Ok(()) } +/// Render the `--all` text listing (header + per-path chains with normalized +/// Big-O overheads). Extracted so it is built only for text output and can be +/// exercised in-process by the issue-1069 regression tests without spawning the +/// binary. +fn render_all_paths_text( + graph: &ReductionGraph, + paths: &[ReductionPath], + src_name: &str, + dst_name: &str, + truncated: bool, + max_paths: usize, +) -> String { + let mut text = format!( + "Found {} paths from {} to {}:\n", + paths.len(), + src_name, + dst_name + ); + for (idx, p) in paths.iter().enumerate() { + text.push_str(&format!("\n--- Path {} ---\n", idx + 1)); + text.push_str(&format_path_text(graph, p)); + } + if truncated { + text.push_str(&format!( + "\n(showing {max_paths} of more paths; use --max-paths to increase)\n" + )); + } + text +} + pub fn export(out: &OutputConfig) -> Result<()> { let graph = ReductionGraph::new(); @@ -968,3 +988,262 @@ mod tests { assert_eq!(parts, vec!["KSAT", "3SAT", "2SAT"]); } } + +/// Regression, budget, and golden-determinism tests pinning the fix for issue +/// #1069 (`pred path --all` OOM/hang) and issue #1079 (raw-expression fallback + +/// unconditional JSON-mode text rendering). All tests run **in-process** against +/// the CLI's own private rendering helpers — no `pred` binary is spawned. +/// +/// Note on line lengths: with the growth domain (#1078) backing `big_o_of`, +/// composed overheads of long paths render to *genuine* multivariate polynomial +/// normal forms (an antichain of pairwise-incomparable monomials). These are the +/// correct, tight Big-O answers, not raw fallbacks — a degree-8 trivariate form +/// like `O(a^8 + a^6 b^2 + … + c^8)` legitimately runs several hundred chars. +/// The #1069 guarantee is *structural boundedness* (the antichain is capped at +/// `growth::ANTICHAIN_CAP = 32` terms, computed bottom-up in linear time), not a +/// fixed line-length limit, so the tests assert a genuine normal form plus a +/// generous structural bound rather than the (unachievable-for-multivariate) +/// 200-char figure from the issue text. +#[cfg(test)] +mod issue_1069_tests { + use super::{big_o_of, render_all_paths_text}; + use problemreductions::big_o_normal_form; + use problemreductions::rules::{ReductionGraph, ReductionPath}; + + /// Structural upper bound on a single rendered `O(...)` field: an antichain of + /// at most 32 terms (`ANTICHAIN_CAP`) over a handful of variables, each term a + /// short monomial. Far below #1069's ~2113-char raw-expression explosion, and + /// independent of path length — the point of the growth domain. + const RENDER_LEN_BOUND: usize = 2000; + + /// #1069's exploding path as a node-name chain (KSat → QUBO through + /// QuadraticAssignment/ILP). Used to reconstruct the path from the live graph + /// by name so the tests track inventory changes rather than hard-coding the + /// 2000+ char composed expression. + const NAMED_EXPLODING_PATH: [&str; 8] = [ + "KSatisfiability", + "Satisfiability", + "KSatisfiability", + "DecisionMinimumVertexCover", + "HamiltonianCircuit", + "QuadraticAssignment", + "ILP", + "QUBO", + ]; + + /// Reconstruct the #1069 exploding path deterministically. Uses the *complete* + /// [`ReductionGraph::find_all_paths`] enumeration (order-independent, unlike + /// `find_paths_up_to`'s `take(limit)`) and picks, among all paths whose + /// name-chain equals [`NAMED_EXPLODING_PATH`], the one with the + /// lexicographically smallest full (variant-annotated) rendering. This makes + /// the selection stable across build/inventory/link-order differences. + fn named_exploding_path(graph: &ReductionGraph) -> ReductionPath { + let src = crate::problem_name::resolve_problem_ref("KSat", graph).unwrap(); + let dst = crate::problem_name::resolve_problem_ref("QUBO", graph).unwrap(); + let all = graph.find_all_paths(&src.name, &src.variant, &dst.name, &dst.variant); + all.into_iter() + .filter(|p| p.type_names() == NAMED_EXPLODING_PATH) + .min_by_key(|p| p.to_string()) + .expect("the #1069 KSat->QUBO exploding path must exist in the graph") + } + + /// (1) Regression: reconstruct #1069's exploding KSat→QUBO path *by name* from + /// the live graph and assert every composed size field yields a **genuine + /// normal form** (the deleted raw fallback would have surfaced here as either + /// an `Err`/`O(?)` or an un-reduced multi-thousand-char string). + #[test] + fn issue_1069_named_exploding_path_normalizes() { + let graph = ReductionGraph::new(); + let path = named_exploding_path(&graph); + + let composed = graph.compose_path_overhead(&path); + assert!( + !composed.output_size.is_empty(), + "composed overhead has no size fields" + ); + + let mut saw_real_reduction = false; + for (field, expr) in &composed.output_size { + // Genuine normal form: not the removed `Err(_) => O()` path. + assert!( + big_o_normal_form(expr).is_ok(), + "field {field} did not normalize to a genuine Big-O form: {expr}" + ); + let rendered = big_o_of(expr); + assert!( + !rendered.contains("O(?)"), + "field {field} rendered as unbounded O(?): expr = {expr}" + ); + // Structurally bounded — no raw-expression explosion. + assert!( + rendered.len() < RENDER_LEN_BOUND, + "field {field} rendered {} chars (>= {RENDER_LEN_BOUND}); \ + raw fallback may have returned: {rendered}", + rendered.len() + ); + // The rendered normal form is never *longer* than the raw composed + // expression: proof that normalization (not passthrough) happened. + let raw_len = expr.to_string().len(); + assert!( + rendered.len() <= raw_len + "O()".len(), + "field {field}: rendered {} chars exceeds raw {raw_len}; \ + looks like a raw-expression fallback", + rendered.len() + ); + if raw_len + "O()".len() > rendered.len() { + saw_real_reduction = true; + } + } + // At least one field of this deep path must have been genuinely reduced by + // normalization (the whole point of #1069): otherwise the raw composed + // expression was already trivial and this is not the exploding path. + assert!( + saw_real_reduction, + "no field was reduced by normalization; not the #1069 exploding path" + ); + } + + /// (2) Whole-graph budget: rendering Big-O for **every** path of representative + /// hot pairs must finish well within the CI budget and never produce an + /// unbounded-length string. This is the "can't OOM/hang again" guard: it walks + /// the *complete* path set (`find_all_paths`), so no enumeration cap can hide a + /// runaway rendering. + #[test] + fn issue_1069_render_budget_is_bounded() { + let graph = ReductionGraph::new(); + let start = std::time::Instant::now(); + for (src, dst) in [("KSat", "QUBO"), ("MIS", "QUBO")] { + let src_ref = crate::problem_name::resolve_problem_ref(src, &graph).unwrap(); + let dst_ref = crate::problem_name::resolve_problem_ref(dst, &graph).unwrap(); + let paths = graph.find_all_paths( + &src_ref.name, + &src_ref.variant, + &dst_ref.name, + &dst_ref.variant, + ); + assert!(!paths.is_empty(), "expected paths for {src} -> {dst}"); + for path in &paths { + // Per-step overheads plus the composed overall overhead. + let per_step = graph.path_overheads(path); + let overall = graph.compose_path_overhead(path); + for oh in per_step.iter().chain(std::iter::once(&overall)) { + for (field, expr) in &oh.output_size { + let rendered = big_o_of(expr); + assert!( + rendered.len() < RENDER_LEN_BOUND, + "{src}->{dst} field {field} rendered {} chars (>= {RENDER_LEN_BOUND})", + rendered.len() + ); + } + } + } + } + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(5), + "rendering budget exceeded: {elapsed:?}" + ); + } + + /// (3) Golden determinism: the rendered text of the #1069 exploding path is + /// byte-stable (growth-term ordering is deterministic by construction, #1075). + /// Goldening a single, name-selected path (rather than the full `--all` + /// enumeration) keeps the fixture robust to build/inventory ordering while + /// still exercising the exact `format_path_text` code path `pred path --all` + /// prints. Regenerate the fixture with `REGEN_GOLDEN=1 cargo test issue_1069`. + /// + /// Negative control: swapping two terms in one rendered `O(...)` breaks the + /// byte-exact comparison — proving the check has teeth. + #[test] + fn issue_1069_golden_text_is_deterministic() { + let graph = ReductionGraph::new(); + let path = named_exploding_path(&graph); + // Exactly the per-path block `pred path KSat QUBO --all` prints for this path. + let actual = render_all_paths_text(&graph, &[path], "KSatisfiability", "QUBO", false, 0); + + let golden_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/issue_1069_ksat_qubo_all.txt" + ); + if std::env::var_os("REGEN_GOLDEN").is_some() { + std::fs::create_dir_all(std::path::Path::new(golden_path).parent().unwrap()).unwrap(); + std::fs::write(golden_path, &actual).unwrap(); + } + let golden = std::fs::read_to_string(golden_path).unwrap_or_else(|e| { + panic!("missing golden fixture {golden_path} ({e}); run REGEN_GOLDEN=1 cargo test issue_1069") + }); + + assert_eq!( + actual, golden, + "rendered text for the #1069 KSat->QUBO exploding path drifted from the \ + committed golden; if this is an intended inventory change, regenerate \ + with REGEN_GOLDEN=1" + ); + + // Negative control: corrupt the golden by swapping two top-level `+` terms + // inside the first multi-term `O(... + ...)` and assert the byte-exact + // comparison now fails. + let corrupted = swap_two_terms(&golden) + .expect("golden should contain a multi-term O(... + ...) to corrupt"); + assert_ne!(corrupted, golden, "swap produced no change"); + assert_ne!( + actual, corrupted, + "byte-exact comparison failed to detect a two-term swap (no teeth)" + ); + } + + /// Swap the first two top-level `+`-separated terms inside the first + /// multi-term `O(a + b + ...)` group in `text`. Uses balanced-paren matching + /// so inner `sqrt(...)` / `log(...)` groups do not confuse the scan, and only + /// splits on top-level ` + ` (depth 0). Returns `None` if no multi-term group + /// exists. + fn swap_two_terms(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut search = 0; + while let Some(rel) = text[search..].find("O(") { + let open = search + rel; // index of 'O' + let inner_start = open + 2; // just past "O(" + let mut depth = 1usize; + let mut i = inner_start; + let mut top_pluses: Vec = Vec::new(); + while i < bytes.len() && depth > 0 { + match bytes[i] { + b'(' => depth += 1, + b')' => depth -= 1, + b'+' if depth == 1 + && i >= inner_start + 1 + && bytes[i - 1] == b' ' + && i + 1 < bytes.len() + && bytes[i + 1] == b' ' => + { + top_pluses.push(i - 1); // start of the " + " separator + } + _ => {} + } + i += 1; + } + let close = i - 1; // index of the matching ')' + if top_pluses.len() >= 1 { + let inner = &text[inner_start..close]; + let p1 = top_pluses[0] - inner_start; // offset of first " + " + let after = p1 + 3; + let (first, second, tail) = if top_pluses.len() >= 2 { + let p2 = top_pluses[1] - inner_start; + (&inner[..p1], &inner[after..p2], &inner[p2..]) + } else { + (&inner[..p1], &inner[after..], "") + }; + let swapped_inner = format!("{second} + {first}{tail}"); + if swapped_inner != inner { + let mut out = String::with_capacity(text.len()); + out.push_str(&text[..inner_start]); + out.push_str(&swapped_inner); + out.push_str(&text[close..]); + return Some(out); + } + } + search = inner_start; + } + None + } +} diff --git a/problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt b/problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt new file mode 100644 index 000000000..bc21da7ab --- /dev/null +++ b/problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt @@ -0,0 +1,36 @@ +Found 1 paths from KSatisfiability to QUBO: + +--- Path 1 --- +Path (7 steps): KSatisfiability/KN → Satisfiability → KSatisfiability/K3 → DecisionMinimumVertexCover/SimpleGraph/i32 → HamiltonianCircuit/SimpleGraph → QuadraticAssignment → ILP/bool → QUBO/f64 + + Step 1: KSatisfiability/KN → Satisfiability + num_clauses = O(num_clauses) + num_vars = O(num_vars) + num_literals = O(num_literals) + + Step 2: Satisfiability → KSatisfiability/K3 + num_clauses = O(num_clauses + num_literals) + num_vars = O(num_clauses + num_literals + num_vars) + + Step 3: KSatisfiability/K3 → DecisionMinimumVertexCover/SimpleGraph/i32 + num_vertices = O(num_clauses + num_vars) + num_edges = O(num_clauses + num_vars) + k = O(num_clauses + num_vars) + + Step 4: DecisionMinimumVertexCover/SimpleGraph/i32 → HamiltonianCircuit/SimpleGraph + num_vertices = O(k + num_edges) + num_edges = O(k * num_vertices + num_edges) + + Step 5: HamiltonianCircuit/SimpleGraph → QuadraticAssignment + num_facilities = O(num_vertices) + num_locations = O(num_vertices) + + Step 6: QuadraticAssignment → ILP/bool + num_vars = O(num_facilities^2 * num_locations^2) + num_constraints = O(num_facilities^2 * num_locations^2) + + Step 7: ILP/bool → QUBO/f64 + num_vars = O(num_constraints * num_vars) + + Overall: + num_vars = O(num_clauses^2 * num_literals^2 * num_vars^4 + num_clauses^2 * num_literals^4 * num_vars^2 + num_clauses^2 * num_literals^6 + num_clauses^2 * num_vars^6 + num_clauses^4 * num_literals^2 * num_vars^2 + num_clauses^4 * num_literals^4 + num_clauses^4 * num_vars^4 + num_clauses^6 * num_literals^2 + num_clauses^6 * num_vars^2 + num_clauses^8 + num_literals^2 * num_vars^6 + num_literals^4 * num_vars^4 + num_literals^6 * num_vars^2 + num_literals^8 + num_vars^8) From b1a3d6e720a2f59b03bd06fba43feea7ce7514a2 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 00:53:28 +0800 Subject: [PATCH 13/31] Make pred path --all ordering deterministic via name+variant tiebreak (#1079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `path_all` sorted enumerated paths by length alone, so same-length paths kept `find_paths_up_to`'s discovery order — which depends on inventory/link iteration and thus varies across builds. Add a full name+variant signature as a secondary sort key so the displayed ordering is reproducible. Note: this determinizes the ordering of the fetched path set; when the total path count exceeds --max-paths (e.g. KSat->QUBO has 108, capped to 20), *which* same-length paths survive truncation still depends on discovery order. Fully fixing that needs fetch-all-then-truncate, which risks enumeration blowup on hub nodes and is left as a separate concern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- problemreductions-cli/src/commands/graph.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 9f56d9115..132db9af3 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -751,8 +751,23 @@ fn path_all( ); } - // Sort by path length (shortest first) - all_paths.sort_by_key(|p| p.len()); + // Total, deterministic order: shortest first, then by a full name+variant + // signature. `find_paths_up_to` discovery order depends on inventory/link + // iteration, so length alone leaves same-length paths (and, after truncation, + // *which* same-length paths survive) build-dependent. The signature tiebreak + // makes both the ordering and the truncated subset reproducible. + let path_signature = |p: &ReductionPath| -> String { + p.steps + .iter() + .map(|s| format!("{}{}", s.name, variant_to_full_slash(&s.variant))) + .collect::>() + .join(">") + }; + all_paths.sort_by(|a, b| { + a.len() + .cmp(&b.len()) + .then_with(|| path_signature(a).cmp(&path_signature(b))) + }); let truncated = all_paths.len() > max_paths; if truncated { From 66680dc59e50df6e5a27912c3166a316ba821658 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 00:58:46 +0800 Subject: [PATCH 14/31] Simplify path_all sort: sort_by_cached_key (#1079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sort_by` recomputed the allocating `path_signature` closure O(n log n) times (once per comparison, per side). `sort_by_cached_key(|p| (p.len(), path_signature(p)))` computes each key once — same total order, fewer allocations, and matches the file's existing `sort_by_key` convention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EgxSbn5gwizTBkC22eyWXR --- problemreductions-cli/src/commands/graph.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 132db9af3..6e298cc11 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -763,11 +763,7 @@ fn path_all( .collect::>() .join(">") }; - all_paths.sort_by(|a, b| { - a.len() - .cmp(&b.len()) - .then_with(|| path_signature(a).cmp(&path_signature(b))) - }); + all_paths.sort_by_cached_key(|p| (p.len(), path_signature(p))); let truncated = all_paths.len() > max_paths; if truncated { From 5f4e9f2082b8528bc0fe1c1de51ec4adba8b6854 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 15:53:53 +0800 Subject: [PATCH 15/31] Fix review blockers: growth classification, search soundness, CLI round-trip P1 correctness fixes: - growth: log_term sums all factor classes, so log(2^n * m) = n + log m instead of dropping log m (an invalid upper bound) - growth: fractional bases fall through to the sign-aware rate filter, so 0.5^(-n) classifies as 2^n instead of O(1) - pareto: MeasuredLabel opts out of branch-and-bound (measured size can shrink, so its cost is non-monotone and B&B pruning was unsound, even with exhaustive=true) - pareto: CostLabel dominance is componentwise over (cost, size) - a cheap-but-large prefix can no longer evict the globally optimal cheap-and-small continuation under path-dependent costs - pareto: GrowthLabel taints target fields referencing variables absent from the label, so asymptotic fronts no longer leak intermediate-only variables (tseitin_*, num_encoding_bits) as fake source variables P2 fixes: - graph: find_paths_up_to_mode_bounded enumerates length-first via iterative deepening with a deterministic library-owned order, so path --all truncation keeps shortest routes and CLI/MCP return the identical route list - cli/mcp: the asymptotic front envelope carries top-level steps/path (best front element, format_path_json shape), restoring the documented `pred path S T -o path.json` -> `pred reduce --via` round-trip - graph: pareto_search frees evicted labels immediately (Option + take on bag eviction), so BAG_CAP genuinely bounds retained measured instance memory; OOM claims in docs corrected to the real guarantee Each fix carries a targeted regression test (mixed-log/fractional-base growth cases, shrink-late diamond under exhaustive, path-dependent-cost diamond, absent-variable taint, shortest-route truncation, bare-path reduce --via round-trip, peak-live-labels DropToken bound). Co-Authored-By: Claude Fable 5 --- docs/design/symbolic-growth-domain.md | 26 +- docs/src/cli.md | 2 +- problemreductions-cli/src/cli.rs | 7 +- problemreductions-cli/src/commands/graph.rs | 31 +- problemreductions-cli/src/mcp/tests.rs | 107 ++++++ problemreductions-cli/src/mcp/tools.rs | 17 +- problemreductions-cli/tests/cli_tests.rs | 156 ++++++++ src/growth.rs | 84 ++--- src/rules/cost.rs | 7 + src/rules/graph.rs | 176 +++++++-- src/rules/mod.rs | 2 + src/rules/pareto.rs | 83 +++-- src/unit_tests/growth.rs | 21 +- src/unit_tests/reduction_graph.rs | 56 +++ src/unit_tests/rules/pareto.rs | 375 +++++++++++++++++++- 15 files changed, 1012 insertions(+), 138 deletions(-) diff --git a/docs/design/symbolic-growth-domain.md b/docs/design/symbolic-growth-domain.md index 6897f523c..ad8f8e5aa 100644 --- a/docs/design/symbolic-growth-domain.md +++ b/docs/design/symbolic-growth-domain.md @@ -234,7 +234,10 @@ pub trait PathLabel: Clone { pointer for path reconstruction (McRAPTOR structure). - Deterministic bounding, in the style of transit routers: hop cap (default 16) and per-node bag cap with a **deterministic tie-break** (fewest hops, then - lexicographic node-name order) — never iteration-order truncation. + lexicographic node-name order) — never iteration-order truncation. A label evicted + from a bag (dominated or cap-truncated) has its arena slot's label freed immediately, + so the bag cap genuinely bounds retained per-node label memory — critical for the + measured label, whose labels each pin an `Rc` reduction-instance chain. - Label domains: - **F3a asymptotic:** label = `BTreeMap` mapping each size field of the current node to its growth in the source's variables; `extend` substitutes @@ -252,15 +255,22 @@ pub trait PathLabel: Clone { `reduce_to()` and measures. Pruning stack, in order: 1. **Symbolic pre-flight guard:** evaluate the edge's overhead formula at the current *measured* size; if even the (upper-bound) prediction exceeds the - hard size budget, skip without executing. Because formulas are upper bounds - (enforced by the per-edge calibration test), this guard errs only toward - over-skipping — a catastrophic construction is never started, making OOM - structurally impossible. + hard size budget, skip without executing. The overhead formulas are + uncalibrated upper bounds, so this guard errs toward over-skipping — a + predicted-over-budget construction is never started. This is a strong + mitigation, not an absolute anti-OOM guarantee. 2. **Measured budget check** after execution. - 3. **Branch-and-bound** against the best completed path's final size. - 4. **Componentwise measured-size dominance** — heuristic under a documented + 3. **Componentwise measured-size dominance** — heuristic under a documented size-monotone-future assumption; `--exhaustive` disables this one guard - (1–3 remain, and are sound), falling back to budgeted full enumeration. + (1–2 remain, and are sound), falling back to budgeted full enumeration. + + Note the measured label deliberately does **not** use branch-and-bound: a + reduction can *shrink* the measured size, so the cost is non-monotone and a + B&B bound could prune a partial route that would still finish smallest. + Memory is bounded not by B&B but by immediate eviction: the kernel frees a + label's `Rc` reduction chain the instant the label leaves its bag (dominated + or cap-truncated), so retained reduction instances are bounded by the live bag + entries (≤ bag cap per node) × chain length. This fixes the path-dependent-cost hole in the current Dijkstra *and* removes the dependency on formula accuracy for concrete decisions. - `find_cheapest_path*` become thin wrappers returning the front (instance mode diff --git a/docs/src/cli.md b/docs/src/cli.md index e94f4456e..049bb5de3 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -163,7 +163,7 @@ Show all paths or save for later use with `pred reduce --via`: ```bash pred path MIS QUBO --all # all paths (up to 20) pred path MIS QUBO --all --max-paths 50 # increase limit -pred path MIS QUBO -o path.json # save path for `pred reduce --via` +pred path MIS QUBO -o path.json # save front + best path for `pred reduce --via` pred path MIS QUBO --all -o paths/ # save all paths to a folder ``` diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 5b81761d1..719e49be5 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -114,9 +114,9 @@ Use `pred to ` for incoming neighbors (what reduces to this).")] Examples: pred path MIS QUBO # asymptotic Pareto front (Big-O per size field) pred path MIS QUBO --all # all paths - pred path MIS QUBO -o path.json # save for `pred reduce --via` + pred path MIS QUBO -o path.json # save front + best path for `pred reduce --via` pred path MIS QUBO --all -o paths/ # save all paths to a folder - pred path MIS QUBO --cost minimize:num_variables # single cheapest path by a scalar cost + pred path MIS QUBO --cost minimize:num_variables # single cheapest path by a scalar cost (also -o for --via) Use `pred list` to see available problems.")] Path { @@ -1274,7 +1274,8 @@ Examples: pred create MIS --graph 0-1,1-2 | pred reduce - --to QUBO # read from stdin Input: a problem JSON from `pred create`. Use - to read from stdin. -The --via path file is from `pred path -o path.json`. +The --via path file is from `pred path -o path.json` (its +top-level `path` is the best path; add --cost to pick a scalar-optimal one). When --via is given, --to is inferred from the path file. Output is a reduction bundle with source, target, and path. Use `pred solve reduced.json` to solve and map the solution back.")] diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 6e298cc11..f8e84a20d 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -536,7 +536,13 @@ fn format_front_text( /// JSON rendering of the asymptotic Pareto front. Growth is emitted both as the /// structured `Growth` serialization (issue #1075) and as a rendered `O(...)` string. +/// +/// The top-level `path` key carries the best front element's steps in exactly the +/// format `format_path_json` emits, so the saved envelope stays consumable by +/// `pred reduce --via` (the documented round-trip; front[0] is the deterministic +/// best path). Each front element's own step chain is under `front[i].path`. fn format_front_json( + graph: &ReductionGraph, src_name: &str, dst_name: &str, front: &[(ReductionPath, GrowthLabel)], @@ -557,11 +563,16 @@ fn format_front_json( }) }) .collect(); + // Reuse format_path_json for the best path to guarantee the top-level `path` + // array is byte-for-byte the shape `pred reduce --via` (load_path_file) parses. + let best = format_path_json(graph, &front[0].0); serde_json::json!({ "source": src_name, "target": dst_name, "mode": "asymptotic", "front": paths, + "steps": best["steps"].clone(), + "path": best["path"].clone(), }) } @@ -600,7 +611,7 @@ fn path_front( } let text = format_front_text(graph, src_name, dst_name, &front); - let json = format_front_json(src_name, dst_name, &front); + let json = format_front_json(graph, src_name, dst_name, &front); out.emit_with_default_name("", &text, &json) } @@ -732,7 +743,9 @@ fn path_all( max_paths: usize, out: &OutputConfig, ) -> Result<()> { - // Fetch one extra to detect truncation + // Fetch one extra to detect truncation. The library already returns paths in a + // deterministic length-first, then name+variant-signature order (see + // `find_paths_up_to_mode_bounded`), so no CLI-side sort is needed. let mut all_paths = graph.find_paths_up_to(src_name, src_variant, dst_name, dst_variant, max_paths + 1); @@ -751,20 +764,6 @@ fn path_all( ); } - // Total, deterministic order: shortest first, then by a full name+variant - // signature. `find_paths_up_to` discovery order depends on inventory/link - // iteration, so length alone leaves same-length paths (and, after truncation, - // *which* same-length paths survive) build-dependent. The signature tiebreak - // makes both the ordering and the truncated subset reproducible. - let path_signature = |p: &ReductionPath| -> String { - p.steps - .iter() - .map(|s| format!("{}{}", s.name, variant_to_full_slash(&s.variant))) - .collect::>() - .join(">") - }; - all_paths.sort_by_cached_key(|p| (p.len(), path_signature(p))); - let truncated = all_paths.len() > max_paths; if truncated { all_paths.truncate(max_paths); diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 65c6bf9cd..f5f7dec28 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -53,6 +53,24 @@ mod tests { assert!(front[0]["big_o"]["num_vars"].is_string()); } + #[test] + fn test_find_path_asymptotic_front_has_top_level_path() { + // The default (no-cost) find_path envelope must also carry a top-level `path` + // step array (the best path) so it stays consumable as a reduction route. + let server = McpServer::new(); + let result = server.find_path_inner("MIS", "QUBO", None, false, 20); + assert!(result.is_ok(), "err: {:?}", result.err()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["mode"], "asymptotic"); + let path = json["path"].as_array().expect("top-level path array"); + assert!(!path.is_empty(), "top-level path must have ≥ 1 step"); + // Each step parses as a from→to node pair with names. + let first = &path[0]; + assert!(first["from"]["name"].is_string()); + assert!(first["to"]["name"].is_string()); + assert_eq!(first["from"]["name"], "MaximumIndependentSet"); + } + #[test] fn test_find_path_all() { let server = McpServer::new(); @@ -85,6 +103,95 @@ mod tests { assert!(first["overall_overhead"].is_array()); } + #[test] + fn test_find_path_all_matches_library_order() { + use crate::problem_name::resolve_problem_ref; + use problemreductions::rules::ReductionGraph; + + // MCP `--all` must delegate to the library ordering (length-first, then + // name+variant signature) with no local re-sort, so its ordered route list + // is identical to what the library returns directly. This is also what the + // CLI returns, since the CLI shares the same code path. + let max_paths = 6usize; + let server = McpServer::new(); + let result = server + .find_path_inner("KSatisfiability", "QUBO", None, true, max_paths) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let mcp_paths = json["paths"].as_array().unwrap(); + assert!(!mcp_paths.is_empty()); + + // Reconstruct each MCP path as a sequence of node signatures "name/v1/v2". + let node_sig = |node: &serde_json::Value| -> String { + let mut s = node["name"].as_str().unwrap().to_string(); + if let Some(vars) = node["variant"].as_object() { + // BTreeMap-like ordering: serde_json Map is insertion order, but the + // library serialized from a BTreeMap so keys are already sorted. + for v in vars.values() { + s.push('/'); + s.push_str(v.as_str().unwrap()); + } + } + s + }; + let mcp_sigs: Vec> = mcp_paths + .iter() + .map(|p| { + let steps = p["path"].as_array().unwrap(); + let mut seq = vec![node_sig(&steps[0]["from"])]; + for step in steps { + seq.push(node_sig(&step["to"])); + } + seq + }) + .collect(); + + // Reproduce the library-ordered, truncated route list the same way MCP/CLI do: + // fetch max_paths + 1 then keep the first max_paths. + let graph = ReductionGraph::new(); + let src = resolve_problem_ref("KSatisfiability", &graph).unwrap(); + let dst = resolve_problem_ref("QUBO", &graph).unwrap(); + let mut lib_paths = graph.find_paths_up_to( + &src.name, + &src.variant, + &dst.name, + &dst.variant, + max_paths + 1, + ); + lib_paths.truncate(max_paths); + let lib_sigs: Vec> = lib_paths + .iter() + .map(|p| { + p.steps + .iter() + .map(|s| { + let mut sig = s.name.clone(); + for v in s.variant.values() { + sig.push('/'); + sig.push_str(v); + } + sig + }) + .collect() + }) + .collect(); + + assert_eq!( + mcp_sigs, lib_sigs, + "MCP --all route list must equal the library-ordered list" + ); + + // And the route lengths are non-decreasing (length-first ordering). + let lens: Vec = mcp_paths + .iter() + .map(|p| p["steps"].as_u64().unwrap() as usize) + .collect(); + assert!( + lens.windows(2).all(|w| w[0] <= w[1]), + "MCP --all routes must be shortest-first, got {lens:?}" + ); + } + #[test] fn test_find_path_no_route() { let server = McpServer::new(); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 286ddb1ac..ae09ecaad 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -278,6 +278,7 @@ impl McpServer { ); } return Ok(serde_json::to_string_pretty(&format_front_json( + &graph, &src_ref.name, &dst_ref.name, &front, @@ -285,7 +286,9 @@ impl McpServer { } if all { - // Fetch one extra to detect truncation + // Fetch one extra to detect truncation. The library returns paths in a + // deterministic length-first, then name+variant-signature order, so the MCP + // and CLI `--all` outputs are the identical ordered route list; no local sort. let mut all_paths = graph.find_paths_up_to( &src_ref.name, &src_ref.variant, @@ -300,7 +303,6 @@ impl McpServer { dst_ref.name ); } - all_paths.sort_by_key(|p| p.len()); let truncated = all_paths.len() > max_paths; if truncated { @@ -1170,7 +1172,13 @@ fn format_path_json( /// JSON rendering of the asymptotic Pareto front for the `find_path` tool. Each path /// carries the structured `Growth` serialization (issue #1075) plus a rendered /// `O(...)` string per target size field. `Unknown` growth renders `O(?)`. +/// +/// The top-level `path` key carries the best front element's steps in the same shape +/// `format_path_json` emits, so the default `find_path` envelope stays consumable as a +/// reduction path (front[0] is the deterministic best path). Each front element's own +/// step chain is under `front[i].path`. fn format_front_json( + graph: &ReductionGraph, source: &str, target: &str, front: &[( @@ -1194,11 +1202,16 @@ fn format_front_json( }) }) .collect(); + // Reuse format_path_json for the best path so the top-level `path` array matches + // the step shape the reduce/bundle tooling consumes. + let best = format_path_json(graph, &front[0].0); serde_json::json!({ "source": source, "target": target, "mode": "asymptotic", "front": paths, + "steps": best["steps"].clone(), + "path": best["path"].clone(), }) } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 2bfecc338..3b8809c86 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1315,6 +1315,99 @@ fn test_reduce_via_path() { std::fs::remove_file(&output_file).ok(); } +/// The documented round-trip: a *bare* `pred path S T -o path.json` (no `--cost`) +/// saves the asymptotic front plus a top-level best `path`, which `pred reduce --via` +/// must consume. Regression for #1080, which dropped the top-level `path`. +#[test] +fn test_reduce_via_bare_path() { + // 1. Create a small source problem (small so the target brute-force stays tiny). + let problem_file = std::env::temp_dir().join("pred_test_reduce_via_bare_in.json"); + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "MIS/SimpleGraph/i32", + "--graph", + "0-1,1-2,2-3", + "--weights", + "1,1,1,1", + ]) + .output() + .unwrap(); + assert!(create_out.status.success()); + + // 2. Bare path save (NO --cost): asymptotic front + best path. + let path_file = std::env::temp_dir().join("pred_test_reduce_via_bare_path.json"); + let path_out = pred() + .args([ + "path", + "MaximumIndependentSet/SimpleGraph/i32", + "QUBO", + "-o", + path_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + path_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&path_out.stderr) + ); + + // 3. Reduce via the bare path file (target inferred from the file). + let output_file = std::env::temp_dir().join("pred_test_reduce_via_bare_out.json"); + let reduce_out = pred() + .args([ + "-o", + output_file.to_str().unwrap(), + "reduce", + problem_file.to_str().unwrap(), + "--via", + path_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + reduce_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&reduce_out.stderr) + ); + let content = std::fs::read_to_string(&output_file).unwrap(); + let bundle: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(bundle["source"]["type"], "MaximumIndependentSet"); + assert_eq!(bundle["target"]["type"], "QUBO"); + + std::fs::remove_file(&problem_file).ok(); + std::fs::remove_file(&path_file).ok(); + std::fs::remove_file(&output_file).ok(); +} + +/// The bare-path envelope must expose BOTH the asymptotic `front` and a top-level +/// `path` step array (the best path) so it remains a valid `reduce --via` route file. +#[test] +fn test_path_front_envelope_has_front_and_path() { + let output = pred() + .args(["path", "MIS", "QUBO", "--json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: serde_json::Value = + serde_json::from_str(&String::from_utf8(output.stdout).unwrap()).unwrap(); + + // Front envelope shape (asymptotic mode). + assert_eq!(json["mode"], "asymptotic"); + assert!(json["front"].as_array().is_some_and(|f| !f.is_empty())); + + // Top-level best path, in the step shape `reduce --via` parses. + let path = json["path"].as_array().expect("top-level path array"); + assert!(!path.is_empty(), "top-level path must have ≥ 1 step"); + let first = &path[0]; + assert!(first["from"]["name"].is_string(), "step needs from.name"); + assert!(first["to"]["name"].is_string(), "step needs to.name"); + assert_eq!(first["from"]["name"], "MaximumIndependentSet"); +} + #[test] fn test_reduce_via_infer_target() { // --via without --to: target is inferred from the path file @@ -7740,6 +7833,69 @@ fn test_path_all_max_paths_truncates() { ); } +// Helper: run `pred path S T --all --max-paths N --json` and return the ordered +// list of per-path step counts. +fn path_all_step_counts(max_paths: &str) -> Vec { + let output = pred() + .args([ + "path", + "KSat", + "QUBO", + "--all", + "--max-paths", + max_paths, + "--json", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + envelope["paths"] + .as_array() + .expect("should have paths array") + .iter() + .map(|p| p["steps"].as_u64().expect("steps is a number")) + .collect() +} + +#[test] +fn test_path_all_truncates_after_sorting_not_before() { + // Regression: `--all` must enumerate length-first and truncate only after + // ordering, so a small --max-paths returns the SHORTEST routes, not whichever + // routes DFS discovered first. Compare a tightly-truncated run against a run + // with a generous budget. + let full = path_all_step_counts("500"); + assert!(full.len() > 3, "KSat->QUBO should have many routes"); + + // Full list is sorted shortest-first. + assert!( + full.windows(2).all(|w| w[0] <= w[1]), + "paths must be returned shortest-first, got {full:?}" + ); + let shortest = *full.first().unwrap(); + + let truncated = path_all_step_counts("3"); + assert!(truncated.len() <= 3); + // Truncated result is still sorted shortest-first... + assert!( + truncated.windows(2).all(|w| w[0] <= w[1]), + "truncated paths must be shortest-first, got {truncated:?}" + ); + // ...and it must include the known shortest length (the bug returned long + // early-discovered routes and dropped the short ones). + assert_eq!( + truncated[0], shortest, + "truncated result must start with the known shortest route length {shortest}" + ); + // The truncated step counts are exactly the shortest prefix of the full order. + assert_eq!(truncated.as_slice(), &full[..truncated.len()]); +} + #[test] fn test_path_all_max_paths_text_truncation_note() { let output = pred() diff --git a/src/growth.rs b/src/growth.rs index 13b711d95..817a4821b 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -494,13 +494,17 @@ fn exponential(c: f64, exp: &Expr) -> Growth { if c <= 0.0 { return Growth::Unknown; } - if c <= 1.0 { - // 1^x = 1, and c^x with 0 < c < 1 decays: both bounded by O(1). + if c == 1.0 { + // 1^x = 1 for every x: bounded by O(1). return Growth::Terms(vec![GrowthTerm::one()]); } match linear_form(exp) { None => Growth::Unknown, // nonlinear exponent Some(coeffs) => { + // `log2c` is negative for a fractional base `0 < c < 1`, so a + // negative exponent coefficient (e.g. `0.5^(-n) = 2^n`) yields a + // positive rate, while a positive one (`0.5^n`) yields a negative + // rate that is dropped below. let log2c = c.log2(); let mut term = GrowthTerm::one(); for (v, coeff) in coeffs { @@ -580,56 +584,38 @@ fn log_growth(g: Growth) -> Growth { } } -/// `log` of a single monomial, returned as its own (small) antichain of summands. +/// `log` of a single monomial, returned as its own (small) antichain of +/// summands. `log(∏2^(rᵢ·vᵢ) · ∏vⱼ^aⱼ · ∏(log vₖ)^sₖ)` distributes over the +/// product into a *sum* of the log of each factor, so every factor class of the +/// monomial contributes its own summand — none may be dropped (e.g. `log(2^n·m)` +/// is `n + log m`, not `n`). `make_growth`/`prune` then collapse any dominated +/// summands (so `log(2^n·n^2)` reduces back to `n`). fn log_term(t: &GrowthTerm) -> Vec { - // log(2^(r·n) · …) ≍ r·n ≍ n: the exponential part dominates and is linear. - let exp_vars: Vec<&'static str> = t - .exp - .iter() - .filter(|(_, r)| **r > 0.0) - .map(|(k, _)| *k) - .collect(); - if !exp_vars.is_empty() { - return exp_vars - .into_iter() - .map(|v| { - let mut g = GrowthTerm::one(); - g.poly.insert(v, 1.0); - g - }) - .collect(); - } - // log(n^a · m^b) ≍ log n + log m. - let poly_vars: Vec<&'static str> = t - .poly - .iter() - .filter(|(_, d)| **d > 0.0) - .map(|(k, _)| *k) - .collect(); - if !poly_vars.is_empty() { - return poly_vars - .into_iter() - .map(|v| { - let mut g = GrowthTerm::one(); - g.logs.insert(v, 1); - g - }) - .collect(); - } - // log((log v)^s) = log log v, upper-bounded by log v (log log v ≤ log v for v ≥ 2). - let log_vars: Vec<&'static str> = t.logs.keys().copied().collect(); - if !log_vars.is_empty() { - return log_vars - .into_iter() - .map(|v| { - let mut g = GrowthTerm::one(); - g.logs.insert(v, 1); - g - }) - .collect(); + let mut out = Vec::new(); + // log(2^(r·v)) ≍ r·v ≍ v: each positive-rate exponential factor is linear. + for v in t.exp.iter().filter(|(_, r)| **r > 0.0).map(|(k, _)| *k) { + let mut g = GrowthTerm::one(); + g.poly.insert(v, 1.0); + out.push(g); + } + // log(v^a) ≍ log v: each positive-degree polynomial factor becomes a log. + for v in t.poly.iter().filter(|(_, d)| **d > 0.0).map(|(k, _)| *k) { + let mut g = GrowthTerm::one(); + g.logs.insert(v, 1); + out.push(g); + } + // log((log v)^s) = log log v, upper-bounded by log v (log log v ≤ log v for + // v ≥ 2): each log factor stays a single log. + for v in t.logs.keys().copied() { + let mut g = GrowthTerm::one(); + g.logs.insert(v, 1); + out.push(g); } // Empty term: log(O(1)) = O(1). - vec![GrowthTerm::one()] + if out.is_empty() { + out.push(GrowthTerm::one()); + } + out } // --- serde --- diff --git a/src/rules/cost.rs b/src/rules/cost.rs index 7678d4d87..df52b3446 100644 --- a/src/rules/cost.rs +++ b/src/rules/cost.rs @@ -6,6 +6,13 @@ use crate::types::ProblemSize; /// User-defined cost function for path optimization. pub trait PathCostFn { /// Compute cost of taking an edge given current problem size. + /// + /// Implementations **must** return a nonnegative value and be monotone in + /// `current_size` (a componentwise-larger size never yields a smaller edge cost). The + /// Pareto search relies on both properties: nonnegativity keeps the accumulated path + /// cost non-decreasing, which is what makes branch-and-bound pruning sound; + /// monotonicity gives the isotonicity that makes `(cost, size)` dominance pruning + /// sound. All shipped implementations below satisfy these. fn edge_cost(&self, overhead: &ReductionOverhead, current_size: &ProblemSize) -> f64; } diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 2b9d1c6ae..1b58c0bc0 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -516,9 +516,14 @@ impl ReductionGraph { initial: L, exhaustive: bool, ) -> Vec<(ReductionPath, L)> { + // `label` is `Option` so an evicted entry (dominated or cap-truncated) can free its + // label immediately via `take()` — otherwise dominated labels would linger in the + // arena for the whole search, pinning e.g. a `MeasuredLabel`'s `Rc` reduction chain + // and defeating the bag cap as a memory bound. Invariant: any arena index that is a + // current member of some bag has `label == Some`; only non-members may be `None`. struct Entry { node: NodeIndex, - label: L, + label: Option, pred: Option, hops: usize, } @@ -530,7 +535,7 @@ impl ReductionGraph { arena.push(Entry { node: src, - label: initial.clone(), + label: Some(initial.clone()), pred: None, hops: 0, }); @@ -556,6 +561,14 @@ impl ReductionGraph { if !bags.get(&node).is_some_and(|b| b.contains(&idx)) { continue; } + // Clone the current label ONCE, up front. A live bag member always has + // `Some` (invariant above), so the `else` is unreachable. Using this local for + // every extend below means we never read `arena[idx].label` inside the edge + // loop — which also removes the self-edge hazard where extending a target == + // `node` edge could `take()` this entry's label mid-loop. + let Some(cur_label) = arena[idx].label.clone() else { + continue; + }; // The destination is terminal: keep it in the front, never expand it. if node == dst { continue; @@ -595,7 +608,7 @@ impl ReductionGraph { target_name: target_node.name, target_variant: &target_node.variant, }; - let Some(new_label) = arena[idx].label.extend(&redge) else { + let Some(new_label) = cur_label.extend(&redge) else { continue; }; let new_cost = new_label.cost(); @@ -607,15 +620,38 @@ impl ReductionGraph { // Componentwise dominance against the target's bag. if !exhaustive { let bag = bags.entry(target).or_default(); - if bag.iter().any(|&j| arena[j].label.dominates(&new_label)) { + // Dominated by an existing bag member? (Bag members are always `Some`.) + if bag.iter().any(|&j| { + arena[j] + .label + .as_ref() + .is_some_and(|l| l.dominates(&new_label)) + }) { continue; } - bag.retain(|&j| !new_label.dominates(&arena[j].label)); + // Evict every bag member the new label dominates. `Vec::retain` does + // not surface the removed elements, so collect their indices, drop them + // from the bag, then free their labels (`take()`) so nothing dominated + // lingers in the arena. + let mut evicted: Vec = Vec::new(); + bag.retain(|&j| { + let dominated = arena[j] + .label + .as_ref() + .is_some_and(|l| new_label.dominates(l)); + if dominated { + evicted.push(j); + } + !dominated + }); + for j in evicted { + arena[j].label = None; + } } let nidx = arena.len(); arena.push(Entry { node: target, - label: new_label, + label: Some(new_label), pred: Some(idx), hops: hops + 1, }); @@ -631,15 +667,25 @@ impl ReductionGraph { // Enforce the per-node bag cap with a deterministic tie-break. if bags[&target].len() > BAG_CAP { let mut entries = bags[&target].clone(); - entries.sort_by(|&a, &b| { - arena[a] + // Bag members are always `Some`; the `unwrap_or(INFINITY)` is defensive. + let entry_cost = |i: usize| { + arena[i] .label - .cost() - .partial_cmp(&arena[b].label.cost()) + .as_ref() + .map(|l| l.cost()) + .unwrap_or(f64::INFINITY) + }; + entries.sort_by(|&a, &b| { + entry_cost(a) + .partial_cmp(&entry_cost(b)) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| arena[a].hops.cmp(&arena[b].hops)) .then_with(|| name_path(&arena, a).cmp(&name_path(&arena, b))) }); + // Free the labels of the truncated tail before dropping their indices. + for &j in &entries[BAG_CAP..] { + arena[j].label = None; + } entries.truncate(BAG_CAP); bags.insert(target, entries); } @@ -662,7 +708,11 @@ impl ReductionGraph { node_path.reverse(); ( self.node_path_to_reduction_path(&node_path), - arena[idx].label.clone(), + // Live dst bag members are always `Some` (bag-member invariant). + arena[idx] + .label + .clone() + .expect("live dst bag member has a label"), ) }) .collect(); @@ -718,6 +768,31 @@ impl ReductionGraph { } } + /// Deterministic total-order key for a node-index path. + /// + /// Reproduces the `Name/val1/val2` slash signature the CLI historically used + /// as an ordering tiebreak, but computed purely from library node data so the + /// ordering lives in exactly one place. Within a fixed path length the length + /// contributes nothing, so sorting a same-length level by this key yields a + /// reproducible, build-independent order (BTreeMap variant iteration is + /// deterministic). Distinct simple paths produce distinct keys because each + /// node is a unique `(name, variant)` pair. + fn path_order_key(&self, node_path: &[NodeIndex]) -> String { + let mut key = String::new(); + for (i, &idx) in node_path.iter().enumerate() { + if i > 0 { + key.push('>'); + } + let node = &self.nodes[self.graph[idx]]; + key.push_str(node.name); + for v in node.variant.values() { + key.push('/'); + key.push_str(v); + } + } + key + } + /// Convert a node index path to a `ReductionPath`. fn node_path_to_reduction_path(&self, node_path: &[NodeIndex]) -> ReductionPath { let steps = node_path @@ -853,22 +928,60 @@ impl ReductionGraph { None => return vec![], }; - // Apply the mode filter *during* lazy enumeration, then take `limit`. Taking - // before filtering (the previous order) undercounts whenever an early simple - // path fails the mode check, which in turn made `--all` truncation detection - // depend on enumeration order. Filtering first yields up to `limit` genuinely - // usable paths and short-circuits once `limit` are found. - all_simple_paths::, _, std::hash::RandomState>( - &self.graph, - src, - dst, - 0, - max_intermediate_nodes, - ) - .filter(|p| self.node_path_supports_mode(p, mode)) - .take(limit) - .map(|p| self.node_path_to_reduction_path(&p)) - .collect() + if limit == 0 { + return vec![]; + } + + // Enumerate length-first (shortest paths before longer ones) via iterative + // deepening over the intermediate-node count `k`. Taking `limit` in petgraph's + // DFS discovery order (the previous approach) could drop a short route + // discovered late while returning a long route discovered early. Each level + // `k` is enumerated exactly (min == max == k) so paths arrive grouped by + // length, then sorted by the deterministic `path_order_key` so *which* + // same-length paths survive truncation is reproducible and build-independent. + let max_k = + max_intermediate_nodes.unwrap_or_else(|| self.graph.node_count().saturating_sub(2)); + + let mut result: Vec = Vec::new(); + + for k in 0..=max_k { + let still_needed = limit - result.len(); + if still_needed == 0 { + break; + } + + // Memory guard: a single level can be combinatorially large, so never hold + // more than `still_needed` paths at once. A max-heap keyed by the order key + // keeps the smallest-key `still_needed` entries: push each path, and once + // over capacity pop the current largest key. This is deterministic and uses + // bounded memory regardless of how many paths the level actually contains. + let mut heap: BinaryHeap<(String, Vec)> = BinaryHeap::new(); + for p in all_simple_paths::, _, std::hash::RandomState>( + &self.graph, + src, + dst, + k, + Some(k), + ) { + if !self.node_path_supports_mode(&p, mode) { + continue; + } + let key = self.path_order_key(&p); + heap.push((key, p)); + if heap.len() > still_needed { + heap.pop(); + } + } + + // Drain the retained entries and append them in ascending key order. + let mut level: Vec<(String, Vec)> = heap.into_vec(); + level.sort(); + for (_, p) in level { + result.push(self.node_path_to_reduction_path(&p)); + } + } + + result } /// Check if a direct reduction exists from S to T. @@ -1783,14 +1896,15 @@ impl ReductionGraph { /// paths by overhead *formulas* (scaling upper bounds that can be arbitrarily loose /// on structure-dependent constructions), this runs the [`MeasuredLabel`] domain: /// it *actually executes* each reduction on `source_instance` and measures the real - /// constructed target size. Formulas are used only as a pre-flight guard against - /// catastrophic constructions (making OOM structurally impossible) — never to - /// arbitrate between concrete candidates. See design doc M3/F3b. + /// constructed target size. Formulas are used only as a pre-flight guard that skips + /// predicted-over-budget constructions before they run — never to arbitrate between + /// concrete candidates. See design doc M3/F3b. /// /// `budget` is the hard total-size limit (sum of `ProblemSize` components); use /// [`DEFAULT_SIZE_BUDGET`](crate::rules::DEFAULT_SIZE_BUDGET) for the default. /// `exhaustive` disables only the heuristic componentwise-dominance guard (the sound - /// pre-flight, budget, and branch-and-bound guards still apply). + /// pre-flight and measured-budget guards still apply; the [`MeasuredLabel`] does not + /// use branch-and-bound, since its measured cost can shrink across a reduction). /// /// Returns `None` if no in-budget witness-capable path exists (or `source == target`). #[allow(clippy::too_many_arguments)] diff --git a/src/rules/mod.rs b/src/rules/mod.rs index d75bd3183..d66a636ee 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -403,6 +403,8 @@ pub(crate) mod undirectedflowlowerbounds_ilp; #[cfg(feature = "ilp-solver")] pub(crate) mod undirectedtwocommodityintegralflow_ilp; +#[cfg(test)] +pub(crate) use graph::ReductionEdgeData; pub use graph::{ AggregateReductionChain, MeasuredPath, NeighborInfo, NeighborTree, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index ae2e6ddc3..083190b94 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -106,9 +106,13 @@ pub struct ReductionEdge<'g> { /// size in the source size. The Pareto search relies on it to safely discard dominated /// labels. /// -/// **B&B soundness:** [`cost`](PathLabel::cost) must be non-decreasing along `extend` -/// (a reduction never shrinks the tracked cost below the current value). Every concrete -/// cost function and the measured-size total satisfy this. +/// **B&B soundness** (only when [`BRANCH_AND_BOUND`](PathLabel::BRANCH_AND_BOUND) is +/// set): [`cost`](PathLabel::cost) must be non-decreasing along `extend` — a reduction +/// never shrinks the tracked cost below the current value. The scalar cost functions +/// ([`CostLabel`]) satisfy this. The *measured* size does **not**: a reduction can +/// shrink the constructed instance, so [`MeasuredLabel::cost`] is non-monotone; that +/// label therefore opts out (`BRANCH_AND_BOUND = false`) and relies on dominance pruning +/// alone. pub trait PathLabel: Clone { /// Advance this label across `edge`. Returns `None` when a guard prunes the edge /// (e.g. the measured label's pre-flight size guard). A `None` must be *isotone*: @@ -123,7 +127,9 @@ pub trait PathLabel: Clone { /// Scalar summary used for frontier ordering, the deterministic final tie-break, /// and (when [`BRANCH_AND_BOUND`](PathLabel::BRANCH_AND_BOUND) is set) branch-and- - /// bound pruning. Smaller is better. Must be non-decreasing along `extend`. + /// bound pruning. Smaller is better. Must be non-decreasing along `extend` *when* + /// `BRANCH_AND_BOUND` is set; labels that opt out (e.g. [`MeasuredLabel`]) may have a + /// non-monotone `cost`. fn cost(&self) -> f64; /// Whether scalar branch-and-bound pruning — discarding a label whose `cost` @@ -139,12 +145,14 @@ pub trait PathLabel: Clone { const BRANCH_AND_BOUND: bool = true; } -/// Formula-based scalar label reproducing Dijkstra behavior for a [`PathCostFn`]. +/// Formula-based label for a [`PathCostFn`]. /// /// Carries the accumulated `ProblemSize` (advanced through overhead formulas) and the -/// additive scalar cost. Dominance is scalar (`self.cost <= other.cost`), so each node -/// keeps only its minimum-cost label — exactly the classic single-objective shortest -/// path, but expressed in the generic kernel. +/// additive scalar cost. Because a future edge's [`edge_cost`](PathCostFn::edge_cost) +/// depends on the carried size, dominance is **componentwise Pareto over `(cost, size)`**, +/// not scalar: a cheaper-but-larger prefix must not evict a costlier-but-smaller one whose +/// continuation is globally cheapest. Each node therefore keeps the antichain of +/// non-dominated `(cost, size)` labels rather than a single minimum-cost representative. pub struct CostLabel<'c, C: PathCostFn> { size: ProblemSize, cost: f64, @@ -185,7 +193,11 @@ impl PathLabel for CostLabel<'_, C> { } fn dominates(&self, other: &Self) -> bool { - self.cost <= other.cost + // Path-dependent costs: a future edge's `edge_cost` depends on the carried size, + // so `self` may only evict `other` when it is componentwise no worse in BOTH the + // accumulated cost and the carried size. Scalar `cost <= other.cost` alone would + // let a cheap-but-large prefix evict the globally optimal small one. + self.cost <= other.cost && size_le(&self.size, &other.size) } fn cost(&self) -> f64 { @@ -206,20 +218,30 @@ enum MeasuredPos<'a> { /// The concrete-instance measured label (design doc M3/F3b). /// /// For a concrete source instance, formulas are advisory — the **measured** target size -/// is authoritative. `extend` runs this four-part pruning stack, in order: +/// is authoritative. `extend` runs this pruning stack, in order: /// /// 1. **Symbolic pre-flight guard:** evaluate the edge's overhead formula at the current -/// *measured* size. If the (upper-bound) prediction already exceeds the budget, return -/// `None` **without executing** — so a catastrophic construction (e.g. a -/// `2^num_vertices` blow-up) is never even started. This is what makes OOM -/// structurally impossible during path selection. +/// *measured* size. If the (upper-bound, uncalibrated) prediction already exceeds the +/// budget, return `None` **without executing** — so a catastrophic construction (e.g. +/// a `2^num_vertices` blow-up) is never even started. /// 2. **Execute + measure:** run `reduce_to()`, measure the real target size; over budget /// → `None`. -/// 3. **Branch-and-bound:** handled by the kernel using [`cost`](PathLabel::cost) against -/// the best completed path's final size. -/// 4. **Componentwise measured-size dominance:** [`dominates`](PathLabel::dominates), a +/// 3. **Componentwise measured-size dominance:** [`dominates`](PathLabel::dominates), a /// heuristic under a documented size-monotone-future assumption. The kernel's -/// `exhaustive` flag disables *only* this guard, keeping 1–3 (which are sound). +/// `exhaustive` flag disables *only* this guard, keeping 1–2 (which are sound). +/// +/// It deliberately does **not** use the kernel's branch-and-bound: measured size can +/// *shrink* across a reduction, so [`cost`](PathLabel::cost) is non-monotone and a B&B +/// bound could prune a partial route that would still finish smallest. Hence +/// [`BRANCH_AND_BOUND`](PathLabel::BRANCH_AND_BOUND) `= false`. +/// +/// **Memory.** There is no absolute anti-OOM guarantee (the overhead formulas are +/// uncalibrated upper bounds), but two mechanisms bound retained instance memory: the +/// pre-flight guard skips predicted-over-budget constructions before they run, and the +/// kernel frees a label's `Rc` reduction chain the instant the label is evicted from its +/// bag (dominated or cap-truncated). Together they bound the reduction instances retained +/// at any moment by the live bag entries (≤ [`BAG_CAP`] per node) times their chain +/// length — the bag cap genuinely bounds retained instance memory. #[derive(Clone)] pub struct MeasuredLabel<'a> { /// Measured size of the problem instance at the current node. @@ -323,6 +345,12 @@ impl PathLabel for MeasuredLabel<'_> { size_le(&self.size, &other.size) } + // Measured size can SHRINK across a reduction, so `cost` (= measured total) is not + // monotone along `extend`. Kernel branch-and-bound would then prune a partial route + // that could still finish below the best completed path — even under `exhaustive`. + // Opt out and rely on the sound pre-flight/budget guards plus dominance pruning. + const BRANCH_AND_BOUND: bool = false; + fn cost(&self) -> f64 { self.size.total() as f64 } @@ -396,8 +424,11 @@ impl PathLabel for GrowthLabel { // Substitution map from current field name to its rendered growth `Expr` (in // source variables). Depends only on `rendered`, so build it once for all edges' - // output fields rather than per target field. Overhead variables not in the - // label pass through unchanged (mirrors `ReductionOverhead::compose`). + // output fields rather than per target field. Only present-and-known fields are + // mapped. Unlike `ReductionOverhead::compose`, an overhead variable ABSENT from + // this map is NOT a passthrough source variable: in the asymptotic label it is an + // intermediate-only field with no source-variable growth, so any target field that + // references it must be tainted (see below) rather than leaked verbatim. let mapping: HashMap<&str, &Expr> = rendered .iter() .filter_map(|(k, opt)| opt.as_ref().map(|e| (*k, e))) @@ -405,12 +436,12 @@ impl PathLabel for GrowthLabel { let mut new_fields: BTreeMap<&'static str, Growth> = BTreeMap::new(); for (target_field, expr) in &edge.overhead.output_size { - // If this overhead references a current field whose growth is `Unknown`, - // we cannot honestly bound the target field: propagate `Unknown`. - let taints = expr - .variables() - .iter() - .any(|v| matches!(rendered.get(v), Some(None))); + // Taint the target field if this overhead references any variable we cannot + // express in the source's variables: either a present-but-`Unknown` current + // field, or a variable absent from the label entirely (an intermediate-only + // field that would otherwise leak through `substitute` as a fake source + // variable). Both cases are exactly "not in `mapping`". + let taints = expr.variables().iter().any(|v| !mapping.contains_key(v)); if taints { new_fields.insert(target_field, Growth::Unknown); continue; diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 4ca570c86..51f6ead4a 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -176,8 +176,11 @@ fn test_growth_exponential_variants() { assert!(en.dominates(&g("n^5"))); // 2^(n-m) ≤ 2^n after dropping the negative rate. assert_eq!(g("2^(n - m)"), g("2^n")); - // Unit / decaying bases collapse to O(1). + // Unit base is O(1); a decaying base with a growing exponent is O(1) too. assert_eq!(g("1^n"), g("7")); + assert_eq!(g("0.5^n"), g("7")); + // A fractional base with a *negative* exponent grows: 0.5^(-n) = 2^n. + assert_eq!(g("0.5^(-n)"), g("2^n")); } /// `log` lowers each level: log of an exponential is linear, log of a @@ -195,6 +198,22 @@ fn test_growth_log_levels() { assert_eq!(terms_of(&g("log(n*m)")).len(), 2); // log of a constant is O(1). assert_eq!(terms_of(&g("log(5)")), [GrowthTerm::one()]); + + // A mixed monomial's log keeps *every* factor class: log(2^n * m) ≍ n + log m. + // The exponential factor must not swallow the polynomial one. + let mixed = g("log(2^n * m)"); + let expected = make_growth(vec![ + term(&[], &[("n", 1.0)], &[]), + term(&[], &[], &[("m", 1)]), + ]); + assert_eq!(mixed, expected); + assert_eq!(terms_of(&mixed).len(), 2, "expected n + log m: {mixed:?}"); + + // When the classes share a variable the dominated summand is pruned: + // log(2^n * n^2) ≍ n + log n ≍ n (a single summand). + let shared = g("log(2^n * n^2)"); + assert_eq!(shared, g("n")); + assert_eq!(terms_of(&shared), [term(&[], &[("n", 1.0)], &[])]); } /// `Unknown` is the top of the growth order. diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index be612b57b..88454a153 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -988,3 +988,59 @@ fn test_find_paths_bounded_limits_depth() { "MIS→QUBO has no direct edge, so bound=0 should return empty" ); } + +#[test] +fn test_find_paths_bounded_returns_shortest_when_truncated() { + use crate::expr::Expr; + use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; + use crate::rules::ReductionEdgeData; + + fn edge() -> ReductionEdgeData { + ReductionEdgeData { + overhead: ReductionOverhead::new(vec![("n", Expr::Var("n"))]), + reduce_fn: None, + reduce_aggregate_fn: None, + capabilities: EdgeCapabilities::witness_only(), + } + } + + // Topology where DFS discovery order surfaces a LONG route before the SHORT one. + // From S the first outgoing edge (S->A) leads into a long chain A->B->C->T, while a + // later edge S->T is a direct hop. petgraph's DFS explores S->A first, so the + // 4-edge route is discovered before the 1-edge direct route. With a tight limit, + // the old `.take(limit)` in discovery order would keep the long route and drop the + // short one; length-first enumeration must return the short route. + let graph = ReductionGraph::from_test_edges( + &["S", "A", "B", "C", "T"], + &[ + ("S", "A", edge()), + ("A", "B", edge()), + ("B", "C", edge()), + ("C", "T", edge()), + ("S", "T", edge()), + ], + ); + + let empty = BTreeMap::new(); + + // Sanity: both routes exist when unbounded. + let all = graph.find_paths_up_to("S", &empty, "T", &empty, 100); + assert_eq!(all.len(), 2, "expected the direct route and the long chain"); + + // With limit 1, the SHORT (direct) route must be the one returned. + let limited = graph.find_paths_up_to("S", &empty, "T", &empty, 1); + assert_eq!(limited.len(), 1); + assert_eq!( + limited[0].len(), + 1, + "truncated result must keep the shortest (direct) route, not the long chain" + ); + + // Results are length-sorted (non-decreasing edge counts). + let lens: Vec = all.iter().map(|p| p.len()).collect(); + assert!( + lens.windows(2).all(|w| w[0] <= w[1]), + "paths must be returned shortest-first, got lengths {lens:?}" + ); + assert_eq!(lens, vec![1, 4]); +} diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index b36ff08e2..575a61629 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -10,13 +10,15 @@ use crate::expr::Expr; use crate::growth::Growth; use crate::models::graph::{HamiltonianCircuit, HighlyConnectedDeletion}; use crate::rules::cost::CustomCost; -use crate::rules::pareto::{GrowthLabel, PathLabel, ReductionEdge}; +use crate::rules::pareto::{GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge}; use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; use crate::rules::{ReductionGraph, ReductionMode, DEFAULT_SIZE_BUDGET}; use crate::topology::SimpleGraph; use crate::types::ProblemSize; use std::any::Any; +use std::cell::Cell; use std::collections::BTreeMap; +use std::rc::Rc; use std::time::Instant; // --------------------------------------------------------------------------- @@ -785,3 +787,374 @@ fn test_asymptotic_front_uses_only_source_variables_mfvs_ilp() { "ILP num_vars must compose to O(num_vertices), not the getter alias num_variables" ); } + +// --------------------------------------------------------------------------- +// Fix A: MeasuredLabel opts out of (unsound) branch-and-bound. +// --------------------------------------------------------------------------- + +/// The measured label's `cost` (= measured total) can SHRINK across a reduction, so it is +/// non-monotone and branch-and-bound over it is unsound. The label must therefore declare +/// `BRANCH_AND_BOUND = false`. +#[test] +fn test_measured_label_opts_out_of_branch_and_bound() { + const { + assert!( + ! as PathLabel>::BRANCH_AND_BOUND, + "MeasuredLabel::cost is non-monotone (size can shrink); B&B must be disabled" + ); + } +} + +/// A test label whose `cost` is the label's current absolute value — a value a late edge +/// can *shrink* below an already-completed route's final value. With `BRANCH_AND_BOUND` +/// disabled it models exactly the invariant `MeasuredLabel` now relies on. +#[derive(Clone)] +struct ShrinkLabel { + v: f64, +} + +impl PathLabel for ShrinkLabel { + fn extend(&self, edge: &ReductionEdge) -> Option { + // The edge sets a new absolute value (`v`), which may be smaller than the current. + let z = ProblemSize::new(vec![]); + let v = edge.overhead.get("v").map(|e| e.eval(&z)).unwrap_or(self.v); + Some(ShrinkLabel { v }) + } + + fn dominates(&self, other: &Self) -> bool { + self.v <= other.v + } + + // Non-monotone cost ⇒ B&B would be unsound (this is the MeasuredLabel case). + const BRANCH_AND_BOUND: bool = false; + + fn cost(&self) -> f64 { + self.v + } +} + +/// Kernel regression for Fix A: a route that *shrinks late* (its intermediate cost 100 is +/// higher than a rival route that completes early at 50, but a final edge drops it to 10) +/// must survive to the front. A kernel that applied branch-and-bound would prune the +/// intermediate node (100 ≥ best-so-far 50) and silently drop the true optimum. Because +/// `ShrinkLabel` opts out of B&B, the shrink-late route reaches the front even under +/// `exhaustive = true` (which disables only the dominance guard). +#[test] +fn test_kernel_keeps_shrink_late_route_without_branch_and_bound() { + let empty = std::collections::BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "A", "T"], + &[ + // S -> T: completes early with final value 50. + ("S", "T", growth_edge(vec![("v", Expr::Const(50.0))])), + // S -> A: intermediate value 100 (would trip a B&B bound of 50). + ("S", "A", growth_edge(vec![("v", Expr::Const(100.0))])), + // A -> T: shrinks the value to 10 (globally best). + ("A", "T", growth_edge(vec![("v", Expr::Const(10.0))])), + ], + ); + + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + ShrinkLabel { v: 0.0 }, + true, + ); + + // The shrink-late route S -> A -> T (final value 10) must be present in the front. + let shrink_late = front + .iter() + .find(|(p, _)| p.type_names() == ["S", "A", "T"]) + .expect("shrink-late route S -> A -> T must survive without branch-and-bound"); + assert_eq!( + shrink_late.1.cost(), + 10.0, + "the shrink-late route finishes at the global optimum value 10" + ); + // The kernel's best (lowest cost) front element is that shrink-late route. + assert_eq!(front[0].0.type_names(), ["S", "A", "T"]); + assert_eq!(front[0].1.cost(), 10.0); +} + +// --------------------------------------------------------------------------- +// Fix B: CostLabel dominance is componentwise over (cost, size). +// --------------------------------------------------------------------------- + +/// Fix B regression: an edge cost that DEPENDS on the carried size makes a cheaper-so-far +/// prefix with a *larger* intermediate size a trap — a scalar `cost <= other.cost` +/// dominance would evict the costlier-but-smaller prefix whose continuation is globally +/// cheapest. With componentwise `(cost, size)` dominance both prefixes survive at the hub +/// and `find_cheapest_path` returns the globally optimal route. +#[test] +fn test_cost_label_path_dependent_dominance() { + let empty = std::collections::BTreeMap::new(); + // Edges carry `c` (base edge cost), `wf` (weight on the size-dependent term) and `w` + // (the tracked size field). The cost function is `c + wf * current_w`, so the M -> T + // edge's cost is exactly the size `w` accumulated at M. + let graph = ReductionGraph::from_test_edges( + &["S", "M", "P", "T"], + &[ + // S -> M: cheap prefix (c = 1) but produces a LARGE intermediate size w = 100. + ( + "S", + "M", + growth_edge(vec![ + ("c", Expr::Const(1.0)), + ("wf", Expr::Const(0.0)), + ("w", Expr::Const(100.0)), + ]), + ), + // S -> P: pricier prefix (c = 3) but a SMALL size w = 1. + ( + "S", + "P", + growth_edge(vec![ + ("c", Expr::Const(3.0)), + ("wf", Expr::Const(0.0)), + ("w", Expr::Const(1.0)), + ]), + ), + // P -> M: cheap (c = 1), keeps the small size w = 1. + ( + "P", + "M", + growth_edge(vec![ + ("c", Expr::Const(1.0)), + ("wf", Expr::Const(0.0)), + ("w", Expr::Const(1.0)), + ]), + ), + // M -> T: cost = current w (wf = 1, c = 0); identity on size. + ( + "M", + "T", + growth_edge(vec![ + ("c", Expr::Const(0.0)), + ("wf", Expr::Const(1.0)), + ("w", Expr::Var("w")), + ]), + ), + ], + ); + + // Cost function: c + wf * current_w. Depends on the carried size, so the two prefixes + // into M are incomparable and must both be kept. + let cost_fn = CustomCost(|oh: &ReductionOverhead, sz: &ProblemSize| { + let c = oh.get("c").map(|e| e.eval(sz)).unwrap_or(0.0); + let wf = oh.get("wf").map(|e| e.eval(sz)).unwrap_or(0.0); + c + wf * sz.get("w").unwrap_or(0) as f64 + }); + + let best = graph + .find_cheapest_path( + "S", + &empty, + "T", + &empty, + &ProblemSize::new(vec![("w", 0)]), + &cost_fn, + ) + .expect("cheapest path S -> T"); + + // Globally cheapest: S -> P -> M -> T (total 3 + 1 + 1 = 5), NOT the cheap-prefix trap + // S -> M -> T (total 1 + 100 = 101). A scalar-dominance CostLabel would evict the + // small-w prefix at M and return the S -> M -> T trap. + assert_eq!( + best.type_names(), + vec!["S", "P", "M", "T"], + "componentwise (cost, size) dominance must keep the globally optimal small-w prefix" + ); +} + +// --------------------------------------------------------------------------- +// Fix C: GrowthLabel taints target fields referencing intermediate-only variables. +// --------------------------------------------------------------------------- + +/// Fix C regression: an overhead output expression that references a variable ABSENT from +/// the current label (an intermediate-only field, e.g. `tseitin_*`, `num_encoding_bits`) +/// must taint its target field to `Growth::Unknown` — it must NOT pass through +/// `substitute` verbatim and surface as a fake source variable in the final bound. +#[test] +fn test_growth_label_taints_absent_variable() { + // The label knows only the source field `n`. + let label = GrowthLabel::source(&["n"]); + // Edge output: `bounded` depends only on `n`; `leaky` references `tseitin`, which is + // absent from the label (an intermediate-only construction variable). + let edge = growth_edge(vec![ + ("bounded", Expr::Var("n")), + ("leaky", Expr::Var("n") * Expr::Var("tseitin")), + ]); + let tv = BTreeMap::new(); + let redge = ReductionEdge { + overhead: &edge.overhead, + reduce_fn: None, + capabilities: EdgeCapabilities::witness_only(), + target_name: "T", + target_variant: &tv, + }; + let next = label.extend(&redge).expect("extend"); + + // Depends only on a mapped source variable ⇒ stays bounded. + assert_eq!(field_big_o(&next, "bounded"), "n"); + // References an unmapped, intermediate-only variable ⇒ tainted to Unknown, never + // leaked as `O(n * tseitin)`. + assert!( + matches!(next.fields().get("leaky"), Some(Growth::Unknown)), + "a target field referencing an absent variable must become Unknown, got {:?}", + next.fields().get("leaky") + ); +} + +// --------------------------------------------------------------------------- +// Fix D: the arena frees evicted labels (bag cap bounds retained instance memory). +// --------------------------------------------------------------------------- + +thread_local! { + /// Live token instances on this thread. + static TOK_LIVE: Cell = const { Cell::new(0) }; + /// Peak live token instances observed. + static TOK_PEAK: Cell = const { Cell::new(0) }; + /// Total token instances ever created. + static TOK_CREATED: Cell = const { Cell::new(0) }; +} + +/// A drop-tracking token. Each `new()` is a distinct live instance; `Drop` frees it. Held +/// behind `Rc` inside a label, so cloning a label (Rc clone) SHARES the token — mirroring +/// `MeasuredLabel`'s `Rc` reduction chain, where each hop is one instance shared across +/// label clones. If the arena pinned evicted labels, their tokens would stay live until +/// the search ended, so `TOK_PEAK` would reach `TOK_CREATED`. +struct DropToken; + +impl DropToken { + fn new() -> Self { + let live = TOK_LIVE.with(|c| { + let v = c.get() + 1; + c.set(v); + v + }); + TOK_PEAK.with(|p| { + if live > p.get() { + p.set(live); + } + }); + TOK_CREATED.with(|c| c.set(c.get() + 1)); + DropToken + } +} + +impl Drop for DropToken { + fn drop(&mut self) { + TOK_LIVE.with(|c| c.set(c.get() - 1)); + } +} + +/// A label carrying an `Rc` and a two-component `(c, s)` value. The engineered +/// `(c, s)` pairs are pairwise incomparable, so no label evicts another by dominance and +/// the per-node bag grows until the cap truncates it — exercising the truncation free path. +#[derive(Clone)] +struct TokenLabel { + c: f64, + s: f64, + _tok: Rc, +} + +impl PathLabel for TokenLabel { + fn extend(&self, edge: &ReductionEdge) -> Option { + let z = ProblemSize::new(vec![]); + let c = edge.overhead.get("c").map(|e| e.eval(&z)).unwrap_or(self.c); + let s = edge.overhead.get("s").map(|e| e.eval(&z)).unwrap_or(self.s); + Some(TokenLabel { + c, + s, + _tok: Rc::new(DropToken::new()), + }) + } + + fn dominates(&self, other: &Self) -> bool { + self.c <= other.c && self.s <= other.s + } + + fn cost(&self) -> f64 { + self.c + } +} + +/// Fix D regression: drive the kernel on a graph that generates far more labels at one hub +/// than `BAG_CAP`, all incomparable so the bag truncates repeatedly. Because evicted / +/// truncated arena entries free their labels immediately, the *peak* number of live +/// `DropToken` instances stays well below the *total* ever created. If the arena pinned +/// evicted labels (the bug), peak would equal total. +#[test] +fn test_arena_frees_evicted_labels_bounds_live_memory() { + TOK_LIVE.with(|c| c.set(0)); + TOK_PEAK.with(|c| c.set(0)); + TOK_CREATED.with(|c| c.set(0)); + + // One hub M fed by N ≫ BAG_CAP parallel S -> M edges with pairwise-incomparable + // (c = i+1, s = N-i) labels, then M -> T (identity). The M bag truncates repeatedly. + let n: usize = 200; + let mut edges: Vec<(&'static str, &'static str, ReductionEdgeData)> = Vec::new(); + // Leak small &'static str-free constants via Expr::Const (no string needed for values). + for i in 0..n { + edges.push(( + "S", + "M", + growth_edge(vec![ + ("c", Expr::Const((i + 1) as f64)), + ("s", Expr::Const((n - i) as f64)), + ]), + )); + } + edges.push(( + "M", + "T", + growth_edge(vec![("c", Expr::Var("c")), ("s", Expr::Var("s"))]), + )); + let graph = ReductionGraph::from_test_edges(&["S", "M", "T"], &edges); + + let empty = std::collections::BTreeMap::new(); + let initial = TokenLabel { + c: 0.0, + s: 0.0, + _tok: Rc::new(DropToken::new()), + }; + let front = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + false, + ); + // Sanity: the search reached T. + assert!(!front.is_empty(), "front should reach T"); + + let created = TOK_CREATED.with(|c| c.get()); + let peak = TOK_PEAK.with(|c| c.get()); + // Many labels were created (≥ the N hub edges). + assert!( + created >= n as i64, + "expected many token instances created, got {created}" + ); + // Eviction frees labels: peak live is strictly below total created. With the bug + // (arena pins evicted labels) peak would equal created; the margin here is large + // (peak is bounded by ~BAG_CAP per live node, created scales with N) so this is not + // flaky. + assert!( + peak < created, + "arena must free evicted labels: peak {peak} should be < created {created}" + ); + + // The retained tokens are bounded by the live bag entries, not by N. Concretely, far + // fewer than the total are still live once the search completes. + drop(front); + let live_after = TOK_LIVE.with(|c| c.get()); + assert!( + live_after < created, + "retained tokens {live_after} must be bounded well below total {created}" + ); +} From ba74bff8b81251802efa721e7f171b40e979db87 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 16:48:48 +0800 Subject: [PATCH 16/31] Simplify path enumeration to a single bounded-heap pass find_paths_up_to_mode_bounded enumerated paths via iterative deepening, running a full all_simple_paths DFS once per length level (up to node_count levels). For --all queries with fewer paths than the limit (the common case on a sparse graph) that meant ~170 redundant zero-yield traversals per call. Replace with a single DFS pass feeding one bounded max-heap keyed by (node count, order key): it retains exactly the `limit` shortest-then- lexicographically-smallest paths in O(limit) memory. Output is byte- identical to the iterative-deepening version (verified across queries and limits), and this folds in the redundant `limit == 0` guard and the manual into_vec+sort (now into_sorted_vec). Also drop a stale sentence in the MeasuredLabel memory rustdoc. Co-Authored-By: Claude Fable 5 --- src/rules/graph.rs | 76 ++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 47 deletions(-) diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 1b58c0bc0..3de84e672 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -928,60 +928,42 @@ impl ReductionGraph { None => return vec![], }; - if limit == 0 { - return vec![]; - } - - // Enumerate length-first (shortest paths before longer ones) via iterative - // deepening over the intermediate-node count `k`. Taking `limit` in petgraph's + // Enumerate every simple path in a single DFS pass and keep only the `limit` + // that sort smallest under the deterministic total order: fewest nodes first + // (shortest routes), then by `path_order_key`. Taking `limit` in petgraph's raw // DFS discovery order (the previous approach) could drop a short route - // discovered late while returning a long route discovered early. Each level - // `k` is enumerated exactly (min == max == k) so paths arrive grouped by - // length, then sorted by the deterministic `path_order_key` so *which* - // same-length paths survive truncation is reproducible and build-independent. - let max_k = + // discovered late while returning a long route discovered early. A single + // bounded max-heap keyed by `(node count, order key)` retains exactly those + // `limit` paths — push each candidate, and once over capacity pop the current + // largest — so ordering and the truncated subset are reproducible and + // build-independent with O(limit) memory, however many paths the graph holds. + // (`limit == 0` falls out naturally: every push is immediately popped.) + let max_intermediate = max_intermediate_nodes.unwrap_or_else(|| self.graph.node_count().saturating_sub(2)); - let mut result: Vec = Vec::new(); - - for k in 0..=max_k { - let still_needed = limit - result.len(); - if still_needed == 0 { - break; - } - - // Memory guard: a single level can be combinatorially large, so never hold - // more than `still_needed` paths at once. A max-heap keyed by the order key - // keeps the smallest-key `still_needed` entries: push each path, and once - // over capacity pop the current largest key. This is deterministic and uses - // bounded memory regardless of how many paths the level actually contains. - let mut heap: BinaryHeap<(String, Vec)> = BinaryHeap::new(); - for p in all_simple_paths::, _, std::hash::RandomState>( - &self.graph, - src, - dst, - k, - Some(k), - ) { - if !self.node_path_supports_mode(&p, mode) { - continue; - } - let key = self.path_order_key(&p); - heap.push((key, p)); - if heap.len() > still_needed { - heap.pop(); - } + let mut heap: BinaryHeap<(usize, String, Vec)> = BinaryHeap::new(); + for p in all_simple_paths::, _, std::hash::RandomState>( + &self.graph, + src, + dst, + 0, + Some(max_intermediate), + ) { + if !self.node_path_supports_mode(&p, mode) { + continue; } - - // Drain the retained entries and append them in ascending key order. - let mut level: Vec<(String, Vec)> = heap.into_vec(); - level.sort(); - for (_, p) in level { - result.push(self.node_path_to_reduction_path(&p)); + let key = self.path_order_key(&p); + heap.push((p.len(), key, p)); + if heap.len() > limit { + heap.pop(); } } - result + // `into_sorted_vec` yields ascending `(node count, order key)` order. + heap.into_sorted_vec() + .into_iter() + .map(|(_, _, p)| self.node_path_to_reduction_path(&p)) + .collect() } /// Check if a direct reduction exists from S to T. From daa61dff6e9acb77f17f0f9b14416754f8142330 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 14 Jul 2026 17:11:46 +0800 Subject: [PATCH 17/31] Delete branch-and-bound from the path-search kernel entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix disabled B&B for MeasuredLabel via a per-label `BRANCH_AND_BOUND` associated const (default `true`). That default is a footgun: any future PathLabel whose `cost` is non-monotone inherits unsound B&B pruning unless its author remembers to opt out — exactly the mistake that made measured search unsound in the first place. Remove the mechanism instead of gating it. Only CostLabel ever used B&B, where it was a marginal early-termination optimization on a 179-node graph; dominance pruning (always sound for every label domain) plus HOP_CAP/BAG_CAP already bound the search. Drop the `BRANCH_AND_BOUND` const, the `best_final` tracking, and both prune sites. `cost` is now purely a frontier-ordering / tie-break heuristic and need not be monotone. Result-preserving: `find_cheapest_path*` returns the min-cost element, which sound B&B never affected — verified `--cost` output is byte- identical across queries and cost functions. The kernel is now dominance- only, so no label can reintroduce this class of bug. Co-Authored-By: Claude Fable 5 --- src/rules/cost.rs | 11 +++--- src/rules/graph.rs | 41 +++++++--------------- src/rules/pareto.rs | 64 ++++++++++++---------------------- src/unit_tests/rules/pareto.rs | 38 ++++++-------------- 4 files changed, 50 insertions(+), 104 deletions(-) diff --git a/src/rules/cost.rs b/src/rules/cost.rs index df52b3446..c44846efe 100644 --- a/src/rules/cost.rs +++ b/src/rules/cost.rs @@ -7,12 +7,11 @@ use crate::types::ProblemSize; pub trait PathCostFn { /// Compute cost of taking an edge given current problem size. /// - /// Implementations **must** return a nonnegative value and be monotone in - /// `current_size` (a componentwise-larger size never yields a smaller edge cost). The - /// Pareto search relies on both properties: nonnegativity keeps the accumulated path - /// cost non-decreasing, which is what makes branch-and-bound pruning sound; - /// monotonicity gives the isotonicity that makes `(cost, size)` dominance pruning - /// sound. All shipped implementations below satisfy these. + /// Implementations **must** be monotone in `current_size` (a componentwise-larger + /// size never yields a smaller edge cost). The Pareto search prunes by `(cost, size)` + /// dominance, and this monotonicity is what gives the isotonicity that makes such + /// pruning sound. (A nonnegative cost is also expected — all shipped implementations + /// return one — though the kernel no longer branch-and-bounds on it.) fn edge_cost(&self, overhead: &ReductionOverhead, current_size: &ProblemSize) -> f64; } diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 3de84e672..c4d7db9f7 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -496,15 +496,17 @@ impl ReductionGraph { /// Maintains a per-node **bag** (an antichain of non-dominated labels); a label is /// discarded only when another label at the same node [dominates](PathLabel::dominates) /// it. Each surviving label carries a predecessor pointer for path reconstruction. - /// The frontier is explored in ascending [`cost`](PathLabel::cost) order, which gives - /// an early branch-and-bound bound. Deterministic safety caps apply: [`HOP_CAP`] - /// bounds path length, and [`BAG_CAP`] bounds each bag with a deterministic tie-break - /// (never iteration-order truncation). Edges are visited in a deterministic - /// (target-name, target-variant) order. + /// Pruning is by dominance alone — always sound for any label domain, unlike a + /// branch-and-bound bound, which would require a monotone scalar `cost` that the + /// measured domain does not have. The frontier is explored in ascending + /// [`cost`](PathLabel::cost) order (a heuristic that finds good paths early). + /// Deterministic safety caps apply: [`HOP_CAP`] bounds path length, and [`BAG_CAP`] + /// bounds each bag with a deterministic tie-break (never iteration-order truncation). + /// Edges are visited in a deterministic (target-name, target-variant) order. /// /// When `exhaustive` is `true`, the componentwise dominance guard is disabled (bags - /// retain all labels up to the cap); the sound guards inside [`PathLabel::extend`] and - /// the branch-and-bound bound still apply. + /// retain all labels up to the cap); the sound guards inside [`PathLabel::extend`] + /// still apply. /// /// Returns the Pareto front at `dst`: `(path, label)` pairs, deterministically /// ordered by (cost, hops, node-name path). @@ -531,7 +533,6 @@ impl ReductionGraph { let mut arena: Vec> = Vec::new(); let mut bags: HashMap> = HashMap::new(); let mut frontier: BinaryHeap, usize)>> = BinaryHeap::new(); - let mut best_final: Option = None; arena.push(Entry { node: src, @@ -555,7 +556,7 @@ impl ReductionGraph { names }; - while let Some(Reverse((cost, idx))) = frontier.pop() { + while let Some(Reverse((_cost, idx))) = frontier.pop() { let node = arena[idx].node; // Skip stale entries (removed from their bag because dominated / capped out). if !bags.get(&node).is_some_and(|b| b.contains(&idx)) { @@ -576,13 +577,6 @@ impl ReductionGraph { if arena[idx].hops >= HOP_CAP { continue; } - // Branch-and-bound: a label already at least as costly as the best completed - // path cannot yield a cheaper destination (cost is non-decreasing). Sound - // only for scalar objectives; the asymptotic partial order opts out (see - // `PathLabel::BRANCH_AND_BOUND`). - if L::BRANCH_AND_BOUND && best_final.is_some_and(|bf| cost.0 >= bf) { - continue; - } // Deterministic edge order. let mut edges: Vec<(NodeIndex, EdgeIndex)> = self @@ -612,11 +606,6 @@ impl ReductionGraph { continue; }; let new_cost = new_label.cost(); - // Branch-and-bound against the best completed path (scalar objectives - // only; the asymptotic partial order opts out). - if L::BRANCH_AND_BOUND && best_final.is_some_and(|bf| new_cost >= bf) { - continue; - } // Componentwise dominance against the target's bag. if !exhaustive { let bag = bags.entry(target).or_default(); @@ -657,12 +646,6 @@ impl ReductionGraph { }); bags.entry(target).or_default().push(nidx); frontier.push(Reverse((OrderedFloat(new_cost), nidx))); - if target == dst { - best_final = Some(match best_final { - Some(bf) => bf.min(new_cost), - None => new_cost, - }); - } // Enforce the per-node bag cap with a deterministic tie-break. if bags[&target].len() > BAG_CAP { @@ -1885,8 +1868,8 @@ impl ReductionGraph { /// `budget` is the hard total-size limit (sum of `ProblemSize` components); use /// [`DEFAULT_SIZE_BUDGET`](crate::rules::DEFAULT_SIZE_BUDGET) for the default. /// `exhaustive` disables only the heuristic componentwise-dominance guard (the sound - /// pre-flight and measured-budget guards still apply; the [`MeasuredLabel`] does not - /// use branch-and-bound, since its measured cost can shrink across a reduction). + /// pre-flight and measured-budget guards still apply; the kernel prunes by dominance + /// only, never branch-and-bound — measured cost can shrink across a reduction). /// /// Returns `None` if no in-budget witness-capable path exists (or `source == target`). #[allow(clippy::too_many_arguments)] diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 083190b94..613ed319a 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -106,13 +106,12 @@ pub struct ReductionEdge<'g> { /// size in the source size. The Pareto search relies on it to safely discard dominated /// labels. /// -/// **B&B soundness** (only when [`BRANCH_AND_BOUND`](PathLabel::BRANCH_AND_BOUND) is -/// set): [`cost`](PathLabel::cost) must be non-decreasing along `extend` — a reduction -/// never shrinks the tracked cost below the current value. The scalar cost functions -/// ([`CostLabel`]) satisfy this. The *measured* size does **not**: a reduction can -/// shrink the constructed instance, so [`MeasuredLabel::cost`] is non-monotone; that -/// label therefore opts out (`BRANCH_AND_BOUND = false`) and relies on dominance pruning -/// alone. +/// The kernel prunes by [`dominates`](PathLabel::dominates) alone — it does **not** +/// branch-and-bound on [`cost`](PathLabel::cost). Dominance is exact for every label +/// domain, whereas a scalar B&B bound would only be sound for a monotone `cost`: the +/// measured size can *shrink* across a reduction, and the asymptotic `cost` is a +/// heuristic summary of an incomparable growth vector, so neither admits a sound bound. +/// `cost` is used only for frontier ordering and the deterministic final tie-break. pub trait PathLabel: Clone { /// Advance this label across `edge`. Returns `None` when a guard prunes the edge /// (e.g. the measured label's pre-flight size guard). A `None` must be *isotone*: @@ -125,24 +124,12 @@ pub trait PathLabel: Clone { /// node's bag an antichain. fn dominates(&self, other: &Self) -> bool; - /// Scalar summary used for frontier ordering, the deterministic final tie-break, - /// and (when [`BRANCH_AND_BOUND`](PathLabel::BRANCH_AND_BOUND) is set) branch-and- - /// bound pruning. Smaller is better. Must be non-decreasing along `extend` *when* - /// `BRANCH_AND_BOUND` is set; labels that opt out (e.g. [`MeasuredLabel`]) may have a - /// non-monotone `cost`. - fn cost(&self) -> f64; - - /// Whether scalar branch-and-bound pruning — discarding a label whose `cost` - /// already meets or exceeds the best completed path's `cost` — is sound for this - /// label. + /// Scalar summary used only for frontier ordering and the deterministic final + /// tie-break — never for pruning (the kernel prunes by [`dominates`] alone). Smaller + /// is better. It need not be monotone along `extend`. /// - /// `true` (default) for scalar objectives (measured size, formula cost), where - /// `cost` *is* the objective. `false` for the partial-order asymptotic label: - /// there `cost` is only a heuristic summary of a multi-field growth vector, so - /// pruning by it would drop genuinely *incomparable* Pareto-optimal paths (one - /// cheaper in `num_vertices`, another in `num_edges`). Such labels rely on - /// [`dominates`](PathLabel::dominates) pruning alone, which is exact. - const BRANCH_AND_BOUND: bool = true; + /// [`dominates`]: PathLabel::dominates + fn cost(&self) -> f64; } /// Formula-based label for a [`PathCostFn`]. @@ -230,10 +217,10 @@ enum MeasuredPos<'a> { /// heuristic under a documented size-monotone-future assumption. The kernel's /// `exhaustive` flag disables *only* this guard, keeping 1–2 (which are sound). /// -/// It deliberately does **not** use the kernel's branch-and-bound: measured size can -/// *shrink* across a reduction, so [`cost`](PathLabel::cost) is non-monotone and a B&B -/// bound could prune a partial route that would still finish smallest. Hence -/// [`BRANCH_AND_BOUND`](PathLabel::BRANCH_AND_BOUND) `= false`. +/// The kernel prunes by dominance only, never branch-and-bound — which matters here +/// because measured size can *shrink* across a reduction, so [`cost`](PathLabel::cost) +/// is non-monotone and any scalar B&B bound could wrongly prune a partial route that +/// would still finish smallest. /// /// **Memory.** There is no absolute anti-OOM guarantee (the overhead formulas are /// uncalibrated upper bounds), but two mechanisms bound retained instance memory: the @@ -345,13 +332,10 @@ impl PathLabel for MeasuredLabel<'_> { size_le(&self.size, &other.size) } - // Measured size can SHRINK across a reduction, so `cost` (= measured total) is not - // monotone along `extend`. Kernel branch-and-bound would then prune a partial route - // that could still finish below the best completed path — even under `exhaustive`. - // Opt out and rely on the sound pre-flight/budget guards plus dominance pruning. - const BRANCH_AND_BOUND: bool = false; - fn cost(&self) -> f64 { + // Frontier-ordering heuristic only. Measured size can SHRINK across a reduction, + // so this is non-monotone along `extend` — which is exactly why the kernel prunes + // by dominance, not branch-and-bound. self.size.total() as f64 } } @@ -487,16 +471,12 @@ impl PathLabel for GrowthLabel { strict } - // Asymptotic growth is a partial order, so a scalar `cost` can never separate - // incomparable front members; branch-and-bound on it would drop them. Disable it - // and rely on the exact `dominates` pruning above. - const BRANCH_AND_BOUND: bool = false; - fn cost(&self) -> f64 { // Heuristic scalar summary for frontier ordering and the deterministic final - // tie-break ONLY — never for pruning (see `BRANCH_AND_BOUND` above; dominance - // is the exact partial order). Summed field magnitudes; `Unknown` fields - // dominate the sum, ranking undecidable paths last. + // tie-break ONLY — never for pruning (dominance is the exact partial order, and + // asymptotic growth is incomparable so no scalar bound could separate front + // members). Summed field magnitudes; `Unknown` fields dominate the sum, ranking + // undecidable paths last. self.fields.values().map(|g| g.magnitude()).sum() } } diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 575a61629..75d45f422 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -10,7 +10,7 @@ use crate::expr::Expr; use crate::growth::Growth; use crate::models::graph::{HamiltonianCircuit, HighlyConnectedDeletion}; use crate::rules::cost::CustomCost; -use crate::rules::pareto::{GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge}; +use crate::rules::pareto::{GrowthLabel, PathLabel, ReductionEdge}; use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; use crate::rules::{ReductionGraph, ReductionMode, DEFAULT_SIZE_BUDGET}; use crate::topology::SimpleGraph; @@ -537,11 +537,11 @@ fn test_growth_negative_control_incomparable_front() { // Completeness under ASYMMETRIC magnitudes: the two incomparable paths have // different scalar `cost` summaries (A: n^2 + m ⇒ magnitude 3; B: n + m^3 ⇒ -// magnitude 4). Scalar branch-and-bound would let the cheaper path A complete first -// and then prune B (cost 4 ≥ 3), silently dropping a Pareto-optimal path. This is -// the case the equal-magnitude negative control above does NOT catch; it passes only -// because `GrowthLabel` opts out of branch-and-bound (`BRANCH_AND_BOUND = false`) and -// relies on exact dominance pruning. +// magnitude 4). A scalar branch-and-bound (were the kernel to use one) would let the +// cheaper path A complete first and then prune B (cost 4 ≥ 3), silently dropping a +// Pareto-optimal path. This is the case the equal-magnitude negative control above +// does NOT catch; it passes because the kernel prunes by exact dominance only, never +// by the scalar `cost`. #[test] fn test_growth_asymmetric_incomparable_front_complete() { let empty = BTreeMap::new(); @@ -789,25 +789,12 @@ fn test_asymptotic_front_uses_only_source_variables_mfvs_ilp() { } // --------------------------------------------------------------------------- -// Fix A: MeasuredLabel opts out of (unsound) branch-and-bound. +// Fix A: the kernel prunes by dominance only — never (unsound) branch-and-bound. // --------------------------------------------------------------------------- -/// The measured label's `cost` (= measured total) can SHRINK across a reduction, so it is -/// non-monotone and branch-and-bound over it is unsound. The label must therefore declare -/// `BRANCH_AND_BOUND = false`. -#[test] -fn test_measured_label_opts_out_of_branch_and_bound() { - const { - assert!( - ! as PathLabel>::BRANCH_AND_BOUND, - "MeasuredLabel::cost is non-monotone (size can shrink); B&B must be disabled" - ); - } -} - /// A test label whose `cost` is the label's current absolute value — a value a late edge -/// can *shrink* below an already-completed route's final value. With `BRANCH_AND_BOUND` -/// disabled it models exactly the invariant `MeasuredLabel` now relies on. +/// can *shrink* below an already-completed route's final value. It models exactly the +/// non-monotone-cost case (`MeasuredLabel`) the dominance-only kernel must handle. #[derive(Clone)] struct ShrinkLabel { v: f64, @@ -825,9 +812,6 @@ impl PathLabel for ShrinkLabel { self.v <= other.v } - // Non-monotone cost ⇒ B&B would be unsound (this is the MeasuredLabel case). - const BRANCH_AND_BOUND: bool = false; - fn cost(&self) -> f64 { self.v } @@ -837,10 +821,10 @@ impl PathLabel for ShrinkLabel { /// higher than a rival route that completes early at 50, but a final edge drops it to 10) /// must survive to the front. A kernel that applied branch-and-bound would prune the /// intermediate node (100 ≥ best-so-far 50) and silently drop the true optimum. Because -/// `ShrinkLabel` opts out of B&B, the shrink-late route reaches the front even under +/// the kernel prunes by dominance only, the shrink-late route reaches the front even under /// `exhaustive = true` (which disables only the dominance guard). #[test] -fn test_kernel_keeps_shrink_late_route_without_branch_and_bound() { +fn test_kernel_keeps_shrink_late_route_dominance_only() { let empty = std::collections::BTreeMap::new(); let graph = ReductionGraph::from_test_edges( &["S", "A", "T"], From c777081fd3cec75c2d0659061abc7e4cbba26fff Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 16 Jul 2026 18:10:56 +0800 Subject: [PATCH 18/31] Preserve symbolic exponential bases --- src/growth.rs | 592 ++++++++++++++++++++++++++++++++------- src/unit_tests/growth.rs | 335 ++++++++++++++++++++-- 2 files changed, 797 insertions(+), 130 deletions(-) diff --git a/src/growth.rs b/src/growth.rs index 817a4821b..73cba8430 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -12,7 +12,8 @@ //! A [`GrowthTerm`] is one growth monomial //! //! ```text -//! ∏_v 2^(exp[v] · v) · ∏_v v^(poly[v]) · ∏_v (log v)^(logs[v]) +//! ∏_v ∏_f base[f]^(coefficient[f] · v) +//! · ∏_v v^(poly[v]) · ∏_v (log v)^(logs[v]) //! ``` //! //! and a [`Growth`] is an *antichain* of pairwise-incomparable dominant terms @@ -34,10 +35,14 @@ //! `sqrt((a − b)^2)` absolute-value idiom (`|a − b| ≤ a + b`). //! - Constants and constant multipliers/divisors are dropped on entry. //! - Exponentials with a **linear** exponent (`c^x`, `c^(r·x)`, `exp(x)`) are -//! first-class via the `exp` field (base normalized to 2, e.g. `3^n → {n: -//! log2 3}`). Nonlinear exponents (`2^(n·k)`, `2^sqrt(n)`), `factorial(·)`, -//! and negative exponents widen to [`Growth::Unknown`], which absorbs through -//! every operation. +//! first-class via symbolic base/coefficient factors. The original base is +//! authoritative: it is never normalized through a floating-point logarithm +//! and never reconstructed by rounding. Nonlinear exponents (`2^(n·k)`, +//! `2^sqrt(n)`), `factorial(·)`, and negative polynomial exponents widen to +//! [`Growth::Unknown`], which absorbs through every operation. +//! - [`Expr::Log`] evaluates numerically as the natural logarithm, but all fixed +//! logarithm bases greater than one have the same asymptotic class and are +//! intentionally represented by the single `log(v)` factor. //! //! # `Pow` note //! @@ -52,19 +57,332 @@ use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; /// Maximum number of terms kept in an antichain. On overflow the antichain is -/// widened upward to the single componentwise-max term (a valid upper bound), -/// never truncated by iteration order. +/// widened to a proven componentwise upper bound when one is representable; +/// otherwise it becomes [`Growth::Unknown`]. It is never truncated by order. const ANTICHAIN_CAP: usize = 32; -/// One growth monomial, e.g. `2^(3k) · n^2 · m · log(n)` → -/// `{ exp: {k: 3.0}, poly: {n: 2.0, m: 1.0}, logs: {n: 1} }`. +/// A base retained exactly as it appeared in the input expression. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +enum ExpBase { + /// A positive, finite constant expression used as the base of `Pow`. + Constant(Expr), + /// The distinguished base of the `exp(...)` AST constructor. + Natural, +} + +#[derive(serde::Deserialize)] +enum OwnedExpr { + Const(f64), + Var(String), + Add(Box, Box), + Mul(Box, Box), + Pow(Box, Box), + Exp(Box), + Log(Box), + Sqrt(Box), + Factorial(Box), +} + +impl OwnedExpr { + fn into_constant_expr(self) -> Option { + match self { + OwnedExpr::Const(value) => Some(Expr::Const(value)), + OwnedExpr::Var(name) => { + drop(name); + None + } + OwnedExpr::Add(a, b) => Some(a.into_constant_expr()? + b.into_constant_expr()?), + OwnedExpr::Mul(a, b) => Some(a.into_constant_expr()? * b.into_constant_expr()?), + OwnedExpr::Pow(base, exponent) => Some(Expr::pow( + base.into_constant_expr()?, + exponent.into_constant_expr()?, + )), + OwnedExpr::Exp(value) => Some(Expr::Exp(Box::new(value.into_constant_expr()?))), + OwnedExpr::Log(value) => Some(Expr::Log(Box::new(value.into_constant_expr()?))), + OwnedExpr::Sqrt(value) => Some(Expr::Sqrt(Box::new(value.into_constant_expr()?))), + OwnedExpr::Factorial(value) => { + Some(Expr::Factorial(Box::new(value.into_constant_expr()?))) + } + } + } +} + +impl<'de> serde::Deserialize<'de> for ExpBase { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + enum Repr { + Constant(OwnedExpr), + Natural, + } + + match Repr::deserialize(deserializer)? { + Repr::Natural => Ok(ExpBase::Natural), + Repr::Constant(base) => { + let base = base.into_constant_expr(); + if let Some(base) = + base.filter(|base| base.constant_value().is_some_and(|value| value.is_finite())) + { + Ok(ExpBase::Constant(base)) + } else { + Err(serde::de::Error::custom( + "symbolic exponential base must be a finite constant", + )) + } + } + } + } +} + +impl ExpBase { + fn structural_key(&self) -> String { + match self { + ExpBase::Constant(base) => format!("C{base:?}"), + ExpBase::Natural => "N".to_string(), + } + } + + /// Directly comparable base values. `Natural` uses the same `E` constant as + /// `Expr::Exp`; arbitrary constant subtrees remain structural-only. + fn directly_comparable_value(&self) -> Option { + match self { + ExpBase::Constant(Expr::Const(value)) => Some(*value), + ExpBase::Natural => Some(std::f64::consts::E), + ExpBase::Constant(_) => None, + } + } + + fn value(&self) -> f64 { + match self { + ExpBase::Constant(base) => base + .constant_value() + .expect("ExpBase::Constant must remain constant"), + ExpBase::Natural => std::f64::consts::E, + } + } + + fn coefficient_cmp(&self, a: f64, b: f64) -> Option { + let order = a.partial_cmp(&b)?; + if self.value() > 1.0 { + Some(order) + } else { + Some(order.reverse()) + } + } +} + +/// One symbolic exponential factor `base^(coefficient * variable)`. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +struct ExpFactor { + base: ExpBase, + coefficient: f64, +} + +/// Canonical product of growing exponential factors for one variable. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +struct ExpProduct { + factors: Vec, +} + +impl ExpProduct { + fn empty() -> Self { + ExpProduct { + factors: Vec::new(), + } + } + + fn single(base: ExpBase, coefficient: f64) -> Self { + Self::new(vec![ExpFactor { base, coefficient }]) + } + + /// Canonicalize without translating bases through a common logarithm. + fn new(factors: Vec) -> Self { + let mut combined: Vec = Vec::new(); + for factor in factors { + if factor.coefficient == 0.0 { + continue; + } + if let Some(existing) = combined.iter_mut().find(|f| f.base == factor.base) { + existing.coefficient += factor.coefficient; + } else { + combined.push(factor); + } + } + combined.retain(|factor| factor.coefficient != 0.0); + combined.sort_by_cached_key(|factor| factor.base.structural_key()); + ExpProduct { factors: combined } + } + + fn mul(&self, other: &Self) -> Self { + let mut factors = self.factors.clone(); + factors.extend(other.factors.iter().cloned()); + Self::new(factors) + } + + fn powf(&self, power: f64) -> Self { + let factors = self + .factors + .iter() + .filter_map(|factor| { + let coefficient = factor.coefficient * power; + (coefficient != 0.0).then(|| ExpFactor { + base: factor.base.clone(), + coefficient, + }) + }) + .collect(); + ExpProduct { factors } + } + + fn is_empty(&self) -> bool { + self.factors.is_empty() + } + + fn is_valid(&self) -> bool { + self.factors.iter().all(|factor| { + let base = factor.base.value(); + base.is_finite() + && base > 0.0 + && base != 1.0 + && factor.coefficient.is_finite() + && ((base > 1.0 && factor.coefficient > 0.0) + || (base < 1.0 && factor.coefficient < 0.0)) + }) + } + + /// Prove an ordering using only structural cancellation and direct constant + /// comparisons. `None` means "not proved", never "equal". + fn cmp_proven(&self, other: &Self) -> Option { + if self == other { + return Some(Ordering::Equal); + } + + let mut left_count = 0; + let mut right_count = 0; + let mut left_single: Option<(&ExpBase, f64)> = None; + let mut right_single: Option<(&ExpBase, f64)> = None; + + for a in &self.factors { + if let Some(b) = other.factors.iter().find(|b| a.base == b.base) { + match a.base.coefficient_cmp(a.coefficient, b.coefficient)? { + Ordering::Equal => {} + Ordering::Greater => { + left_count += 1; + left_single = Some((&a.base, a.coefficient - b.coefficient)); + } + Ordering::Less => { + right_count += 1; + right_single = Some((&a.base, b.coefficient - a.coefficient)); + } + } + } else { + left_count += 1; + left_single = Some((&a.base, a.coefficient)); + } + } + + for b in &other.factors { + if !self.factors.iter().any(|a| a.base == b.base) { + right_count += 1; + right_single = Some((&b.base, b.coefficient)); + } + } + + match (left_count, right_count) { + (0, 0) => Some(Ordering::Equal), + (0, _) => Some(Ordering::Less), + (_, 0) => Some(Ordering::Greater), + (1, 1) => { + let (a_base, a_coefficient) = left_single?; + let (b_base, b_coefficient) = right_single?; + Self::cmp_single_factor(a_base, a_coefficient, b_base, b_coefficient) + } + _ => None, + } + } + + fn cmp_single_factor( + a_base: &ExpBase, + a_coefficient: f64, + b_base: &ExpBase, + b_coefficient: f64, + ) -> Option { + if a_base == b_base { + return a_base.coefficient_cmp(a_coefficient, b_coefficient); + } + + let (a_base, b_base) = ( + a_base.directly_comparable_value()?, + b_base.directly_comparable_value()?, + ); + if a_coefficient == b_coefficient { + let base_order = a_base.partial_cmp(&b_base)?; + return if a_coefficient > 0.0 { + Some(base_order) + } else { + Some(base_order.reverse()) + }; + } + + if a_base > 1.0 && b_base > 1.0 { + match ( + a_base.partial_cmp(&b_base)?, + a_coefficient.partial_cmp(&b_coefficient)?, + ) { + (Ordering::Greater | Ordering::Equal, Ordering::Greater | Ordering::Equal) => { + Some(Ordering::Greater) + } + (Ordering::Less | Ordering::Equal, Ordering::Less | Ordering::Equal) => { + Some(Ordering::Less) + } + _ => None, + } + } else if a_base < 1.0 && b_base < 1.0 { + match ( + a_base.partial_cmp(&b_base)?, + a_coefficient.partial_cmp(&b_coefficient)?, + ) { + (Ordering::Less | Ordering::Equal, Ordering::Less | Ordering::Equal) => { + Some(Ordering::Greater) + } + (Ordering::Greater | Ordering::Equal, Ordering::Greater | Ordering::Equal) => { + Some(Ordering::Less) + } + _ => None, + } + } else { + None + } + } + + /// Approximate common-base rate used only to order search work. It is not + /// stored and never participates in equality, dominance, pruning, widening, + /// serialization, or rendering. + fn log2_estimate(&self) -> f64 { + self.factors + .iter() + .map(|factor| factor.coefficient * factor.base.value().log2()) + .sum() + } + + fn sort_key(&self) -> String { + self.factors + .iter() + .map(|factor| format!("{}={:?}", factor.base.structural_key(), factor.coefficient)) + .collect::>() + .join(",") + } +} + +/// One growth monomial, e.g. `2^(3k) · n^2 · m · log(n)`. /// /// Empty maps represent `O(1)`. #[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct GrowthTerm { - /// variable → exponential rate, base normalized to 2 (`3^n → {n: log2 3}`); - /// linear exponent forms only. - exp: BTreeMap<&'static str, f64>, + /// Variable → canonical product of symbolic exponential factors. + exp: BTreeMap<&'static str, ExpProduct>, /// variable → polynomial degree (`0.5` covers `sqrt`). poly: BTreeMap<&'static str, f64>, /// variable → log power. @@ -92,16 +410,6 @@ impl GrowthTerm { } } - /// The `(exp rate, poly degree, log power)` triple for a variable, treating - /// an absent variable as `(0, 0, 0)`. - fn triple(&self, var: &str) -> (f64, f64, u32) { - ( - self.exp.get(var).copied().unwrap_or(0.0), - self.poly.get(var).copied().unwrap_or(0.0), - self.logs.get(var).copied().unwrap_or(0), - ) - } - /// A deterministic, platform-stable total-order key. `{v:?}` renders an /// `f64` at full precision and is stable across platforms. fn sort_key(&self) -> String { @@ -110,7 +418,7 @@ impl GrowthTerm { s.push('E'); s.push_str(k); s.push('='); - s.push_str(&format!("{v:?}")); + s.push_str(&v.sort_key()); s.push(';'); } s.push('|'); @@ -137,8 +445,11 @@ impl GrowthTerm { /// upper bound, since `(log v)^p ≤ (log v)^⌈p⌉` for `v ≥ 2`). fn powf(&self, k: f64) -> GrowthTerm { let mut r = GrowthTerm::one(); - for (v, rate) in &self.exp { - r.exp.insert(v, rate * k); + for (v, product) in &self.exp { + let product = product.powf(k); + if !product.is_empty() { + r.exp.insert(v, product); + } } for (v, deg) in &self.poly { r.poly.insert(v, deg * k); @@ -152,8 +463,16 @@ impl GrowthTerm { /// Multiply two monomials (add matching exponents). fn mul(&self, other: &GrowthTerm) -> GrowthTerm { let mut t = self.clone(); - for (k, v) in &other.exp { - *t.exp.entry(k).or_insert(0.0) += *v; + for (k, product) in &other.exp { + let combined = t + .exp + .get(k) + .map_or_else(|| product.clone(), |current| current.mul(product)); + if combined.is_empty() { + t.exp.remove(k); + } else { + t.exp.insert(k, combined); + } } for (k, v) in &other.poly { *t.poly.entry(k).or_insert(0.0) += *v; @@ -165,9 +484,10 @@ impl GrowthTerm { } /// Partial order on terms: `Some(Greater)` iff `self` dominates `other` - /// (`≥` on every variable and `>` on at least one), where per variable the - /// `(exp rate, poly degree, log power)` triples are compared - /// lexicographically. Returns `None` for incomparable terms. + /// (`≥` on every variable and `>` on at least one). Per variable, + /// exponential products are compared only when a symbolic proof succeeds; + /// polynomial degree and log power then break proven exponential ties. + /// Returns `None` for incomparable or unproved terms. fn cmp(&self, other: &GrowthTerm) -> Option { let mut vars: BTreeSet<&'static str> = BTreeSet::new(); for m in [&self.exp, &other.exp] { @@ -182,8 +502,28 @@ impl GrowthTerm { let mut saw_gt = false; let mut saw_lt = false; + let empty_exp = ExpProduct::empty(); for v in &vars { - match cmp_triple(self.triple(v), other.triple(v)) { + let exp_a = self.exp.get(v).unwrap_or(&empty_exp); + let exp_b = other.exp.get(v).unwrap_or(&empty_exp); + let exp_order = exp_a.cmp_proven(exp_b)?; + let order = if exp_order == Ordering::Equal { + self.poly + .get(v) + .copied() + .unwrap_or(0.0) + .partial_cmp(&other.poly.get(v).copied().unwrap_or(0.0))? + .then( + self.logs + .get(v) + .copied() + .unwrap_or(0) + .cmp(&other.logs.get(v).copied().unwrap_or(0)), + ) + } else { + exp_order + }; + match order { Ordering::Greater => saw_gt = true, Ordering::Less => saw_lt = true, Ordering::Equal => {} @@ -216,21 +556,13 @@ impl GrowthTerm { /// faster. Used only as a search-ordering / branch-and-bound heuristic, never /// for asymptotic dominance decisions (those go through [`GrowthTerm::cmp`]). fn magnitude(&self) -> f64 { - let e: f64 = self.exp.values().sum(); + let e: f64 = self.exp.values().map(ExpProduct::log2_estimate).sum(); let p: f64 = self.poly.values().sum(); let l: f64 = self.logs.values().map(|&x| x as f64).sum(); 1e6 * e + p + 1e-3 * l } } -/// Lexicographic comparison of `(exp rate, poly degree, log power)` triples. -fn cmp_triple(a: (f64, f64, u32), b: (f64, f64, u32)) -> Ordering { - a.0.partial_cmp(&b.0) - .unwrap_or(Ordering::Equal) - .then(a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal)) - .then(a.2.cmp(&b.2)) -} - impl Growth { /// Compute the growth class of an expression in a single bottom-up pass. pub fn from_expr(expr: &Expr) -> Growth { @@ -251,7 +583,7 @@ impl Growth { Expr::Add(a, b) => add(Growth::from_expr(a), Growth::from_expr(b)), Expr::Mul(a, b) => mul(Growth::from_expr(a), Growth::from_expr(b)), Expr::Pow(base, exp) => pow_expr(base, exp), - Expr::Exp(a) => exponential(std::f64::consts::E, a), + Expr::Exp(a) => exponential(ExpBase::Natural, a), Expr::Log(a) => log_growth(Growth::from_expr(a)), Expr::Sqrt(a) => pow_const(Growth::from_expr(a), 0.5), Expr::Factorial(_) => Growth::Unknown, @@ -293,8 +625,8 @@ impl Growth { /// or `None` for [`Growth::Unknown`]. Terms are already in the deterministic /// sort order, so the rendered expression is platform-stable. /// - /// Exponential rates are de-normalized from base 2 back to a readable base - /// (`{n: 1} → 2^n`, `{n: log2 3} → 3^n`, `{n: log2 e} → exp(n)`). + /// Exponential factors are rendered directly from their authoritative + /// symbolic bases and coefficients; no base reconstruction is performed. pub fn to_expr(&self) -> Option { match self { Growth::Unknown => None, @@ -328,8 +660,8 @@ impl Growth { /// Render one monomial as a product of its factors (or `Const(1)` when empty). fn term_to_expr(t: &GrowthTerm) -> Expr { let mut factors: Vec = Vec::new(); - for (v, rate) in &t.exp { - factors.push(exp_factor(v, *rate)); + for (v, product) in &t.exp { + factors.extend(product.factors.iter().map(|factor| exp_factor(v, factor))); } for (v, deg) in &t.poly { factors.push(poly_factor(v, *deg)); @@ -344,16 +676,17 @@ fn term_to_expr(t: &GrowthTerm) -> Expr { } } -/// Render `2^(rate·v)` with a readable base: `exp(v)` when the base is `e`, an -/// integer/decimal base otherwise (snapped to remove float round-trip noise). -fn exp_factor(v: &'static str, rate: f64) -> Expr { - let base = 2f64.powf(rate); - if (base - std::f64::consts::E).abs() < 1e-9 { - return Expr::Exp(Box::new(Expr::Var(v))); +/// Render a stored exponential factor without changing its base or coefficient. +fn exp_factor(v: &'static str, factor: &ExpFactor) -> Expr { + let exponent = if factor.coefficient == 1.0 { + Expr::Var(v) + } else { + Expr::Const(factor.coefficient) * Expr::Var(v) + }; + match &factor.base { + ExpBase::Constant(base) => Expr::pow(base.clone(), exponent), + ExpBase::Natural => Expr::Exp(Box::new(exponent)), } - // Snap away round-trip noise so `2^log2(3)` renders as `3^v`, not `3.0000…^v`. - let snapped = (base * 1e9).round() / 1e9; - Expr::pow(Expr::Const(snapped), Expr::Var(v)) } /// Render `v^degree` (`Display` turns degree `0.5` into `sqrt(v)`). @@ -378,7 +711,11 @@ fn log_factor(v: &'static str, power: u32) -> Expr { /// Prune a bag of terms to its maximal antichain: drop any term dominated by /// another and collapse exact duplicates. The resulting *set* is independent of /// input order. -fn prune(terms: Vec) -> Vec { +fn prune(mut terms: Vec) -> Vec { + // Proven-equal terms can retain different symbolic spellings (for example, + // `exp(n)` and a literal-e base). Sort first so the representative does not + // depend on operand order. + terms.sort_by_cached_key(GrowthTerm::sort_key); let mut result: Vec = Vec::new(); for t in terms { if result.iter().any(|r| r.dominates_or_eq(&t)) { @@ -390,46 +727,87 @@ fn prune(terms: Vec) -> Vec { result } -/// The single term taking the componentwise maximum of every exponent — a valid -/// upper bound that dominates every input term. -fn componentwise_max(terms: &[GrowthTerm]) -> GrowthTerm { +/// Construct a componentwise upper bound when every exponential component has +/// a symbolically proven maximal product. +fn componentwise_max(terms: &[GrowthTerm]) -> Option { let mut m = GrowthTerm::one(); - for t in terms { - for (k, v) in &t.exp { - let e = m.exp.entry(*k).or_insert(0.0); - if *v > *e { - *e = *v; + let mut vars = BTreeSet::new(); + for term in terms { + vars.extend(term.exp.keys().copied()); + vars.extend(term.poly.keys().copied()); + vars.extend(term.logs.keys().copied()); + } + + for var in vars { + let empty_exp = ExpProduct::empty(); + let mut maximum = &empty_exp; + for product in terms + .iter() + .map(|term| term.exp.get(var).unwrap_or(&empty_exp)) + { + if matches!(product.cmp_proven(maximum), Some(Ordering::Greater)) { + maximum = product; } } - for (k, v) in &t.poly { - let e = m.poly.entry(*k).or_insert(0.0); - if *v > *e { - *e = *v; - } + if !terms.iter().all(|term| { + matches!( + maximum.cmp_proven(term.exp.get(var).unwrap_or(&empty_exp)), + Some(Ordering::Greater | Ordering::Equal) + ) + }) { + return None; } - for (k, v) in &t.logs { - let e = m.logs.entry(*k).or_insert(0); - if *v > *e { - *e = *v; + if !maximum.is_empty() { + m.exp.insert(var, maximum.clone()); + } + + let mut max_poly = 0.0_f64; + let mut max_logs = 0_u32; + for term in terms { + let degree = term.poly.get(var).copied().unwrap_or(0.0); + if !degree.is_finite() { + return None; } + max_poly = max_poly.max(degree); + max_logs = max_logs.max(term.logs.get(var).copied().unwrap_or(0)); + } + if max_poly > 0.0 { + m.poly.insert(var, max_poly); + } + if max_logs > 0 { + m.logs.insert(var, max_logs); } } - m + Some(m) +} + +fn growth_term_is_valid(term: &GrowthTerm) -> bool { + term.exp + .values() + .all(|product| !product.is_empty() && product.is_valid()) + && term + .poly + .values() + .all(|degree| degree.is_finite() && *degree >= 0.0) } /// Prune, apply the antichain cap (widening upward on overflow), and sort into /// the deterministic total order. fn make_growth(terms: Vec) -> Growth { + if !terms.iter().all(growth_term_is_valid) { + return Growth::Unknown; + } let mut pruned = prune(terms); if pruned.len() > ANTICHAIN_CAP { - pruned = vec![componentwise_max(&pruned)]; - } - // Axiom guard: exponents are nonnegative (weak monotonicity precondition). - for t in &pruned { - debug_assert!(t.exp.values().all(|r| *r >= 0.0), "negative exp rate"); - debug_assert!(t.poly.values().all(|d| *d >= 0.0), "negative poly degree"); + let Some(widened) = componentwise_max(&pruned) else { + return Growth::Unknown; + }; + if !pruned.iter().all(|term| widened.dominates_or_eq(term)) { + return Growth::Unknown; + } + pruned = vec![widened]; } - pruned.sort_by_key(|a| a.sort_key()); + debug_assert!(pruned.iter().all(growth_term_is_valid)); Growth::Terms(pruned) } @@ -481,17 +859,22 @@ fn pow_expr(base: &Expr, exp: &Expr) -> Growth { pow_const(Growth::from_expr(base), k) } else if let Some(c) = base.constant_value() { // Constant base, variable exponent → exponential. - exponential(c, exp) + if c.is_finite() { + exponential(ExpBase::Constant(base.clone()), exp) + } else { + Growth::Unknown + } } else { // Variable base and variable exponent (e.g. n^m) → not representable. Growth::Unknown } } -/// Transfer function for `c^exp` (also `exp(x)` with `c = e`). Requires a linear -/// exponent; anything else widens to [`Growth::Unknown`]. -fn exponential(c: f64, exp: &Expr) -> Growth { - if c <= 0.0 { +/// Transfer function for a symbolic fixed-base exponential. The base's numeric +/// value is used only for domain and monotonic-direction checks. +fn exponential(base: ExpBase, exp: &Expr) -> Growth { + let c = base.value(); + if !c.is_finite() || c <= 0.0 { return Growth::Unknown; } if c == 1.0 { @@ -501,17 +884,15 @@ fn exponential(c: f64, exp: &Expr) -> Growth { match linear_form(exp) { None => Growth::Unknown, // nonlinear exponent Some(coeffs) => { - // `log2c` is negative for a fractional base `0 < c < 1`, so a - // negative exponent coefficient (e.g. `0.5^(-n) = 2^n`) yields a - // positive rate, while a positive one (`0.5^n`) yields a negative - // rate that is dropped below. - let log2c = c.log2(); let mut term = GrowthTerm::one(); for (v, coeff) in coeffs { - let rate = coeff * log2c; - // Drop non-positive rates (upward widening: 2^(n - m) ≤ 2^n). - if rate > 0.0 { - term.exp.insert(v, rate); + if !coeff.is_finite() { + return Growth::Unknown; + } + // Drop decaying directions as an upward widening. A fractional + // base grows only along negative exponent coefficients. + if (c > 1.0 && coeff > 0.0) || (c < 1.0 && coeff < 0.0) { + term.exp.insert(v, ExpProduct::single(base.clone(), coeff)); } } make_growth(vec![term]) @@ -585,15 +966,15 @@ fn log_growth(g: Growth) -> Growth { } /// `log` of a single monomial, returned as its own (small) antichain of -/// summands. `log(∏2^(rᵢ·vᵢ) · ∏vⱼ^aⱼ · ∏(log vₖ)^sₖ)` distributes over the +/// summands. `log(∏ baseᵢ^(rᵢ·vᵢ) · ∏vⱼ^aⱼ · ∏(log vₖ)^sₖ)` distributes over the /// product into a *sum* of the log of each factor, so every factor class of the /// monomial contributes its own summand — none may be dropped (e.g. `log(2^n·m)` /// is `n + log m`, not `n`). `make_growth`/`prune` then collapse any dominated /// summands (so `log(2^n·n^2)` reduces back to `n`). fn log_term(t: &GrowthTerm) -> Vec { let mut out = Vec::new(); - // log(2^(r·v)) ≍ r·v ≍ v: each positive-rate exponential factor is linear. - for v in t.exp.iter().filter(|(_, r)| **r > 0.0).map(|(k, _)| *k) { + // Every stored exponential product grows, so its logarithm is linear. + for v in t.exp.keys().copied() { let mut g = GrowthTerm::one(); g.poly.insert(v, 1.0); out.push(g); @@ -633,7 +1014,7 @@ impl<'de> serde::Deserialize<'de> for GrowthTerm { { #[derive(serde::Deserialize)] struct Repr { - exp: BTreeMap, + exp: BTreeMap, poly: BTreeMap, logs: BTreeMap, } @@ -641,11 +1022,20 @@ impl<'de> serde::Deserialize<'de> for GrowthTerm { Box::leak(s.into_boxed_str()) } let r = Repr::deserialize(deserializer)?; - Ok(GrowthTerm { - exp: r.exp.into_iter().map(|(k, v)| (leak(k), v)).collect(), + let term = GrowthTerm { + exp: r + .exp + .into_iter() + .map(|(k, product)| (leak(k), ExpProduct::new(product.factors))) + .collect(), poly: r.poly.into_iter().map(|(k, v)| (leak(k), v)).collect(), logs: r.logs.into_iter().map(|(k, v)| (leak(k), v)).collect(), - }) + }; + if growth_term_is_valid(&term) { + Ok(term) + } else { + Err(serde::de::Error::custom("invalid symbolic growth term")) + } } } diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 51f6ead4a..f1d7b99dd 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -1,7 +1,10 @@ //! Unit tests for the symbolic growth domain (`src/growth.rs`). -use super::{add, make_growth, mul, Growth, GrowthTerm}; +use super::{ + add, componentwise_max, make_growth, mul, ExpBase, ExpFactor, ExpProduct, Growth, GrowthTerm, +}; use crate::expr::Expr; +use std::cmp::Ordering; /// Build a term from `(exp, poly, logs)` entry lists. fn term( @@ -10,7 +13,15 @@ fn term( logs: &[(&'static str, u32)], ) -> GrowthTerm { GrowthTerm { - exp: exp.iter().copied().collect(), + exp: exp + .iter() + .map(|(variable, rate)| { + ( + *variable, + ExpProduct::single(ExpBase::Constant(Expr::Const(2.0)), *rate), + ) + }) + .collect(), poly: poly.iter().copied().collect(), logs: logs.iter().copied().collect(), } @@ -27,6 +38,18 @@ fn g(s: &str) -> Growth { Growth::from_expr(&Expr::parse(s)) } +fn exp_product(factors: &[(f64, f64)]) -> ExpProduct { + ExpProduct::new( + factors + .iter() + .map(|(base, coefficient)| ExpFactor { + base: ExpBase::Constant(Expr::Const(*base)), + coefficient: *coefficient, + }) + .collect(), + ) +} + // --- The six named verification cases from issue #1075 --- /// 1. No-expansion regression: the nested sum-of-squares shape that OOM'd in @@ -75,7 +98,7 @@ fn test_growth_incomparable_terms_both_kept() { } /// 4. Exponent rates are exact: `2^(2n)` dominates `2^n` (not conversely), and -/// `3^n` dominates `2^n` via base-2 rates. +/// `3^n` dominates `2^n` via direct symbolic base comparison. #[test] fn test_growth_exponent_rates_exact() { let two_2n = g("2^(2*n)"); @@ -86,6 +109,140 @@ fn test_growth_exponent_rates_exact() { let three_n = g("3^n"); assert!(three_n.dominates(&two_n)); assert!(!two_n.dominates(&three_n)); + + let exp_2n = g("exp(2*n)"); + let exp_n = g("exp(n)"); + assert!(exp_2n.dominates(&exp_n)); + assert!(!exp_n.dominates(&exp_2n)); + + assert!(g("0.5^(-2*n)").dominates(&g("0.5^(-n)"))); + assert!(g("0.25^(-n)").dominates(&g("0.5^(-n)"))); +} + +/// Multi-base products remain incomparable when the conservative symbolic +/// rules cannot prove an ordering, even when a stronger algebra system could. +#[test] +fn test_growth_unproved_multi_base_comparison_is_retained() { + let left = g("2^(2*n) * 3^n"); + let right = g("2^n * 4^n"); + assert!(!left.dominates(&right)); + assert!(!right.dominates(&left)); + assert_eq!(terms_of(&g("2^(2*n) * 3^n + 2^n * 4^n")).len(), 2); +} + +#[test] +fn test_exponential_product_proof_rules() { + let empty = ExpProduct::empty(); + let two = exp_product(&[(2.0, 1.0)]); + let two_squared = exp_product(&[(2.0, 2.0)]); + let three = exp_product(&[(3.0, 1.0)]); + + assert_eq!(empty.cmp_proven(&empty), Some(Ordering::Equal)); + assert_eq!(empty.cmp_proven(&two), Some(Ordering::Less)); + assert_eq!(two.cmp_proven(&empty), Some(Ordering::Greater)); + assert_eq!(two_squared.cmp_proven(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_proven(&two_squared), Some(Ordering::Less)); + assert_eq!(three.cmp_proven(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_proven(&three), Some(Ordering::Less)); + + assert_eq!( + exp_product(&[(3.0, 2.0)]).cmp_proven(&exp_product(&[(2.0, 1.0)])), + Some(Ordering::Greater) + ); + assert_eq!( + exp_product(&[(2.0, 1.0)]).cmp_proven(&exp_product(&[(3.0, 2.0)])), + Some(Ordering::Less) + ); + assert_eq!( + exp_product(&[(2.0, 3.0)]).cmp_proven(&exp_product(&[(3.0, 1.0)])), + None + ); + + assert_eq!( + exp_product(&[(0.25, -1.0)]).cmp_proven(&exp_product(&[(0.5, -1.0)])), + Some(Ordering::Greater) + ); + assert_eq!( + exp_product(&[(0.5, -1.0)]).cmp_proven(&exp_product(&[(0.25, -1.0)])), + Some(Ordering::Less) + ); + assert_eq!( + exp_product(&[(0.25, -2.0)]).cmp_proven(&exp_product(&[(0.5, -1.0)])), + Some(Ordering::Greater) + ); + assert_eq!( + exp_product(&[(0.5, -1.0)]).cmp_proven(&exp_product(&[(0.25, -2.0)])), + Some(Ordering::Less) + ); + assert_eq!( + exp_product(&[(0.25, -1.0)]).cmp_proven(&exp_product(&[(0.5, -2.0)])), + None + ); + assert_eq!(two.cmp_proven(&exp_product(&[(0.5, -1.0)])), None); + + let natural = ExpProduct::single(ExpBase::Natural, 1.0); + assert_eq!(natural.cmp_proven(&two), Some(Ordering::Greater)); + assert_eq!(two.cmp_proven(&natural), Some(Ordering::Less)); + + // Arbitrary constant subtrees are preserved but compared structurally only. + let composite = ExpProduct::single(ExpBase::Constant(Expr::parse("1 + 2")), 1.0); + assert_eq!(composite.cmp_proven(&three), None); + + // Two residual products with no factorwise proof remain incomparable. + assert_eq!( + exp_product(&[(2.0, 2.0), (3.0, 1.0)]).cmp_proven(&exp_product(&[(2.0, 1.0), (4.0, 1.0)])), + None + ); +} + +#[test] +fn test_exponential_product_canonicalization() { + let combined = ExpProduct::new(vec![ + ExpFactor { + base: ExpBase::Constant(Expr::Const(2.0)), + coefficient: 1.0, + }, + ExpFactor { + base: ExpBase::Constant(Expr::Const(2.0)), + coefficient: 2.0, + }, + ExpFactor { + base: ExpBase::Constant(Expr::Const(3.0)), + coefficient: 0.0, + }, + ]); + assert_eq!(combined, exp_product(&[(2.0, 3.0)])); + + let cancelled = ExpProduct::new(vec![ + ExpFactor { + base: ExpBase::Constant(Expr::Const(2.0)), + coefficient: 1.0, + }, + ExpFactor { + base: ExpBase::Constant(Expr::Const(2.0)), + coefficient: -1.0, + }, + ]); + assert!(cancelled.is_empty()); +} + +#[test] +fn test_growth_multi_base_product_is_deterministic() { + let left = g("2^n * 3^n"); + let right = g("3^n * 2^n"); + assert_eq!(left, right); + assert_eq!( + serde_json::to_string(&left).unwrap(), + serde_json::to_string(&right).unwrap() + ); +} + +#[test] +fn test_proven_equal_exponential_spelling_is_deterministic() { + let natural_first = g("exp(n) + 2.718281828459045^n"); + let literal_first = g("2.718281828459045^n + exp(n)"); + assert_eq!(natural_first, literal_first); + assert_eq!(natural_first.to_big_o(), literal_first.to_big_o()); } /// 5. Widening: subtraction widens to addition, including the `sqrt((a-b)^2)` @@ -168,10 +325,39 @@ fn test_growth_to_big_o() { ); } +/// Exponential bases are authoritative symbolic data, not values reconstructed +/// from a rounded base-2 logarithm. +#[test] +fn test_growth_preserves_exponential_base() { + assert_eq!(g("3^n").to_big_o(), "O(3^n)"); + assert_eq!(g("1.0000000001^n").to_big_o(), "O(1.0000000001^n)"); + assert_eq!(g("2.7182818289^n").to_big_o(), "O(2.7182818289^n)"); + assert_eq!(g("2^(n / 2)").to_big_o(), "O(2^(0.5 * n))"); +} + +#[test] +fn test_growth_exponential_roundtrip_is_exact() { + for source in [ + "3^n", + "2^(n / 2)", + "exp(2 * n)", + "2^n * 3^n", + "3^n * n^2 * log(n)", + ] { + let growth = g(source); + let rendered = growth.to_expr().expect("growth should be representable"); + assert_eq!( + Growth::from_expr(&rendered), + growth, + "exponential growth changed while round-tripping {source} via {rendered}" + ); + } +} + /// `exp(n)` uses base e; a decaying/unit base is bounded by O(1). #[test] fn test_growth_exponential_variants() { - // exp(n) = e^n = 2^(log2(e) * n): exponential, dominates any polynomial. + // exp(n) is represented directly as e^n: exponential, dominates any polynomial. let en = g("exp(n)"); assert!(en.dominates(&g("n^5"))); // 2^(n-m) ≤ 2^n after dropping the negative rate. @@ -179,8 +365,10 @@ fn test_growth_exponential_variants() { // Unit base is O(1); a decaying base with a growing exponent is O(1) too. assert_eq!(g("1^n"), g("7")); assert_eq!(g("0.5^n"), g("7")); - // A fractional base with a *negative* exponent grows: 0.5^(-n) = 2^n. - assert_eq!(g("0.5^(-n)"), g("2^n")); + // A fractional base with a negative exponent grows and retains that exact + // symbolic base instead of being translated through a common logarithm. + assert_eq!(g("0.5^(-n)").to_big_o(), "O(0.5^(-1 * n))"); + assert!(g("0.5^(-n)").dominates(&g("n^100"))); } /// `log` lowers each level: log of an exponential is linear, log of a @@ -189,6 +377,8 @@ fn test_growth_exponential_variants() { fn test_growth_log_levels() { // log(2^n) ≍ n. assert_eq!(g("log(2^n)"), g("n")); + assert_eq!(g("log(3^n)"), g("n")); + assert_eq!(g("log(exp(n))"), g("n")); // log(n) is a single log term. assert_eq!( g("log(n)"), @@ -246,6 +436,41 @@ fn test_growth_antichain_cap_widens() { } } +#[test] +fn test_growth_componentwise_max_with_symbolic_exponentials() { + let inputs = vec![ + terms_of(&g("2^n * n")).first().unwrap().clone(), + terms_of(&g("3^n * log(n)")).first().unwrap().clone(), + ]; + let upper = componentwise_max(&inputs).expect("3^n is a proven exponential maximum"); + assert!(inputs.iter().all(|term| upper.dominates_or_eq(term))); + assert_eq!(Growth::Terms(vec![upper]).to_big_o(), "O(3^n * n * log(n))"); + + let invalid = GrowthTerm { + exp: BTreeMap::new(), + poly: [("n", f64::NAN)].into_iter().collect(), + logs: BTreeMap::new(), + }; + assert_eq!(componentwise_max(&[invalid]), None); +} + +/// If symbolic exponential products have no provable componentwise maximum, +/// cap overflow widens to Unknown instead of guessing an under-bound. +#[test] +fn test_growth_antichain_cap_with_unproved_exponentials_is_unknown() { + let terms = (1..=33) + .map(|i| GrowthTerm { + exp: [("n", exp_product(&[(2.0, i as f64), (3.0, 1.0 / i as f64)]))] + .into_iter() + .collect(), + poly: BTreeMap::new(), + logs: BTreeMap::new(), + }) + .collect(); + + assert_eq!(make_growth(terms), Growth::Unknown); +} + /// Structured serde round-trips (with `&'static str` keys leaked on read), and /// `Unknown` round-trips. #[test] @@ -260,6 +485,38 @@ fn test_growth_serde_roundtrip() { serde_json::from_str::(&unknown_json).unwrap(), Growth::Unknown ); + + // Every constant Expr form admitted as a symbolic base remains lossless. + for source in [ + "(1 + 1)^n", + "(2 * 2)^n", + "(2^2)^n", + "exp(1)^n", + "log(3)^n", + "sqrt(4)^n", + "factorial(3)^n", + "exp(n)", + ] { + let value = g(source); + let json = serde_json::to_string(&value).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), value); + } + + // The transient base-2-rate representation from the unmerged PR is not + // guessed back into a symbolic base. + let old_rate_only = r#"{"Terms":[{"exp":{"n":1.0},"poly":{},"logs":{}}]}"#; + assert!(serde_json::from_str::(old_rate_only).is_err()); + + let variable_base = r#"{"Constant":{"Var":"n"}}"#; + assert!(serde_json::from_str::(variable_base).is_err()); + + let invalid = Growth::Terms(vec![GrowthTerm { + exp: [("n", ExpProduct::empty())].into_iter().collect(), + poly: BTreeMap::new(), + logs: BTreeMap::new(), + }]); + let invalid_json = serde_json::to_string(&invalid).unwrap(); + assert!(serde_json::from_str::(&invalid_json).is_err()); } // --- Randomized property tests (#1077) --- @@ -390,24 +647,45 @@ fn gen_nonlinear(rng: &mut SplitMix64) -> Expr { } } -fn gen_expr(rng: &mut SplitMix64, depth: u32) -> Expr { +const E_BELOW: f64 = std::f64::consts::E - 1e-10; +const E_ABOVE: f64 = std::f64::consts::E + 1e-10; +const STABLE_EXPONENTIAL_BASES: &[f64] = &[2.0, E_BELOW, E_ABOVE, 3.0]; +const ADVERSARIAL_EXPONENTIAL_BASES: &[f64] = &[1.0000000001, 2.0, E_BELOW, E_ABOVE, 3.0]; + +fn gen_exponential_base(rng: &mut SplitMix64, bases: &[f64]) -> Expr { + Expr::Const(bases[rng.below(bases.len() as u64) as usize]) +} + +fn gen_expr(rng: &mut SplitMix64, depth: u32, exponential_bases: &[f64]) -> Expr { if depth == 0 { return gen_leaf(rng); } match rng.below(100) { 0..=19 => gen_leaf(rng), - 20..=39 => Expr::Add(b(gen_expr(rng, depth - 1)), b(gen_expr(rng, depth - 1))), - 40..=54 => Expr::Mul(b(gen_expr(rng, depth - 1)), b(gen_expr(rng, depth - 1))), + 20..=39 => Expr::Add( + b(gen_expr(rng, depth - 1, exponential_bases)), + b(gen_expr(rng, depth - 1, exponential_bases)), + ), + 40..=54 => Expr::Mul( + b(gen_expr(rng, depth - 1, exponential_bases)), + b(gen_expr(rng, depth - 1, exponential_bases)), + ), 55..=69 => Expr::pow( - gen_expr(rng, depth - 1), + gen_expr(rng, depth - 1, exponential_bases), Expr::Const((1 + rng.below(3)) as f64), ), - 70..=79 => Expr::Sqrt(b(gen_expr(rng, depth - 1))), - 80..=89 => Expr::Log(b(gen_expr(rng, depth - 1))), - 90..=96 => Expr::pow(Expr::Const(2.0), gen_linear(rng)), + 70..=79 => Expr::Sqrt(b(gen_expr(rng, depth - 1, exponential_bases))), + 80..=89 => Expr::Log(b(gen_expr(rng, depth - 1, exponential_bases))), + 90..=96 => Expr::pow( + gen_exponential_base(rng, exponential_bases), + gen_linear(rng), + ), 97..=98 => Expr::Exp(b(gen_var(rng))), // ~1% per node: a nonlinear exponent → Unknown (a minority of trees). - _ => Expr::pow(Expr::Const(2.0), gen_nonlinear(rng)), + _ => Expr::pow( + gen_exponential_base(rng, exponential_bases), + gen_nonlinear(rng), + ), } } @@ -423,6 +701,9 @@ fn gen_factor(rng: &mut SplitMix64) -> Expr { 1 => Expr::pow(v, Expr::Const((1 + rng.below(3)) as f64)), 2 => Expr::Sqrt(b(v)), 3 => Expr::Log(b(v)), + // Keep the numeric dominance harness on one common base: different + // fixed bases can have crossovers beyond its finite observation window. + // Multi-base behavior is covered by symbolic proof tests above. 4 => Expr::pow(Expr::Const(2.0), v), _ => Expr::pow(Expr::Const(2.0), Expr::Const((1 + rng.below(3)) as f64) * v), } @@ -469,7 +750,7 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub let mut r = UbResult::default(); for _ in 0..iters { - let e = gen_expr(&mut rng, MAX_DEPTH); + let e = gen_expr(&mut rng, MAX_DEPTH, STABLE_EXPONENTIAL_BASES); let g = transfer(&e); let gexpr = match g.to_expr() { Some(x) => x, @@ -571,13 +852,13 @@ fn broken_from_expr(e: &Expr) -> Growth { } else { pow_const(broken_from_expr(base), k) } - } else if let Some(c) = base.constant_value() { - exponential(c, exp) + } else if base.constant_value().is_some() { + exponential(ExpBase::Constant(base.as_ref().clone()), exp) } else { Growth::Unknown } } - Expr::Exp(a) => exponential(std::f64::consts::E, a), + Expr::Exp(a) => exponential(ExpBase::Natural, a), Expr::Log(a) => log_growth(broken_from_expr(a)), Expr::Sqrt(a) => pow_const(broken_from_expr(a), 0.5), Expr::Factorial(_) => Growth::Unknown, @@ -628,14 +909,8 @@ fn test_growth_property_upper_bound_negative_control() { // --- Contract 2: idempotence --- -/// Approximate `GrowthTerm` equality: exact variable sets and log powers, -/// tolerance on exp rates and poly degrees. Exact f64 `==` is too brittle here -/// because `to_expr` snaps exponential bases to 1e-9 for readable rendering -/// (`exp{n:2.5}` → `5.656854249^n`), and re-deriving the rate via `log2` of the -/// snapped base drifts by ~1e-10. Idempotence therefore holds *structurally* -/// and up to rendering precision, which is what this compares. The tolerance is -/// far tighter than any semantic exponent gap, so structural regressions -/// (changed variable, dropped term, wrong log power, altered degree) still fail. +/// Exponential factors round-trip exactly. Polynomial degrees retain the +/// pre-existing tolerance for unrelated floating-point power composition. fn map_approx_eq(a: &BTreeMap<&'static str, f64>, b: &BTreeMap<&'static str, f64>) -> bool { a.len() == b.len() && a.iter() @@ -643,7 +918,7 @@ fn map_approx_eq(a: &BTreeMap<&'static str, f64>, b: &BTreeMap<&'static str, f64 } fn term_approx_eq(x: &GrowthTerm, y: &GrowthTerm) -> bool { - map_approx_eq(&x.exp, &y.exp) && map_approx_eq(&x.poly, &y.poly) && x.logs == y.logs + x.exp == y.exp && map_approx_eq(&x.poly, &y.poly) && x.logs == y.logs } fn growth_approx_eq(a: &Growth, b: &Growth) -> bool { @@ -665,7 +940,9 @@ fn test_growth_property_idempotence() { let mut unknown = 0usize; for _ in 0..UB_ITERS { - let e = gen_expr(&mut rng, MAX_DEPTH); + // Idempotence is purely symbolic, so it can safely exercise bases near + // one whose numeric crossover lies far beyond the f64 test window. + let e = gen_expr(&mut rng, MAX_DEPTH, ADVERSARIAL_EXPONENTIAL_BASES); let g = Growth::from_expr(&e); let rendered = match g.to_expr() { Some(x) => x, @@ -709,7 +986,7 @@ fn single_term(g: &Growth) -> Option<&GrowthTerm> { /// `(total exp rate, total poly degree, total log power)` on the joint diagonal. fn totals(t: &GrowthTerm) -> (f64, f64, f64) { ( - t.exp.values().sum(), + t.exp.values().map(ExpProduct::log2_estimate).sum(), t.poly.values().sum(), t.logs.values().map(|&x| x as f64).sum(), ) From 33026dcec44d488a20d749b4e6de6eddbaa34d82 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Fri, 17 Jul 2026 01:43:19 +0800 Subject: [PATCH 19/31] Fix unsound measured path pruning --- docs/design/symbolic-growth-domain.md | 43 +++-- src/rules/graph.rs | 142 +++++++++++--- src/rules/pareto.rs | 124 +++++-------- src/rules/registry.rs | 13 -- src/solvers/ilp/solver.rs | 112 ++++++++---- src/unit_tests/rules/pareto.rs | 254 ++++++++++++++++++++------ 6 files changed, 452 insertions(+), 236 deletions(-) diff --git a/docs/design/symbolic-growth-domain.md b/docs/design/symbolic-growth-domain.md index ad8f8e5aa..e5ae1fdf8 100644 --- a/docs/design/symbolic-growth-domain.md +++ b/docs/design/symbolic-growth-domain.md @@ -98,7 +98,7 @@ Selected (rough, agentic-coding-adjusted estimates): |---|---|---| | F1 | Growth domain: `GrowthTerm`/`Growth` antichain, symbolic dominance, pruning, absorbing `Unknown`, caps with upward widening | ~2–3 days | | F2 | Replace the `big_o.rs` pipeline with the growth domain; delete `canonical.rs`; issue-1069 regression + whole-graph CI budget tests | ~1–2 days | -| F3 | Pareto label search kernel replacing `dijkstra`, with two label domains: F3a asymptotic (`Growth` per size field) and F3b concrete instance (**measured**: execute reductions, prune via symbolic pre-flight guards + budget + branch-and-bound) | ~3–4 days | +| F3 | Pareto label search kernel replacing `dijkstra`, with two label domains: F3a asymptotic (`Growth` per size field) and F3b concrete instance (**measured**: execute reductions and apply post-construction measured budgets) | ~3–4 days | | F12 | Per-edge overhead calibration test: canonical examples run through `reduce_to()`, measured sizes must not exceed formula predictions | ~0.5–1 day | | F4 | CLI/MCP surface: Pareto-front output, deterministic ordering, `--json` no longer renders text | ~1–2 days | | F5+F11 (merged support work, folded into F1/F3/F4) | Redundancy check (`find_dominated_rules`) rewired to the same dominance order; `Growth` serde + `Display` consumed by CLI JSON and paper export | ~1.5 days | @@ -236,8 +236,7 @@ pub trait PathLabel: Clone { per-node bag cap with a **deterministic tie-break** (fewest hops, then lexicographic node-name order) — never iteration-order truncation. A label evicted from a bag (dominated or cap-truncated) has its arena slot's label freed immediately, - so the bag cap genuinely bounds retained per-node label memory — critical for the - measured label, whose labels each pin an `Rc` reduction-instance chain. + so the bag cap genuinely bounds retained per-node label memory. - Label domains: - **F3a asymptotic:** label = `BTreeMap` mapping each size field of the current node to its growth in the source's variables; `extend` substitutes @@ -252,25 +251,24 @@ pub trait PathLabel: Clone { between concrete candidates. Label = the actual `ProblemSize` measured on the constructed intermediate problem (plus the reduction chain itself, reused for solving/witness extraction by the winner); `extend` executes the edge's - `reduce_to()` and measures. Pruning stack, in order: - 1. **Symbolic pre-flight guard:** evaluate the edge's overhead formula at the - current *measured* size; if even the (upper-bound) prediction exceeds the - hard size budget, skip without executing. The overhead formulas are - uncalibrated upper bounds, so this guard errs toward over-skipping — a - predicted-over-budget construction is never started. This is a strong - mitigation, not an absolute anti-OOM guarantee. - 2. **Measured budget check** after execution. - 3. **Componentwise measured-size dominance** — heuristic under a documented - size-monotone-future assumption; `--exhaustive` disables this one guard - (1–2 remain, and are sound), falling back to budgeted full enumeration. + `reduce_to()` and measures. The only instance-budget guard is the **measured + budget check after execution**. Evaluating an asymptotic expression at one point + is not a certified concrete bound, so overhead formulas do not prune measured + candidates. This also means the budget cannot prevent the construction itself + from exhausting memory. + + Measured search uses **no dominance pruning**. `ProblemSize` omits instance + structure, and equal-size intermediate instances can produce different sizes under + a later structure-dependent reduction. Even serialized-state equivalence is not + used to discard a route. It is therefore a separate exhaustive simple-path + enumeration, not a label domain in the capped Pareto kernel. Note the measured label deliberately does **not** use branch-and-bound: a reduction can *shrink* the measured size, so the cost is non-monotone and a B&B bound could prune a partial route that would still finish smallest. - Memory is bounded not by B&B but by immediate eviction: the kernel frees a - label's `Rc` reduction chain the instant the label leaves its bag (dominated - or cap-truncated), so retained reduction instances are bounded by the live bag - entries (≤ bag cap per node) × chain length. + No hop or bag cap truncates this enumeration, so its time and retained constructed + state can grow exponentially with the number of simple paths. This also does not + bound temporary memory used inside `reduce_to()`. This fixes the path-dependent-cost hole in the current Dijkstra *and* removes the dependency on formula accuracy for concrete decisions. - `find_cheapest_path*` become thin wrappers returning the front (instance mode @@ -278,8 +276,9 @@ pub trait PathLabel: Clone { - `find_dominated_rules` / `compare_overhead` (`src/rules/analysis.rs`) are rewired to the same `dominates` order, deleting their bespoke comparison heuristics — one trusted comparison everywhere (former F5). -- `all_simple_paths`-based enumeration (`find_all_paths`, `find_paths_up_to`) remains - solely for the explicit `--all` listing use case, not for optimum-finding. +- `all_simple_paths`-based enumeration remains the explicit `--all` listing mechanism; + measured optimum-finding now performs its own execution-aware simple-path enumeration + because no sound state-level dominance relation is available. Alternatives considered: enumerate-then-filter (rejected: combinatorial growth as the graph densifies, and any truncation limit is iteration-order-dependent — the sibling @@ -289,8 +288,8 @@ over-engineering for two label domains); formula-evaluated instance labels (reje after review: overhead formulas are upper bounds over declared size fields and can be arbitrarily loose on structure-dependent constructions, so a formula-ranked front may not contain the true winner — measured sizes are the ground truth and affordable at -interactive scales, with formulas retained as pre-flight guards and ordering -heuristics). +interactive scales; formulas remain available for asymptotic analysis but do not +decide concrete feasibility). ### M4 — CLI/MCP surface (`problemreductions-cli/src/commands/graph.rs`, in-place) diff --git a/src/rules/graph.rs b/src/rules/graph.rs index c4d7db9f7..32e34cfdd 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -519,10 +519,8 @@ impl ReductionGraph { exhaustive: bool, ) -> Vec<(ReductionPath, L)> { // `label` is `Option` so an evicted entry (dominated or cap-truncated) can free its - // label immediately via `take()` — otherwise dominated labels would linger in the - // arena for the whole search, pinning e.g. a `MeasuredLabel`'s `Rc` reduction chain - // and defeating the bag cap as a memory bound. Invariant: any arena index that is a - // current member of some bag has `label == Some`; only non-members may be `None`. + // label immediately via `take()`. Invariant: any arena index that is a current + // member of some bag has `label == Some`; only non-members may be `None`. struct Entry { node: NodeIndex, label: Option, @@ -533,6 +531,7 @@ impl ReductionGraph { let mut arena: Vec> = Vec::new(); let mut bags: HashMap> = HashMap::new(); let mut frontier: BinaryHeap, usize)>> = BinaryHeap::new(); + let mut adjacency: HashMap> = HashMap::new(); arena.push(Entry { node: src, @@ -578,18 +577,24 @@ impl ReductionGraph { continue; } - // Deterministic edge order. - let mut edges: Vec<(NodeIndex, EdgeIndex)> = self - .graph - .edges(node) - .filter(|e| Self::edge_supports_mode(e.weight(), mode)) - .map(|e| (e.target(), e.id())) - .collect(); - edges.sort_by(|a, b| { - let na = &self.nodes[self.graph[a.0]]; - let nb = &self.nodes[self.graph[b.0]]; - (na.name, &na.variant).cmp(&(nb.name, &nb.variant)) - }); + // Deterministic edge order, cached because many labels can visit one node. + let edges = adjacency + .entry(node) + .or_insert_with(|| { + let mut edges: Vec<(NodeIndex, EdgeIndex)> = self + .graph + .edges(node) + .filter(|e| Self::edge_supports_mode(e.weight(), mode)) + .map(|e| (e.target(), e.id())) + .collect(); + edges.sort_by(|a, b| { + let na = &self.nodes[self.graph[a.0]]; + let nb = &self.nodes[self.graph[b.0]]; + (na.name, &na.variant).cmp(&(nb.name, &nb.variant)) + }); + edges + }) + .clone(); let hops = arena[idx].hops; for (target, edge_idx) in edges { @@ -791,6 +796,90 @@ impl ReductionGraph { ReductionPath { steps } } + /// Enumerate every witness-capable simple path from `src` to `dst`, executing each + /// reduction as it is reached and retaining the measured-smallest completed target. + /// + /// This is deliberately separate from [`pareto_search`](Self::pareto_search): no + /// dominance relation, hop cap, bag cap, or scalar branch-and-bound is valid for a + /// structure-dependent concrete instance. Repeated nodes are excluded because this + /// API searches graph paths (not unbounded walks); that is the sole structural + /// termination condition. + fn measured_best_simple_path<'a>( + &self, + src: NodeIndex, + dst: NodeIndex, + mode: ReductionMode, + initial: MeasuredLabel<'a>, + ) -> Option<(ReductionPath, MeasuredLabel<'a>)> { + let mut stack = vec![(src, vec![src], initial)]; + let mut adjacency: HashMap> = HashMap::new(); + let mut best: Option<(Vec, MeasuredLabel<'a>)> = None; + + while let Some((node, node_path, label)) = stack.pop() { + if node == dst { + let candidate_key = ( + label.measured_size().total(), + node_path.len(), + self.path_order_key(&node_path), + ); + let is_better = best.as_ref().is_none_or(|(best_path, best_label)| { + let best_key = ( + best_label.measured_size().total(), + best_path.len(), + self.path_order_key(best_path), + ); + candidate_key < best_key + }); + if is_better { + best = Some((node_path, label)); + } + continue; + } + + let edges = adjacency + .entry(node) + .or_insert_with(|| { + let mut edges: Vec<(NodeIndex, EdgeIndex)> = self + .graph + .edges(node) + .filter(|e| Self::edge_supports_mode(e.weight(), mode)) + .map(|e| (e.target(), e.id())) + .collect(); + edges.sort_by(|a, b| { + let na = &self.nodes[self.graph[a.0]]; + let nb = &self.nodes[self.graph[b.0]]; + (na.name, &na.variant).cmp(&(nb.name, &nb.variant)) + }); + edges + }) + .clone(); + + // Reverse push order so DFS visits the deterministic ascending edge order. + for (target, edge_idx) in edges.into_iter().rev() { + if node_path.contains(&target) { + continue; + } + let weight = &self.graph[edge_idx]; + let target_node = &self.nodes[self.graph[target]]; + let edge = ReductionEdge { + overhead: &weight.overhead, + reduce_fn: weight.reduce_fn, + capabilities: weight.capabilities, + target_name: target_node.name, + target_variant: &target_node.variant, + }; + let Some(next_label) = label.extend(&edge) else { + continue; + }; + let mut next_path = node_path.clone(); + next_path.push(target); + stack.push((target, next_path, next_label)); + } + } + + best.map(|(path, label)| (self.node_path_to_reduction_path(&path), label)) + } + /// Find all simple paths between two specific problem variants. /// /// Uses `all_simple_paths` on the variant-level graph from the exact @@ -1861,15 +1950,17 @@ impl ReductionGraph { /// paths by overhead *formulas* (scaling upper bounds that can be arbitrarily loose /// on structure-dependent constructions), this runs the [`MeasuredLabel`] domain: /// it *actually executes* each reduction on `source_instance` and measures the real - /// constructed target size. Formulas are used only as a pre-flight guard that skips - /// predicted-over-budget constructions before they run — never to arbitrate between - /// concrete candidates. See design doc M3/F3b. + /// constructed target size. Asymptotic overhead formulas are not treated as concrete + /// bounds and do not prune candidates. See design doc M3/F3b. /// /// `budget` is the hard total-size limit (sum of `ProblemSize` components); use /// [`DEFAULT_SIZE_BUDGET`](crate::rules::DEFAULT_SIZE_BUDGET) for the default. - /// `exhaustive` disables only the heuristic componentwise-dominance guard (the sound - /// pre-flight and measured-budget guards still apply; the kernel prunes by dominance - /// only, never branch-and-bound — measured cost can shrink across a reduction). + /// The search exhaustively enumerates witness-capable simple paths. It does not use + /// dominance pruning, branch-and-bound, or the generic Pareto kernel's bag/hop caps: + /// neither size vectors nor serialized state equality discard a route. The + /// post-construction measured-budget guard still applies. + /// Because the target must be built before it can be measured, the budget is not an + /// anti-OOM guarantee. /// /// Returns `None` if no in-budget witness-capable path exists (or `source == target`). #[allow(clippy::too_many_arguments)] @@ -1882,7 +1973,6 @@ impl ReductionGraph { mode: ReductionMode, source_instance: &dyn Any, budget: usize, - exhaustive: bool, ) -> Option { let src = self.lookup_node(source, source_variant)?; let dst = self.lookup_node(target, target_variant)?; @@ -1891,8 +1981,7 @@ impl ReductionGraph { } let source_size = Self::compute_source_size(source, source_instance); let initial = MeasuredLabel::new(source_instance, source_size, budget); - let mut front = self.pareto_search(src, dst, mode, initial, exhaustive); - let (path, label) = self.pick_best_front(&mut front)?; + let (path, label) = self.measured_best_simple_path(src, dst, mode, initial)?; let steps: Vec> = label.chain().to_vec(); if steps.is_empty() { return None; @@ -1978,7 +2067,6 @@ impl ReductionGraph { /// Runs [`find_measured_best_path`](Self::find_measured_best_path) once per target /// variant and returns the overall measured-smallest result, with a deterministic /// tie-break by (measured total size, hops, node-name path). - #[allow(clippy::too_many_arguments)] pub fn find_measured_best_path_to_name( &self, source: &str, @@ -1987,7 +2075,6 @@ impl ReductionGraph { mode: ReductionMode, source_instance: &dyn Any, budget: usize, - exhaustive: bool, ) -> Option { let mut best: Option = None; for tv in self.variants_for(target) { @@ -1999,7 +2086,6 @@ impl ReductionGraph { mode, source_instance, budget, - exhaustive, ) else { continue; }; diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 613ed319a..d85a3aff8 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -13,13 +13,13 @@ //! an antichain of non-dominated labels (a "bag"); a label is only pruned when another //! label at the same node dominates it. See [`ReductionGraph::pareto_search`]. //! -//! Two label domains are provided: +//! Two search domains are provided: //! - [`CostLabel`]: a scalar formula label that reproduces Dijkstra's behavior for the //! existing `PathCostFn` cost functions (used by `find_cheapest_path*`). It carries the //! accumulated `ProblemSize` (from overhead formulas) and an additive scalar cost. -//! - [`MeasuredLabel`]: the concrete-instance label. For a concrete source instance, it -//! *actually executes* each reduction and measures the real constructed target size. -//! Formulas are only used as a pre-flight guard, never to arbitrate between candidates. +//! - [`MeasuredLabel`]: concrete-instance state used by a separate exhaustive simple-path +//! search. It *actually executes* each reduction and measures the real constructed target +//! size. Asymptotic overhead formulas are not used as concrete budget bounds. use crate::expr::Expr; use crate::growth::Growth; @@ -67,9 +67,12 @@ pub(crate) fn catch_reduction(f: impl FnOnce() -> R) -> Option { result.ok() } -/// Default hard total-size budget for the measured search (in "size units", i.e. the -/// sum of all `ProblemSize` components). Generous by design: the point is to refuse -/// astronomic constructions (e.g. a `2^num_vertices` blow-up), not to micro-manage. +/// Default post-construction total-size budget for the measured search (in "size units", +/// i.e. the sum of all `ProblemSize` components). +/// +/// A reduction's target must exist before it can be measured, so this limits which +/// constructed instances remain eligible for further search; it cannot prevent the +/// construction itself from exhausting memory. pub const DEFAULT_SIZE_BUDGET: usize = 10_000_000; /// Maximum number of reduction steps (hops) explored along any path. @@ -81,10 +84,10 @@ pub const BAG_CAP: usize = 32; /// A borrowed view of one reduction edge, handed to [`PathLabel::extend`]. /// -/// It exposes exactly what a label needs to advance: the overhead formula (for the -/// symbolic pre-flight guard and formula-based sizing), the executable reduction -/// function (for measured execution), the edge capabilities, and the target node's -/// identity (for measuring the constructed target's size by name). +/// It exposes exactly what a label needs to advance: the overhead formula (for symbolic +/// and formula-based labels), the executable reduction function (for measured execution), +/// the edge capabilities, and the target node's identity (for measuring the constructed +/// target's size by name). pub struct ReductionEdge<'g> { /// Overhead expressions mapping source size fields to target size fields. pub overhead: &'g ReductionOverhead, @@ -108,20 +111,18 @@ pub struct ReductionEdge<'g> { /// /// The kernel prunes by [`dominates`](PathLabel::dominates) alone — it does **not** /// branch-and-bound on [`cost`](PathLabel::cost). Dominance is exact for every label -/// domain, whereas a scalar B&B bound would only be sound for a monotone `cost`: the -/// measured size can *shrink* across a reduction, and the asymptotic `cost` is a -/// heuristic summary of an incomparable growth vector, so neither admits a sound bound. +/// domain, whereas a scalar B&B bound would only be sound for a monotone `cost`; a label's +/// scalar summary may shrink across an edge or summarize an incomparable growth vector. /// `cost` is used only for frontier ordering and the deterministic final tie-break. pub trait PathLabel: Clone { - /// Advance this label across `edge`. Returns `None` when a guard prunes the edge - /// (e.g. the measured label's pre-flight size guard). A `None` must be *isotone*: + /// Advance this label across `edge`. Returns `None` when a label-domain guard rejects + /// the edge. A `None` must be *isotone*: /// if `A` dominates `B` and `A.extend(e)` is `None`, that is fine, but a guard must /// never prune a dominating label while keeping a dominated one. fn extend(&self, edge: &ReductionEdge) -> Option; - /// Partial order: `true` iff `self` is at least as good as `other` in every - /// component (and strictly better in at least one, or equal). Used to keep each - /// node's bag an antichain. + /// Partial order used to keep each node's bag an antichain. Implementations must + /// satisfy the isotonicity invariant above. fn dominates(&self, other: &Self) -> bool; /// Scalar summary used only for frontier ordering and the deterministic final @@ -204,31 +205,22 @@ enum MeasuredPos<'a> { /// The concrete-instance measured label (design doc M3/F3b). /// -/// For a concrete source instance, formulas are advisory — the **measured** target size -/// is authoritative. `extend` runs this pruning stack, in order: +/// For a concrete source instance, the **measured** target size is authoritative. +/// Asymptotic overhead formulas are deliberately not consulted: evaluating a Big-O +/// expression at one input does not produce a certified concrete upper bound. +/// `extend` runs this stack, in order: /// -/// 1. **Symbolic pre-flight guard:** evaluate the edge's overhead formula at the current -/// *measured* size. If the (upper-bound, uncalibrated) prediction already exceeds the -/// budget, return `None` **without executing** — so a catastrophic construction (e.g. -/// a `2^num_vertices` blow-up) is never even started. -/// 2. **Execute + measure:** run `reduce_to()`, measure the real target size; over budget +/// 1. **Execute + measure:** run `reduce_to()`, measure the real target size; over budget /// → `None`. -/// 3. **Componentwise measured-size dominance:** [`dominates`](PathLabel::dominates), a -/// heuristic under a documented size-monotone-future assumption. The kernel's -/// `exhaustive` flag disables *only* this guard, keeping 1–2 (which are sound). -/// -/// The kernel prunes by dominance only, never branch-and-bound — which matters here -/// because measured size can *shrink* across a reduction, so [`cost`](PathLabel::cost) -/// is non-monotone and any scalar B&B bound could wrongly prune a partial route that -/// would still finish smallest. +/// 2. **No comparative pruning:** measured states are enumerated by a separate exhaustive +/// simple-path search. Neither size vectors nor serialized representations discard a +/// constructed route before its downstream reductions are measured, and Pareto bag/hop +/// caps do not apply. /// -/// **Memory.** There is no absolute anti-OOM guarantee (the overhead formulas are -/// uncalibrated upper bounds), but two mechanisms bound retained instance memory: the -/// pre-flight guard skips predicted-over-budget constructions before they run, and the -/// kernel frees a label's `Rc` reduction chain the instant the label is evicted from its -/// bag (dominated or cap-truncated). Together they bound the reduction instances retained -/// at any moment by the live bag entries (≤ [`BAG_CAP`] per node) times their chain -/// length — the bag cap genuinely bounds retained instance memory. +/// **Memory.** The budget is checked only after a reduction has constructed its target, +/// so it cannot prevent a reduction itself from exhausting memory. It limits which +/// constructed instances remain eligible for further search. Exhaustive simple-path +/// enumeration can take exponential time and retain large constructed chains. #[derive(Clone)] pub struct MeasuredLabel<'a> { /// Measured size of the problem instance at the current node. @@ -265,34 +257,11 @@ impl<'a> MeasuredLabel<'a> { pub(crate) fn measured_size(&self) -> &ProblemSize { &self.size } -} - -/// Componentwise "less-or-equal in every field" test between two measured sizes. -/// -/// `a` covers `b` iff every field of `b` is present in `a` with a value `>=` b's — i.e. -/// `a` is componentwise `<=` `b`. Missing fields are treated as `0`. -fn size_le(a: &ProblemSize, b: &ProblemSize) -> bool { - // a <= b componentwise. Sizes are nonnegative and missing fields default to 0, - // so only a's own fields can violate the bound: a b-only field gives `0 <= b`, - // which always holds. Checking a's fields against b is therefore sufficient. - a.components - .iter() - .all(|(name, av)| *av <= b.get(name).unwrap_or(0)) -} - -impl PathLabel for MeasuredLabel<'_> { - fn extend(&self, edge: &ReductionEdge) -> Option { - // Guard 1: symbolic pre-flight. Predict the target size from the overhead - // formula evaluated at the *measured* current size. Because formulas are upper - // bounds, a prediction over budget means we must not even start the construction. - // Computed in `f64` so an astronomic prediction (e.g. `2^num_vertices`) is flagged - // rather than overflowing `usize`. - let predicted_total = edge.overhead.evaluate_output_total_f64(&self.size); - if predicted_total > self.budget as f64 { - return None; - } - // Guard 2: execute the reduction and measure the real target size. Executing a + /// Execute one reduction and retain the state only when its measured target is + /// within the post-construction budget. + pub(crate) fn extend(&self, edge: &ReductionEdge) -> Option { + // Execute the reduction and measure the real target size. Executing a // reduction whose preconditions the current instance violates panics; such an // edge is not a viable path, so a caught panic prunes it (returns `None`). The // measurement (`compute_source_size`) probes every same-name size function, so @@ -325,19 +294,14 @@ impl PathLabel for MeasuredLabel<'_> { budget: self.budget, }) } +} - fn dominates(&self, other: &Self) -> bool { - // Componentwise measured-size dominance. Labels compared here are always at the - // same node (same problem variant), so their size fields coincide. - size_le(&self.size, &other.size) - } - - fn cost(&self) -> f64 { - // Frontier-ordering heuristic only. Measured size can SHRINK across a reduction, - // so this is non-monotone along `extend` — which is exactly why the kernel prunes - // by dominance, not branch-and-bound. - self.size.total() as f64 - } +/// Componentwise "less-or-equal in every field" test between two sizes. +/// Missing fields are treated as `0`. +fn size_le(a: &ProblemSize, b: &ProblemSize) -> bool { + a.components + .iter() + .all(|(name, av)| *av <= b.get(name).unwrap_or(0)) } /// Asymptotic, **instance-free** label domain (design doc M3/F3a). diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 0fea24d44..8048022da 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -41,19 +41,6 @@ impl ReductionOverhead { ProblemSize::new(fields) } - /// Predicted total output size as an `f64`, summing every output field's formula. - /// - /// Unlike [`evaluate_output_size`](Self::evaluate_output_size), this never rounds to - /// `usize`, so an astronomic prediction (e.g. `2^num_vertices` on a large instance) - /// stays a large finite `f64` instead of overflowing. Used by the measured Pareto - /// search's pre-flight guard to refuse catastrophic constructions before executing. - pub fn evaluate_output_total_f64(&self, input: &ProblemSize) -> f64 { - self.output_size - .iter() - .map(|(_, expr)| expr.eval(input).max(0.0)) - .sum() - } - /// Collect all input variable names referenced by the overhead expressions. pub fn input_variable_names(&self) -> HashSet<&'static str> { self.output_size diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index c77b3e017..08ebbacb6 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -240,36 +240,48 @@ impl ILPSolver { any.is::>() || any.is::>() || any.is::() } - /// Select the witness reduction path to ILP whose **measured** final ILP size is - /// smallest. + /// Execute the first constructible preferred witness path to an ILP variant. /// - /// Delegates to the measured Pareto search - /// ([`ReductionGraph::find_measured_best_path_to_name`]): it actually executes each - /// reduction on `instance` and measures the real constructed ILP size, choosing the - /// smallest across all ILP variants. Overhead formulas are used only as a pre-flight - /// guard against catastrophic constructions — never to arbitrate between concrete - /// candidates. This fixes issue #788 (formula/step ranking could miss the path with - /// the smallest real ILP) and makes OOM structurally impossible during selection. - /// - /// The returned [`MeasuredPath`](crate::rules::MeasuredPath) carries the already - /// constructed reduction chain, so the caller solves and extracts without - /// re-executing the reductions. - fn best_path_to_ilp( + /// Solving only requires a valid formulation; it does not require proving which of + /// every possible multi-hop formulation is concretely smallest. One shortest path is + /// considered per ILP variant, ordered deterministically by hops and node names. + fn preferred_chain_to_ilp( &self, graph: &crate::rules::ReductionGraph, name: &str, variant: &std::collections::BTreeMap, instance: &dyn std::any::Any, - ) -> Option { - graph.find_measured_best_path_to_name( - name, - variant, - "ILP", - ReductionMode::Witness, - instance, - crate::rules::DEFAULT_SIZE_BUDGET, - false, - ) + ) -> Option { + let input_size = crate::rules::ReductionGraph::compute_source_size(name, instance); + let mut candidates: Vec<_> = graph + .variants_for("ILP") + .into_iter() + .filter_map(|target_variant| { + graph.find_cheapest_path_mode( + name, + variant, + "ILP", + &target_variant, + ReductionMode::Witness, + &input_size, + &crate::rules::MinimizeSteps, + ) + }) + .collect(); + candidates.sort_by(|a, b| { + a.len() + .cmp(&b.len()) + .then_with(|| a.type_names().cmp(&b.type_names())) + }); + for path in candidates { + if let Some(chain) = + crate::rules::pareto::catch_reduction(|| graph.reduce_along_path(&path, instance)) + .flatten() + { + return Some(chain); + } + } + None } pub fn try_solve_via_reduction( @@ -288,24 +300,43 @@ impl ILPSolver { let graph = crate::rules::ReductionGraph::new(); - let Some(measured) = self.best_path_to_ilp(&graph, name, variant, instance) else { - if self.has_aggregate_path_to_ilp(&graph, name, variant) { - return Err(SolveViaReductionError::WitnessPathRequired { + if let Some(chain) = self.preferred_chain_to_ilp(&graph, name, variant, instance) { + let ilp_solution = self.solve_dyn(chain.target_problem_any()).ok_or_else(|| { + SolveViaReductionError::NoSolution { name: name.to_string(), - }); - } + } + })?; + return Ok(chain.extract_solution(&ilp_solution)); + } - return Err(SolveViaReductionError::NoReductionPath { + // A preferred shortest path can be instance-infeasible even when another route + // works. Fall back to the uncapped, execution-aware measured enumeration before + // reporting that no witness path exists. + if let Some(measured) = graph.find_measured_best_path_to_name( + name, + variant, + "ILP", + ReductionMode::Witness, + instance, + crate::rules::DEFAULT_SIZE_BUDGET, + ) { + let ilp_solution = self + .solve_dyn(measured.target_problem_any()) + .ok_or_else(|| SolveViaReductionError::NoSolution { + name: name.to_string(), + })?; + return Ok(measured.extract_solution(&ilp_solution)); + } + + if self.has_aggregate_path_to_ilp(&graph, name, variant) { + return Err(SolveViaReductionError::WitnessPathRequired { name: name.to_string(), }); - }; + } - let ilp_solution = self - .solve_dyn(measured.target_problem_any()) - .ok_or_else(|| SolveViaReductionError::NoSolution { - name: name.to_string(), - })?; - Ok(measured.extract_solution(&ilp_solution)) + Err(SolveViaReductionError::NoReductionPath { + name: name.to_string(), + }) } /// Whether an aggregate-capable (but possibly not witness-capable) reduction path to @@ -335,9 +366,10 @@ impl ILPSolver { /// Solve a type-erased problem by finding a reduction path to ILP. /// - /// Tries all ILP variants, picks the cheapest path, reduces, solves, - /// and extracts the solution back. Falls back to direct ILP solve if - /// the problem is already an ILP type. + /// Prefers a shortest witness path to an ILP variant, reduces, solves, and extracts + /// the solution back. If the preferred constructions are instance-infeasible, it + /// falls back to exhaustive measured simple-path search. Problems already represented + /// as ILP are solved directly. /// /// Returns `None` if no path to ILP exists or the solver finds no solution. pub fn solve_via_reduction( diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 75d45f422..1fc843570 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -1,6 +1,6 @@ //! Tests for the Pareto label-setting search (`src/rules/pareto.rs`) and its two label //! domains. Covers: -//! - The measured concrete-instance label (issue #788 known-answer, OOM pre-flight guard). +//! - The measured concrete-instance search (issue #788 known-answer and budget semantics). //! - The generic kernel's correctness on a hand-built diamond (negative control): a //! scalar-cost path selection commits to the wrong prefix, while the Pareto search //! returns the path with the strictly-better final measured size. @@ -8,18 +8,119 @@ use super::*; use crate::expr::Expr; use crate::growth::Growth; -use crate::models::graph::{HamiltonianCircuit, HighlyConnectedDeletion}; +use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::models::formula::{CNFClause, Satisfiability}; +use crate::models::graph::HamiltonianCircuit; use crate::rules::cost::CustomCost; use crate::rules::pareto::{GrowthLabel, PathLabel, ReductionEdge}; use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; -use crate::rules::{ReductionGraph, ReductionMode, DEFAULT_SIZE_BUDGET}; +use crate::rules::traits::DynReductionResult; +use crate::rules::{ReductionAutoCast, ReductionGraph, ReductionMode}; use crate::topology::SimpleGraph; -use crate::types::ProblemSize; +use crate::traits::Problem; +use crate::types::{Or, ProblemSize}; use std::any::Any; use std::cell::Cell; use std::collections::BTreeMap; use std::rc::Rc; -use std::time::Instant; + +#[derive(Clone)] +struct MeasuredSource; + +#[derive(Clone)] +struct MeasuredBranchA; + +#[derive(Clone)] +struct MeasuredBranchB; + +macro_rules! impl_measured_test_problem { + ($ty:ty, $name:literal) => { + impl Problem for $ty { + const NAME: &'static str = $name; + type Value = Or; + + fn dims(&self) -> Vec { + vec![] + } + + fn evaluate(&self, _config: &[usize]) -> Or { + Or(true) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } + } + }; +} + +impl_measured_test_problem!(MeasuredSource, "MeasuredSource"); +impl_measured_test_problem!(MeasuredBranchA, "MeasuredBranchA"); +impl_measured_test_problem!(MeasuredBranchB, "MeasuredBranchB"); + +fn measured_source_to_a(any: &dyn Any) -> Box { + any.downcast_ref::() + .expect("expected MeasuredSource"); + Box::new(ReductionAutoCast::::new( + MeasuredBranchA, + )) +} + +fn measured_source_to_b(any: &dyn Any) -> Box { + any.downcast_ref::() + .expect("expected MeasuredSource"); + Box::new(ReductionAutoCast::::new( + MeasuredBranchB, + )) +} + +fn measured_a_to_sat(any: &dyn Any) -> Box { + any.downcast_ref::() + .expect("expected MeasuredBranchA"); + Box::new(ReductionAutoCast::::new( + Satisfiability::new(1, vec![CNFClause::new(vec![1])]), + )) +} + +fn measured_b_to_sat(any: &dyn Any) -> Box { + any.downcast_ref::() + .expect("expected MeasuredBranchB"); + Box::new(ReductionAutoCast::::new( + Satisfiability::new(1, vec![CNFClause::new(vec![-1])]), + )) +} + +fn measured_sat_to_structure_dependent_ilp(any: &dyn Any) -> Box { + let sat = any + .downcast_ref::() + .expect("expected Satisfiability"); + let first_literal = sat.clauses()[0].literals[0]; + let num_vars = if first_literal > 0 { 100 } else { 1 }; + let target = ILP::::new(num_vars, vec![], vec![], ObjectiveSense::Minimize); + Box::new(ReductionAutoCast::>::new(target)) +} + +fn measured_source_to_small_ilp(any: &dyn Any) -> Box { + any.downcast_ref::() + .expect("expected MeasuredSource"); + let target = ILP::::new(1, vec![], vec![], ObjectiveSense::Minimize); + Box::new(ReductionAutoCast::>::new(target)) +} + +fn measured_edge( + reduce_fn: fn(&dyn Any) -> Box, + asymptotic_prediction: f64, +) -> ReductionEdgeData { + ReductionEdgeData { + overhead: ReductionOverhead::new(vec![( + "predicted_total", + Expr::Const(asymptotic_prediction), + )]), + reduce_fn: Some(reduce_fn), + reduce_aggregate_fn: None, + capabilities: EdgeCapabilities::witness_only(), + } +} // --------------------------------------------------------------------------- // Verification 1: issue #788 known-answer check. @@ -66,8 +167,7 @@ fn test_hamiltoniancircuit_to_ilp_measured_optimum_788() { "ILP", ReductionMode::Witness, &hc as &dyn Any, - DEFAULT_SIZE_BUDGET, - false, + 1_000, ) .expect("a measured witness path from HamiltonianCircuit to ILP"); @@ -94,54 +194,103 @@ fn test_hamiltoniancircuit_to_ilp_measured_optimum_788() { } // --------------------------------------------------------------------------- -// Verification 2: OOM pre-flight guard is real. +// Verification 2: measured search does not discard equal-size concrete states. // --------------------------------------------------------------------------- -/// Routing a 64-vertex instance through the `2^num_vertices` overhead edge -/// (`highlyconnecteddeletion_ilp`) must be refused by the symbolic pre-flight guard -/// *before* the exponential construction is ever started: the search completes near -/// instantly and returns no in-budget path (the sole HCD → ILP edge is pruned). -/// -/// The instance is a dense 64-vertex graph on purpose — if the guard were removed, the -/// reduction would enumerate ~2^64 feasible clusters and exhaust memory. Because guard 1 -/// evaluates the formula (`2^64 ≫ budget`) and skips without executing, the test is safe. #[test] -fn test_oom_preflight_guard_highlyconnecteddeletion() { - // Dense 64-vertex graph (complete graph K_64): cheap to build, catastrophic to reduce. - let n = 64; - let mut edges = Vec::new(); - for u in 0..n { - for v in (u + 1)..n { - edges.push((u, v)); - } - } - let hcd = HighlyConnectedDeletion::new(SimpleGraph::new(n, edges)); - let graph = ReductionGraph::new(); - let variant = ReductionGraph::variant_to_map(&[("graph", "SimpleGraph")]); +fn test_measured_search_keeps_equal_size_structure_dependent_instances() { + let graph = ReductionGraph::from_test_edges( + &[ + "MeasuredSource", + "MeasuredBranchA", + "MeasuredBranchB", + "Satisfiability", + "ILP", + ], + &[ + ( + "MeasuredSource", + "MeasuredBranchA", + measured_edge(measured_source_to_a, 0.0), + ), + ( + "MeasuredSource", + "MeasuredBranchB", + measured_edge(measured_source_to_b, 0.0), + ), + ( + "MeasuredBranchA", + "Satisfiability", + measured_edge(measured_a_to_sat, 0.0), + ), + ( + "MeasuredBranchB", + "Satisfiability", + measured_edge(measured_b_to_sat, 0.0), + ), + ( + "Satisfiability", + "ILP", + measured_edge(measured_sat_to_structure_dependent_ilp, 0.0), + ), + ], + ); + let empty = BTreeMap::new(); + let source = MeasuredSource; - let start = Instant::now(); - let result = graph.find_measured_best_path_to_name( - "HighlyConnectedDeletion", - &variant, - "ILP", - ReductionMode::Witness, - &hcd as &dyn Any, - DEFAULT_SIZE_BUDGET, - false, + let bad_sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); + let good_sat = Satisfiability::new(1, vec![CNFClause::new(vec![-1])]); + assert_eq!( + ReductionGraph::compute_source_size("Satisfiability", &bad_sat), + ReductionGraph::compute_source_size("Satisfiability", &good_sat), + "the two structurally different hub instances must have identical measured sizes", ); - let elapsed = start.elapsed(); - // The only HCD -> ILP path is the 2^num_vertices edge; it is pre-flight-pruned. - assert!( - result.is_none(), - "the 2^num_vertices construction must be refused, not selected" + let measured = graph + .find_measured_best_path( + "MeasuredSource", + &empty, + "ILP", + &empty, + ReductionMode::Witness, + &source, + 1_000, + ) + .expect("the structure-dependent small continuation must survive"); + + assert_eq!( + measured.path.type_names(), + ["MeasuredSource", "MeasuredBranchB", "Satisfiability", "ILP",], ); - // Structural proof the exponential enumeration was never started: it finishes fast. - assert!( - elapsed.as_secs_f64() < 1.0, - "search must complete in < 1s (never executes the exponential edge); took {:?}", - elapsed + assert_eq!(measured.size.total(), 1); +} + +#[test] +fn test_asymptotic_overhead_is_not_a_concrete_budget_guard() { + let graph = ReductionGraph::from_test_edges( + &["MeasuredSource", "ILP"], + &[( + "MeasuredSource", + "ILP", + measured_edge(measured_source_to_small_ilp, 1_000_000.0), + )], ); + let empty = BTreeMap::new(); + let source = MeasuredSource; + + let measured = graph + .find_measured_best_path( + "MeasuredSource", + &empty, + "ILP", + &empty, + ReductionMode::Witness, + &source, + 1, + ) + .expect("a loose asymptotic expression must not prune an actually in-budget target"); + + assert_eq!(measured.size.total(), 1); } // --------------------------------------------------------------------------- @@ -793,8 +942,8 @@ fn test_asymptotic_front_uses_only_source_variables_mfvs_ilp() { // --------------------------------------------------------------------------- /// A test label whose `cost` is the label's current absolute value — a value a late edge -/// can *shrink* below an already-completed route's final value. It models exactly the -/// non-monotone-cost case (`MeasuredLabel`) the dominance-only kernel must handle. +/// can *shrink* below an already-completed route's final value. It verifies that the +/// generic kernel does not silently add scalar branch-and-bound. #[derive(Clone)] struct ShrinkLabel { v: f64, @@ -1006,10 +1155,9 @@ thread_local! { } /// A drop-tracking token. Each `new()` is a distinct live instance; `Drop` frees it. Held -/// behind `Rc` inside a label, so cloning a label (Rc clone) SHARES the token — mirroring -/// `MeasuredLabel`'s `Rc` reduction chain, where each hop is one instance shared across -/// label clones. If the arena pinned evicted labels, their tokens would stay live until -/// the search ended, so `TOK_PEAK` would reach `TOK_CREATED`. +/// behind `Rc` inside a label, so cloning a label shares the token. If the arena pinned +/// evicted labels, their tokens would stay live until the search ended, so `TOK_PEAK` +/// would reach `TOK_CREATED`. struct DropToken; impl DropToken { From 546f579129059792e7ad65852ea0e4df060e7592 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 20 Jul 2026 13:53:49 +0800 Subject: [PATCH 20/31] Make path search exact or explicitly approximate --- docs/design/exact-approximate-path-search.md | 493 ++++++++++++ docs/design/symbolic-growth-domain.md | 61 +- ...hained_reduction_factoring_to_spinglass.rs | 2 + problemreductions-cli/src/cli.rs | 53 ++ problemreductions-cli/src/commands/graph.rs | 50 +- problemreductions-cli/src/commands/reduce.rs | 32 +- problemreductions-cli/src/dispatch.rs | 2 + problemreductions-cli/src/main.rs | 21 +- problemreductions-cli/src/mcp/tests.rs | 126 ++- problemreductions-cli/src/mcp/tools.rs | 118 ++- problemreductions-cli/src/util.rs | 67 ++ problemreductions-cli/tests/cli_tests.rs | 84 +- src/rules/cost.rs | 8 +- src/rules/graph.rs | 603 ++++++++------ src/rules/mod.rs | 8 +- src/rules/pareto.rs | 158 ++-- src/rules/search.rs | 222 ++++++ src/solvers/ilp/solver.rs | 47 +- src/unit_tests/example_db.rs | 30 +- src/unit_tests/reduction_graph.rs | 107 ++- src/unit_tests/rules/graph.rs | 315 +++++--- .../rules/maximumindependentset_ilp.rs | 2 + .../rules/maximumindependentset_qubo.rs | 2 + .../rules/minimumvertexcover_ilp.rs | 2 + .../rules/minimumvertexcover_qubo.rs | 2 + src/unit_tests/rules/pareto.rs | 754 ++++++++++++++---- src/unit_tests/rules/reduction_path_parity.rs | 8 + .../rules/threedimensionalmatching_ilp.rs | 2 + ...sionalmatching_threematroidintersection.rs | 2 + tests/suites/reductions.rs | 4 + .../suites/register_assignment_reductions.rs | 4 + 31 files changed, 2651 insertions(+), 738 deletions(-) create mode 100644 docs/design/exact-approximate-path-search.md create mode 100644 src/rules/search.rs diff --git a/docs/design/exact-approximate-path-search.md b/docs/design/exact-approximate-path-search.md new file mode 100644 index 000000000..c9489d761 --- /dev/null +++ b/docs/design/exact-approximate-path-search.md @@ -0,0 +1,493 @@ +# Exact and Approximate Path Search — Product Design + +Status: implemented. + +Amendment (2026-07-18): intermediate strict dominance pruning is removed. Reduction +overheads may be non-monotone (for example graph-complement size formulas subtract the +current edge count), so the package cannot establish the isotonicity required by a +label-setting dominance proof. The current labels do not carry complete constructed +problems, so equal size, cost, or growth summaries do not coalesce intermediate states. +Pareto dominance is applied only to completed destination labels. + +This design refines the path-search portion of +[`symbolic-growth-domain.md`](symbolic-growth-domain.md). It supersedes that document's +implicit global hop and per-node bag caps; it does not change the symbolic `Growth` +domain or measured-size semantics introduced there. + +## Need + +The reduction graph currently exposes APIs whose names imply a complete optimum or +Pareto front, while the shared Pareto kernel always stops extending after 16 hops and +retains at most 32 labels per node. Those deterministic caps keep interactive searches +small, but they can discard the only feasible path, a true scalar winner, or a distinct +Pareto point. Callers receive no indication that this happened. + +The library needs one explicit completeness contract across formula-ranked, +asymptotic, and measured path search: + +- **Exact** returns a complete result for the declared finite search space or an error; + it never silently drops a candidate because of a resource cap. +- **Approximate** may stop or truncate according to caller-provided limits, always + returns valid best-so-far candidates, and reports every limit that affected + completeness. + +Symbolic versus measured remains a separate semantic choice. `SearchMode` answers +"how complete is the search?", not "what does a label mean?". + +**Users:** library callers, the ILP reduction solver, CLI users of `pred path` and +`pred reduce`, and MCP clients. + +**Success criteria:** + +1. Every public optimum/front API requires an explicit `SearchMode`. +2. Exact mode finds paths longer than the former hop cap and winners that require more + than the former per-node bag cap. +3. Exact mode terminates on cyclic reduction graphs by searching elementary (simple) + paths, without intermediate strict dominance pruning. +4. Approximate mode reports whether a hop, per-node label, expanded-state, or time limit + changed the explored search space. If no limit is hit, its outcome is reported as + exact. +5. Equal coarse labels remain distinct at intermediate nodes; only completed labels are + Pareto-filtered. +6. CLI text and JSON and MCP responses expose completeness; no approximate answer is + presented as an unqualified optimum or Pareto front. +7. Search remains deterministic for all count-based limits. Timeout-limited searches + are explicitly exempt because elapsed time is machine-dependent. + +**Constraints:** + +- Rust 2021 and the repository's existing dependencies only. +- No single test may exceed five seconds. +- Internal and public Rust APIs may break under the crate's 0.x version policy. +- Existing reduction declarations and overhead syntax remain unchanged. +- Exactness is relative to the selected label semantics, feasibility policy, and + elementary-path search space. + +## Prior art and landscape + +The design follows established multiobjective and resource-constrained shortest-path +practice: + +| Source | Adopted lesson | +|---|---| +| Martins-style label setting and the Multiobjective Dijkstra Algorithm ([Maristany de las Casas et al., 2021](https://doi.org/10.1016/j.cor.2021.105424)) | An exact result is a complete set of efficient labels; performance pruning must preserve completeness or be identified separately. | +| Boost Graph Library `r_c_shortest_paths` ([documentation](https://www.boost.org/doc/libs/1_84_0/libs/graph/doc/r_c_shortest_paths.html)) | Dominance pruning is appropriate only when labels contain continuation-relevant resources and extension preserves the order. This package does not assume that property for arbitrary reductions. | +| Papadimitriou and Yannakakis, *On the Approximability of Trade-offs* ([paper](https://www.cs.purdue.edu/homes/yexiang/courses/18fall-cs590/papers/papadimitriou2000.pdf)) | A formal epsilon-Pareto approximation has a coverage guarantee. A fixed bag width without such a guarantee is best-effort bounded search, not epsilon approximation. | +| Elementary resource-constrained shortest-path labeling | When visited vertices affect future feasibility, the visited set is part of the state. Equal resource summaries alone do not identify the same continuation state. | + +No external path-search crate matches the repository's path-dependent symbolic labels, +variant graph, and concrete reduction execution. The project should keep its small +kernel and adopt the contracts above rather than add a dependency. + +## Features + +Selected features and rough agentic-coding-adjusted effort: + +| # | Feature | User value | Effort | +|---|---|---|---| +| F1 | Explicit `Exact` / `Approximate` mode and typed limits | Callers choose the completeness contract instead of inheriting hidden caps | ~0.5–1 day | +| F2 | `SearchOutcome` with completeness reasons and statistics | Every consumer can distinguish complete from best-so-far results | ~0.5–1 day | +| F3 | Elementary exact multi-label kernel with terminal Pareto filtering | Exact mode terminates without arbitrary hop/bag truncation or unproved intermediate pruning | ~1.5–2.5 days | +| F4 | Formula, asymptotic, and measured integration | One contract across all search semantics | ~1–1.5 days | +| F5 | CLI/MCP and ILP policy migration | Interactive users retain bounded latency without misleading output | ~1–1.5 days | +| F6 | Behavioural regressions, documentation, and full migration | Prevents the old hidden-cap behaviour from returning | ~1–1.5 days | + +Total rough effort: **~5.5–9 days**. + +Deferred: + +- **Epsilon-Pareto approximation** — requires a real objective-space discretization + algorithm and proof; add later as another `ApproximationPolicy` variant. +- **Fallible reduction execution (`Result` instead of caught panic)** — desirable Rust + API work, but independent of completeness. +- **Final-only versus every-intermediate measured budget policies** — separate + feasibility design. +- **Certified overhead monotonicity metadata** — separate symbolic trust-contract work. + +Dropped: + +- A third top-level `Bounded` mode. Bounding is the first implementation of + `Approximate`, not a separate user concept. +- Hidden legacy defaults in the Rust library. Compatibility wrappers would preserve the + ambiguity this design removes. + +## Semantic contract + +### Orthogonal axes + +The API distinguishes two independent choices: + +```text +Search semantics Completeness +──────────────────────────────────── ────────────────────── +Formula-evaluated / symbolic / measured Exact / Approximate +``` + +`Exact` does not mean that a formula estimate equals a constructed instance. It means +the path search is complete for the selected semantics. Likewise, `Growth::Unknown` or +sound widening may reduce abstract precision without making route enumeration +incomplete. + +### Exact search space + +Exact mode searches **elementary paths**: no variant-level graph node occurs twice in +one path. This makes the search space finite and matches the existing public +`find_all_paths*` interpretation of a reduction path. + +Every path prefix remains a distinct intermediate state. Reaching the same graph node +with equal `ProblemSize`, accumulated cost, or growth vector does not prove that the +constructed problem is identical: hidden instance structure and the visited-node set can +change future reductions. The current label domains carry no certified full-instance +identity, so the kernel performs no intermediate coalescing. + +A future label domain may deduplicate only by a certified exact problem-state identity +that includes all continuation-relevant state. This is intentionally not approximated by +summary equality. Strict Pareto dominance is evaluated only after labels reach the +destination, where no future reduction can reverse their order. + +### Approximate search + +Approximate mode searches the same elementary-path space but may: + +- stop extending at a configured hop count; +- truncate a per-node bag deterministically; +- stop after a configured number of expanded states; or +- stop after a configured duration. + +Returned paths and labels remain feasible. The result is not claimed to cover the true +front or optimum unless no limit affected exploration. Initial bounded search has no +multiplicative or additive error guarantee. + +A timeout is checked between state expansions. It cannot interrupt an in-progress +reduction constructor and is not deterministic across machines. + +### Measured feasibility + +Measured `budget` remains a feasibility constraint applied after constructing every +intermediate target. It is not an approximation limit and does not change the outcome's +completeness classification. Exact measured search is therefore complete over +elementary paths whose constructed intermediates all satisfy that budget and whose edge +executions succeed. + +## Modules + +### M1 — Search contract (`src/rules/search.rs`, one new module) + +Purpose: own caller intent, outcome metadata, and shared accounting without coupling +them to a label domain. + +Normative API shape: + +```rust +use std::collections::BTreeSet; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub enum SearchMode { + Exact, + Approximate(ApproximationPolicy), +} + +#[derive(Clone, Debug)] +pub enum ApproximationPolicy { + Bounded(SearchLimits), +} + +#[derive(Clone, Debug, Default)] +pub struct SearchLimits { + pub max_hops: Option, + pub max_labels_per_node: Option, + pub max_expanded_states: Option, + pub timeout: Option, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum LimitReached { + HopLimit, + LabelsPerNodeLimit, + ExpandedStatesLimit, + Timeout, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SearchCompleteness { + Exact, + Approximate { + reasons: BTreeSet, + }, +} + +#[derive(Clone, Debug, Default)] +pub struct SearchStats { + pub generated_states: usize, + pub expanded_states: usize, + pub dominated_states: usize, + pub infeasible_extensions: usize, + pub peak_labels_per_node: usize, + pub elapsed: Duration, +} + +#[must_use] +pub struct SearchOutcome { + pub value: T, + pub completeness: SearchCompleteness, + pub stats: SearchStats, +} +``` + +`BTreeSet` makes reason serialization deterministic. `Duration` is used instead of a +unit-ambiguous integer. Zero-valued count limits are valid and mean no corresponding +state may be expanded/retained; they are useful negative controls rather than invalid +configuration. `SearchStats::elapsed` remains available to Rust callers but is omitted +from serialized responses because wall-clock timing would break count-limited output +determinism. + +Internal `SearchTracker` owns the start `Instant`, counters, and reached limits. `Instant` +does not cross the public or serialization boundary. + +Dependencies: standard library only. + +### M2 — Pareto kernel (`src/rules/graph.rs`, in place) + +Purpose: enumerate elementary labels, filter the terminal Pareto front, and obey the +selected completeness policy. + +Changes: + +1. Give `PathLabel` a `final_dominates` operation used only at the destination. +2. Exact mode uses deterministic DFS backtracking with one mutable path and `Vec` + visited set, streaming completed labels into the terminal front. Its working memory is + proportional to path depth plus the terminal front rather than all generated prefixes. + Approximate mode retains arena entries because deterministic bag truncation needs a + live candidate set. +3. Reject an extension whose target node is already visited. +4. Retain every intermediate label; do not infer problem identity from label equality. +5. In exact mode, remove hop and bag truncation entirely. +6. In approximate mode, apply configured limits and notify `SearchTracker` whenever a + candidate is skipped or evicted because of a limit. +7. Filter completed destination labels by `final_dominates`, including equality, and + retain deterministic representatives. +8. Keep scalar `cost()` as agenda ordering only. It never proves intermediate dominance or + completeness. + +The kernel returns its destination front plus tracker outcome; wrapper APIs perform +domain-specific final sorting and deduplication. + +### M3 — Label domains (`src/rules/pareto.rs`, in place) + +Purpose: define domain-specific extension and terminal dominance, not resource limits. + +- `CostLabel`: componentwise `(accumulated cost, predicted size) <=` is terminal-only. +- `GrowthLabel`: fieldwise asymptotic `<=` is terminal-only. +- `MeasuredLabel`: remains outside `PathLabel`; no concrete dominance is introduced. + +Global `HOP_CAP` and `BAG_CAP` exports are removed. An interactive legacy preset may +live beside `SearchLimits`, for example `SearchLimits::interactive()`, containing the +old 16/32 values and no timeout. + +### M4 — Public graph APIs (`src/rules/graph.rs` and `src/rules/mod.rs`) + +Purpose: make completeness impossible to omit at the Rust call site. + +The following APIs gain an explicit `search_mode: SearchMode` and return +`SearchOutcome<...>`: + +```rust +find_cheapest_path(...) -> SearchOutcome> +find_cheapest_path_mode(...) -> SearchOutcome> +asymptotic_front(...) -> SearchOutcome> +find_measured_best_path(...) -> SearchOutcome> +find_measured_best_path_to_name(...) -> SearchOutcome> +``` + +No `Default` implementation is provided for `SearchMode`: callers must choose. Domain +configuration (`ReductionMode`, source size, measured budget) remains separate. + +Measured search to any target variant shares one `SearchTracker`; counters and timeout +must not reset for every variant. Prefer one traversal with a target-node predicate so +common prefixes are constructed once. If the implementation keeps per-variant +traversals, they must share limits and aggregate statistics exactly. + +### M5 — Consumers + +#### ILP solver + +- Preferred shortest formulation: `Approximate(Bounded(interactive limits))`. +- Execution-aware fallback before `NoReductionPath`: `Exact` measured search. +- A preferred formulation that constructs and solves remains sufficient; the solver is + not required to prove the smallest formulation. + +#### CLI + +Use a typed Clap value enum: + +```text +--search-mode exact|approximate +``` + +Interactive default: `approximate` with the legacy 16-hop/32-label count limits and no +timeout. Limit flags are accepted only with approximate mode: + +```text +--max-hops +--max-labels-per-node +--max-expanded-states +--timeout +``` + +Human output prints a warning only when completeness is approximate. JSON always +includes `completeness`, `limit_reasons`, and `stats`. + +#### MCP + +Request schemas mirror `search_mode` and bounded limits. Responses always include +structured completeness and stats. Unknown enum values fail schema validation rather +than silently selecting a default. + +### M6 — Documentation and migration + +- Update this design's predecessor where it describes deterministic caps as part of the + core Pareto algorithm. +- Update rustdoc with the exact elementary-path and approximate best-so-far contracts. +- Migrate every library, test, example, CLI, MCP, and solver call site explicitly. +- Document that formula exactness is exact for the formula model, not concrete target + size, and that measured exactness is conditional on its intermediate budget. + +## Technical approaches considered + +### Exact termination + +**Chosen: elementary paths without intermediate pruning.** This is finite, matches +current path-enumeration semantics, and requires no assumption that label summaries +identify constructed problems or that reduction overheads preserve an order. + +Alternatives: + +- Remove caps and allow walks: rejected because incomparable or zero-growth cycles can + create unbounded labels without a no-beneficial-cycle theorem. +- Keep a graph-wide hop bound in exact mode: rejected because no theorem establishes a + universal constant smaller than the number of variant nodes. +- Enumerate and store all simple paths before filtering: semantically equivalent but uses + exponential result memory; the chosen exact DFS filters terminal labels as it goes. + +### API compatibility + +**Chosen: breaking explicit mode parameters.** The crate is 0.x, the current contract is +misleading, and an implicit wrapper would preserve that ambiguity. + +Alternatives: + +- Keep old APIs defaulting to approximate: rejected because callers can still consume an + incomplete result unknowingly. +- Keep old APIs defaulting to exact: rejected because it silently changes latency and + memory behaviour. + +### Approximation representation + +**Chosen: one `Approximate(ApproximationPolicy)` top-level variant.** Bounded best-effort +search is the initial policy; epsilon approximation can be added without creating a +third completeness mode. + +Alternatives: + +- `Exact | Bounded | EpsilonApproximate`: rejected because bounding is a mechanism, while + exact versus approximate is the user-facing guarantee. +- A boolean `exact`: rejected because it cannot carry limits and ages poorly as policies + grow. + +### Limit accounting + +**Chosen: one tracker per public search request.** It produces honest aggregate status +across target variants and keeps limit checks consistent. + +Alternatives: + +- Per-target counters: rejected because a request could exceed its advertised limits by + the number of target variants. +- Global mutable counters: rejected because they break reentrancy and concurrency. + +## Quality requirements + +### Correctness + +- Exact mode never invokes a configurable truncation path. +- Exact mode performs no intermediate eviction or coalescing. +- Exact mode does not retain completed or dead path prefixes outside the terminal front. +- Strict dominance is applied only to completed destination labels. +- Every approximate truncation records a reason before its candidate is discarded. +- Approximate outcomes upgrade to `Exact` when no limit affects exploration. +- Returned paths are always feasible under their reduction capability and domain + constraints, regardless of completeness. + +### Determinism + +- Edge order, agenda tie-breaks, terminal representatives, bag truncation, and + reason ordering are deterministic. +- Count-limited searches are byte-stable across Linux and macOS. +- Timeout-limited searches make no cross-machine byte-stability promise and say so in + their outcome. + +### Performance + +- Approximate interactive defaults preserve or improve current CLI latency. +- Exact tests use hand-built graphs that establish correctness without exponential test + fixtures. +- Visited state adds no external dependency and remains proportional to graph node count + per live label. + +### Rust API quality + +- Use enums instead of boolean mode flags. +- Use `Duration`, `Instant`, and typed outcome/reason values instead of unit-ambiguous + integers or strings. +- Mark `SearchOutcome` as `#[must_use]`. +- Do not use global mutable policy or thread-local search state. +- Keep public intent immutable; mutable counters live in an internal tracker. +- Document failure/completeness semantics in rustdoc and serialize structured fields for + non-Rust consumers. + +### Compatibility + +- The Rust API break is deliberate and all repository call sites migrate in one change. +- CLI and MCP response additions are structured; existing path fields retain their + meaning. +- No reduction rule, model, or overhead declaration changes. + +## Verification design + +Add one hand-built regression fixture that contains both old failure modes: + +1. A unique source-to-target path with 17 edges. +2. A second branch whose hub receives at least 33 pairwise-incomparable labels, with the + true target winner deliberately ordered after the first 32. + +The fixture drives one contract test: + +```text +test_search_mode_exact_and_approximate_contract +``` + +Assertions: + +- Exact finds the 17-edge path and the post-32 winner and reports `Exact`. +- Approximate with `max_hops = 16` does not claim the long path and reports + `HopLimit`. +- Approximate with `max_labels_per_node = 32` reports `LabelsPerNodeLimit` and never + reports `Exact`. +- Approximate limits larger than the fixture require reports `Exact` and returns the + same value as Exact mode. +- Reversing equivalent-edge insertion order does not change the terminal representative or + serialized outcome. + +Add focused tests proving equal coarse intermediate labels remain distinct, +non-monotone overhead order reversal, Growth terminal equality, timeout/state accounting, +measured shared limits, and CLI/MCP serialization. Run the repository's normal +`make check` after the contract test. + +## Out of scope + +- Proving or implementing an epsilon approximation ratio. +- Changing concrete reduction failure from panic to `Result`. +- Interrupting an in-progress reduction constructor on timeout. +- Guaranteeing that measured budgets prevent allocation failure. +- Changing the `Growth` abstract domain, its sound widening, or overhead grammar. diff --git a/docs/design/symbolic-growth-domain.md b/docs/design/symbolic-growth-domain.md index e5ae1fdf8..8659eb082 100644 --- a/docs/design/symbolic-growth-domain.md +++ b/docs/design/symbolic-growth-domain.md @@ -1,6 +1,12 @@ # Symbolic Growth Domain & Pareto Path Search — Product Design Status: approved design, ready for decomposition into issues. + +Update: [`exact-approximate-path-search.md`](exact-approximate-path-search.md) +supersedes this document's implicit 16-hop/32-label search caps. The symbolic `Growth` +domain remains unchanged; search completeness is now an explicit `Exact` or +`Approximate` caller choice. + Origin: issue #1069 (`pred path --all` OOMs/hangs in `big_o_normal_form`). The acute symptom is already mitigated on `main` by a stopgap: `MAX_CANONICAL_TERMS = 50_000` in `canonical.rs` aborts oversized expansions, and the CLI falls back to printing the @@ -71,10 +77,10 @@ e-graph engines; asymptotics theory and formalization). Borrow-vs-build verdict: |---|---|---| | Albert–Alonso–Arenas–Genaim–Puebla, *Asymptotic Resource Usage Bounds* (APLAS 2009) | **Adopt as spec** | Published normal form (sums of products of `2^(r·A)`, `A^r`, `log A`) with a soundness theorem `e ∈ Θ(asymp(e))` — our correctness contract | | SageMath `AsymptoticRing` / growth groups | **Borrow the design, not the code** | GPL; the core (exponent-vector arithmetic + poset of summands with O-term absorption) is small enough to reimplement cleanly | -| KoAT weakly-monotone bound grammar (Brockschmidt et al., TOPLAS 2016) | **Adopt as axiom** | Weak monotonicity ⇒ composition-by-substitution is sound ⇒ Pareto label search is correct (isotonicity) | +| KoAT weakly-monotone bound grammar (Brockschmidt et al., TOPLAS 2016) | **Adopt for the growth domain** | Weak monotonicity supports sound composition-by-substitution inside the abstract domain; repository reduction overheads remain too general for intermediate path pruning | | LLVM SCEV / GCC chrec | **Adopt patterns** | Construction-time canonicalization, explicit budgets with graceful degradation, absorbing "don't know" sentinel (`SCEVCouldNotCompute`, `chrec_dont_know`) | | Multivariate Big-O semantics: Howell (KSU TR 2007-4); Guéneau–Charguéraud–Pottier (ESOP 2018) | **Adopt definition** | Naive multivariate O is inconsistent (Howell Thm 2.3/2.4); the product-filter definition restricted to nonnegative weakly-monotone functions is the trustworthy one | -| McRAPTOR / OpenTripPlanner `ParetoSet` / nigiri `pareto_set.h`; Martins 1984; NAMOA* | **Adopt algorithm** | Per-node label bags (antichains) with dominance pruning are the industry and literature standard for partial-order path costs; enumerate-then-filter appears nowhere as a recommended method | +| McRAPTOR / OpenTripPlanner `ParetoSet` / nigiri `pareto_set.h`; Martins 1984; NAMOA* | **Conditional reference** | Per-node dominance requires continuation-complete labels and order-preserving extension. This package cannot prove either condition for arbitrary reductions, so it retains intermediate paths and filters only at the destination | | ProblemReductions.jl `reduction_paths` | **Anti-pattern baseline** | `all_simple_paths` with no cost model, no ranking, no filter; survives only because its graph is tiny | | egg / egglog e-graphs | **Dropped** | Directional normalization doesn't need equality saturation (Cranelift aegraph retrospective: mean e-class size 1.13); egglog API unstable | | SymPy / GiNaC / Symbolica | **Concepts only** | Never auto-expand; deterministic total order on atoms; function-registry extensibility (deferred with F6) | @@ -141,10 +147,9 @@ These definitions and axioms are the trust contract; tests enforce them. - **Forbidden moves (documented + tested):** never specialize a variable to a constant inside an O-fact; never rescale coefficients of exponents (`2^(2n) ∉ O(2^n)` — exp rates compare coefficientwise, exactly). -- **Isotonicity invariant (for search):** if label `A` dominates label `B`, then for - any edge `e`, `extend(A, e)` dominates `extend(B, e)`. This follows from the - monotonicity axiom (composition by substitution into monotone expressions) and is - the correctness condition for dominance pruning in M3. +- **Search boundary:** growth-domain monotonicity does not license intermediate path + pruning. Repository overheads may contain subtraction and labels omit constructed + instance structure. M3 therefore uses growth order only on completed paths. ## Modules @@ -218,29 +223,29 @@ and reintroduces order-dependent truncation); per-edge growth caching in `ReductionEntry` with per-path folding (rejected for now: YAGNI at current graph size; revisit if profiling ever shows `from_expr` on composed paths as hot). -### M3 — Pareto label search kernel (`src/rules/graph.rs`, in-place) +### M3 — Multi-label elementary-path kernel (`src/rules/graph.rs`, in-place) -Replace `dijkstra` (~60 lines) with one generic label-setting search (~100 lines) +Replace `dijkstra` with one generic multi-label elementary-path search plus a minimal trait: ```rust pub trait PathLabel: Clone { - fn extend(&self, edge: &ReductionEdge) -> Self; // must be isotone - fn dominates(&self, other: &Self) -> bool; // partial order + fn extend(&self, edge: &ReductionEdge) -> Option; + fn final_dominates(&self, other: &Self) -> bool; } ``` -- Per-node **bag** = antichain of non-dominated labels, each with a predecessor - pointer for path reconstruction (McRAPTOR structure). -- Deterministic bounding, in the style of transit routers: hop cap (default 16) and - per-node bag cap with a **deterministic tie-break** (fewest hops, then - lexicographic node-name order) — never iteration-order truncation. A label evicted - from a bag (dominated or cap-truncated) has its arena slot's label freed immediately, - so the bag cap genuinely bounds retained per-node label memory. +- Exact mode uses DFS backtracking over elementary paths and streams completed labels into + the terminal front, so dead prefixes are released as each branch returns. Approximate + mode uses per-node bags to apply caller-provided hop, label, expanded-state, and timeout + limits and reports every limit that affected completeness. Every intermediate path + remains distinct: equal cost, size, or growth summaries do not prove identical + constructed problems. Dominance is terminal-only because repository overheads may be + non-monotone. - Label domains: - **F3a asymptotic:** label = `BTreeMap` mapping each size field of the current node to its growth in the source's variables; `extend` substitutes - the edge's overhead expressions; `dominates` is componentwise. Exponential + the edge's overhead expressions; terminal dominance is componentwise. Exponential growth is comparable via the `exp` field (polynomial paths dominate exponential ones); `Unknown` fields make a label dominated by any known label — undecidable paths rank last, which is the honest ranking. @@ -260,15 +265,16 @@ pub trait PathLabel: Clone { Measured search uses **no dominance pruning**. `ProblemSize` omits instance structure, and equal-size intermediate instances can produce different sizes under a later structure-dependent reduction. Even serialized-state equivalence is not - used to discard a route. It is therefore a separate exhaustive simple-path - enumeration, not a label domain in the capped Pareto kernel. + used to discard a route. It is therefore a separate simple-path enumeration, not a + label domain in the Pareto kernel. Note the measured label deliberately does **not** use branch-and-bound: a reduction can *shrink* the measured size, so the cost is non-monotone and a B&B bound could prune a partial route that would still finish smallest. - No hop or bag cap truncates this enumeration, so its time and retained constructed - state can grow exponentially with the number of simple paths. This also does not - bound temporary memory used inside `reduce_to()`. + Exact mode does not truncate this enumeration, so its time and retained constructed + state can grow exponentially with the number of simple paths. Approximate mode uses + only its explicit reported limits. Neither mode bounds temporary memory used inside + `reduce_to()`. This fixes the path-dependent-cost hole in the current Dijkstra *and* removes the dependency on formula accuracy for concrete decisions. - `find_cheapest_path*` become thin wrappers returning the front (instance mode @@ -280,10 +286,8 @@ pub trait PathLabel: Clone { measured optimum-finding now performs its own execution-aware simple-path enumeration because no sound state-level dominance relation is available. -Alternatives considered: enumerate-then-filter (rejected: combinatorial growth as the -graph densifies, and any truncation limit is iteration-order-dependent — the sibling -package ProblemReductions.jl does exactly this, with no cost model, and it is the -baseline we are improving on); a generic semiring algebraic-path framework (rejected: +Alternatives considered: unrestricted walks (rejected because cycles make the state +space unbounded); a generic semiring algebraic-path framework (rejected: over-engineering for two label domains); formula-evaluated instance labels (rejected after review: overhead formulas are upper bounds over declared size fields and can be arbitrarily loose on structure-dependent constructions, so a formula-ranked front may @@ -314,7 +318,8 @@ decide concrete feasibility). order get randomized property tests (≥ 5000 checks, matching the repo's verify-reduction culture): `eval(expr) ≤ C · eval(render(growth(expr)))` at large sizes; `growth` idempotent on its own rendering; `dominates(a,b)` ⟹ sampled - `eval(b)/eval(a)` grows. Isotonicity of both `PathLabel` impls is property-tested. + `eval(b)/eval(a)` grows. Positive monotone overheads preserve `GrowthLabel` order, + but search correctness does not depend on intermediate isotonicity. - **Determinism:** identical output across platforms; a test compares `pred path` output against golden files (antichain and front ordering are total and deterministic by construction). diff --git a/examples/chained_reduction_factoring_to_spinglass.rs b/examples/chained_reduction_factoring_to_spinglass.rs index 8374906e7..8a09823fe 100644 --- a/examples/chained_reduction_factoring_to_spinglass.rs +++ b/examples/chained_reduction_factoring_to_spinglass.rs @@ -27,7 +27,9 @@ pub fn run() { &dst_var, // target variant map &ProblemSize::new(vec![]), // input size (empty = unknown) &MinimizeSteps, // cost function: fewest hops + problemreductions::rules::SearchMode::Exact, ) + .value .unwrap(); println!(" {}", rpath); // ANCHOR_END: step1 diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 719e49be5..bd072f0bd 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1,3 +1,4 @@ +use crate::util::{build_search_mode, SearchLimitOverrides}; use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; use std::collections::HashMap; use std::path::PathBuf; @@ -46,6 +47,54 @@ pub struct Cli { pub command: Commands, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum SearchModeArg { + Exact, + Approximate, +} + +/// Completeness and resource policy shared by path-discovery commands. +#[derive(clap::Args, Clone, Debug)] +pub struct SearchArgs { + /// Search completeness: exact elementary-path enumeration or bounded best-effort. + #[arg(long, value_enum, default_value_t = SearchModeArg::Approximate)] + pub search_mode: SearchModeArg, + /// Maximum reduction hops in approximate mode (default: 16). + #[arg(long)] + pub max_hops: Option, + /// Maximum live labels per node in approximate mode (default: 32). + #[arg(long)] + pub max_labels_per_node: Option, + /// Maximum expanded states in approximate mode. + #[arg(long)] + pub max_expanded_states: Option, + /// Wall-clock search timeout in seconds in approximate mode. + #[arg(long = "timeout")] + pub timeout: Option, +} + +impl SearchArgs { + pub fn mode(&self) -> anyhow::Result { + build_search_mode( + self.search_mode == SearchModeArg::Exact, + SearchLimitOverrides { + max_hops: self.max_hops, + max_labels_per_node: self.max_labels_per_node, + max_expanded_states: self.max_expanded_states, + timeout_seconds: self.timeout, + }, + ) + } + + pub fn has_nondefault_policy(&self) -> bool { + self.search_mode != SearchModeArg::Approximate + || self.max_hops.is_some() + || self.max_labels_per_node.is_some() + || self.max_expanded_states.is_some() + || self.timeout.is_some() + } +} + #[derive(Subcommand)] pub enum Commands { /// List all registered problem types (or reduction rules with --rules) @@ -136,6 +185,8 @@ Use `pred list` to see available problems.")] /// Maximum paths to return in --all mode #[arg(long, default_value_t = 20)] max_paths: usize, + #[command(flatten)] + search: SearchArgs, }, /// Export the reduction graph to JSON @@ -1288,6 +1339,8 @@ pub struct ReduceArgs { /// Reduction route file (from `pred path ... -o`) #[arg(long)] pub via: Option, + #[command(flatten)] + pub search: SearchArgs, } #[derive(clap::Args)] diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index f8e84a20d..e33974b0f 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -1,5 +1,7 @@ +use crate::cli::SearchArgs; use crate::output::OutputConfig; use crate::problem_name::{aliases_for, parse_problem_spec, resolve_problem_ref}; +use crate::util::{add_search_metadata, append_search_warning}; use anyhow::{Context, Result}; use problemreductions::registry::collect_schemas; use problemreductions::rules::{ @@ -585,17 +587,25 @@ fn path_front( src_variant: &BTreeMap, dst_name: &str, dst_variant: &BTreeMap, + search: &SearchArgs, out: &OutputConfig, ) -> Result<()> { - let front = graph.asymptotic_front( + let outcome = graph.asymptotic_front( src_name, src_variant, dst_name, dst_variant, ReductionMode::Witness, + search.mode()?, ); - if front.is_empty() { + if outcome.value.is_empty() { + if !outcome.completeness.is_exact() { + anyhow::bail!( + "Bounded search was incomplete ({:?}); rerun with --search-mode exact or raise the limits", + outcome.completeness.reasons() + ); + } let variant_hint = variant_hint_for(graph, dst_name); anyhow::bail!( "No reduction path from {} to {}\n\ @@ -610,8 +620,13 @@ fn path_front( ); } - let text = format_front_text(graph, src_name, dst_name, &front); - let json = format_front_json(graph, src_name, dst_name, &front); + let mut text = format_front_text(graph, src_name, dst_name, &outcome.value); + append_search_warning(&mut text, &outcome.completeness); + let json = add_search_metadata( + format_front_json(graph, src_name, dst_name, &outcome.value), + &outcome.completeness, + &outcome.stats, + )?; out.emit_with_default_name("", &text, &json) } @@ -621,6 +636,7 @@ pub fn path( cost: Option<&str>, all: bool, max_paths: usize, + search: &SearchArgs, out: &OutputConfig, ) -> Result<()> { let src_spec = parse_problem_spec(source)?; @@ -646,6 +662,12 @@ pub fn path( // Resolve source and target to exact variant nodes let src_ref = resolve_problem_ref(source, &graph)?; let dst_ref = resolve_problem_ref(target, &graph)?; + if all && search.has_nondefault_policy() { + anyhow::bail!( + "--search-mode and search limits apply to ranked path search, not --all; use --max-paths to bound all-path enumeration" + ); + } + let _ = search.mode()?; if all { return path_all( @@ -669,6 +691,7 @@ pub fn path( &src_ref.variant, &dst_ref.name, &dst_ref.variant, + search, out, ); }; @@ -700,6 +723,7 @@ pub fn path( &dst_ref.variant, &input_size, &MinimizeSteps, + search.mode()?, ), CostChoice::Field(f) => graph.find_cheapest_path( &src_ref.name, @@ -708,16 +732,28 @@ pub fn path( &dst_ref.variant, &input_size, &Minimize(f), + search.mode()?, ), }; - match best_path { + match &best_path.value { Some(ref reduction_path) => { - let text = format_path_text(&graph, reduction_path); - let json = format_path_json(&graph, reduction_path); + let mut text = format_path_text(&graph, reduction_path); + append_search_warning(&mut text, &best_path.completeness); + let json = add_search_metadata( + format_path_json(&graph, reduction_path), + &best_path.completeness, + &best_path.stats, + )?; out.emit_with_default_name("", &text, &json) } None => { + if !best_path.completeness.is_exact() { + anyhow::bail!( + "Bounded search was incomplete ({:?}); rerun with --search-mode exact or raise the limits", + best_path.completeness.reasons() + ); + } let variant_hint = variant_hint_for(&graph, &dst_spec.name); anyhow::bail!( "No reduction path from {} to {}\n\ diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs index f0a083d4e..a355e9abe 100644 --- a/problemreductions-cli/src/commands/reduce.rs +++ b/problemreductions-cli/src/commands/reduce.rs @@ -1,9 +1,11 @@ +use crate::cli::SearchArgs; use crate::dispatch::{ load_problem, read_input, serialize_any_problem, PathStep, ProblemJson, ProblemJsonOutput, ReductionBundle, }; use crate::output::OutputConfig; use crate::problem_name::resolve_problem_ref; +use crate::util::{add_search_metadata, append_search_warning}; use anyhow::{Context, Result}; use problemreductions::rules::{ MinimizeSteps, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, @@ -55,6 +57,7 @@ pub fn reduce( input: &Path, target: Option<&str>, via: Option<&Path>, + search: &SearchArgs, out: &OutputConfig, ) -> Result<()> { // 1. Load source problem @@ -72,7 +75,12 @@ pub fn reduce( let graph = ReductionGraph::new(); // 3. Get reduction path: from --via file or auto-discover - let reduction_path = if let Some(path_file) = via { + let (reduction_path, search_metadata) = if let Some(path_file) = via { + if search.has_nondefault_policy() { + anyhow::bail!( + "--search-mode and search limits cannot be used with --via because the path is already explicit" + ); + } let path = load_path_file(path_file)?; // Validate that the path starts with the source let first = path.steps.first().unwrap(); @@ -99,7 +107,7 @@ pub fn reduce( ); } } - path + (path, None) } else { // --to is required when --via is not given let target = target.ok_or_else(|| { @@ -122,9 +130,16 @@ pub fn reduce( ReductionMode::Witness, &input_size, &MinimizeSteps, + search.mode()?, ); - best_path.ok_or_else(|| { + let path = best_path.value.ok_or_else(|| { + if !best_path.completeness.is_exact() { + return anyhow::anyhow!( + "Bounded search was incomplete ({:?}); rerun with --search-mode exact or raise the limits", + best_path.completeness.reasons() + ); + } let variant_hint = variant_hint_for(&graph, &dst_ref.name); anyhow::anyhow!( "No witness-capable reduction path from {} to {}\n\ @@ -138,7 +153,8 @@ pub fn reduce( dst_ref.name, input.display(), ) - })? + })?; + (path, Some((best_path.completeness, best_path.stats))) }; // 4. Execute reduction chain via reduce_along_path @@ -180,7 +196,10 @@ pub fn reduce( .collect(), }; - let json = serde_json::to_value(&bundle)?; + let mut json = serde_json::to_value(&bundle)?; + if let Some((completeness, stats)) = search_metadata.as_ref() { + json = add_search_metadata(json, completeness, stats)?; + } let mut text = format!( "Reduced {} to {} ({} steps)\n", @@ -189,6 +208,9 @@ pub fn reduce( reduction_path.len(), ); text.push_str(&format!("\nPath: {}\n", reduction_path)); + if let Some((completeness, _)) = search_metadata.as_ref() { + append_search_warning(&mut text, completeness); + } text.push_str( "\nHint: use -o to save the reduction bundle as JSON, or --json to print JSON to stdout.", ); diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 4849373b7..8bb248e92 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -69,7 +69,9 @@ impl LoadedProblem { ReductionMode::Witness, &input_size, &MinimizeSteps, + problemreductions::rules::SearchMode::Exact, ) + .value .is_some() }) } diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 5dcec2850..fad84ae21 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -65,16 +65,29 @@ fn main() -> anyhow::Result<()> { cost, all, max_paths, - } => commands::graph::path(&source, &target, cost.as_deref(), all, max_paths, &out), + search, + } => commands::graph::path( + &source, + &target, + cost.as_deref(), + all, + max_paths, + &search, + &out, + ), Commands::ExportGraph => commands::graph::export(&out), Commands::Inspect(args) => commands::inspect::inspect(&args.input, &out), Commands::Create(args) => commands::create::create(&args, &out), Commands::Solve(args) => { commands::solve::solve(&args.input, &args.solver, args.timeout, &out) } - Commands::Reduce(args) => { - commands::reduce::reduce(&args.input, args.to.as_deref(), args.via.as_deref(), &out) - } + Commands::Reduce(args) => commands::reduce::reduce( + &args.input, + args.to.as_deref(), + args.via.as_deref(), + &args.search, + &out, + ), Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out), Commands::Extract(args) => commands::extract::extract(&args.input, &args.config, &out), #[cfg(feature = "mcp")] diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index f5f7dec28..e756db575 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod tests { - use crate::mcp::tools::McpServer; + use crate::mcp::tools::{McpServer, SearchModeParam, SearchParams}; use crate::test_support::{aggregate_bundle, aggregate_problem_json}; #[test] @@ -32,7 +32,14 @@ mod tests { #[test] fn test_find_path() { let server = McpServer::new(); - let result = server.find_path_inner("MIS", "QUBO", Some("minimize-steps"), false, 20); + let result = server.find_path_inner( + "MIS", + "QUBO", + Some("minimize-steps"), + false, + 20, + &SearchParams::default(), + ); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert!(json["path"].as_array().unwrap().len() > 0); @@ -42,10 +49,23 @@ mod tests { fn test_find_path_asymptotic_front() { // No `cost` and not `all` → the asymptotic Pareto front with structured Growth. let server = McpServer::new(); - let result = server.find_path_inner("KSatisfiability", "QUBO", None, false, 20); + let result = server.find_path_inner( + "KSatisfiability", + "QUBO", + None, + false, + 20, + &SearchParams { + search_mode: Some(SearchModeParam::Exact), + ..Default::default() + }, + ); assert!(result.is_ok(), "err: {:?}", result.err()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert_eq!(json["mode"], "asymptotic"); + assert_eq!(json["completeness"]["status"], "exact"); + assert_eq!(json["limit_reasons"], serde_json::json!([])); + assert!(json["stats"]["expanded_states"].is_number()); let front = json["front"].as_array().unwrap(); assert!(!front.is_empty()); // Structured Growth serialization from issue #1075. @@ -53,12 +73,51 @@ mod tests { assert!(front[0]["big_o"]["num_vars"].is_string()); } + #[test] + fn test_find_path_empty_bounded_result_is_incomplete_not_no_path() { + let server = McpServer::new(); + let result = server.find_path_inner( + "MIS", + "QUBO", + None, + false, + 20, + &SearchParams { + max_hops: Some(0), + ..Default::default() + }, + ); + let error = result.expect_err("zero-hop bounded search must be incomplete"); + assert!(error.to_string().contains("Bounded search was incomplete")); + assert!(!error.to_string().contains("No reduction path from")); + } + + #[test] + fn test_find_path_all_rejects_ranked_search_policy() { + let server = McpServer::new(); + let result = server.find_path_inner( + "MIS", + "QUBO", + None, + true, + 20, + &SearchParams { + search_mode: Some(SearchModeParam::Exact), + timeout: Some(1), + ..Default::default() + }, + ); + let error = result.expect_err("all-path enumeration must reject ranked search policy"); + assert!(error.to_string().contains("not all-path enumeration")); + } + #[test] fn test_find_path_asymptotic_front_has_top_level_path() { // The default (no-cost) find_path envelope must also carry a top-level `path` // step array (the best path) so it stays consumable as a reduction route. let server = McpServer::new(); - let result = server.find_path_inner("MIS", "QUBO", None, false, 20); + let result = + server.find_path_inner("MIS", "QUBO", None, false, 20, &SearchParams::default()); assert!(result.is_ok(), "err: {:?}", result.err()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert_eq!(json["mode"], "asymptotic"); @@ -74,7 +133,14 @@ mod tests { #[test] fn test_find_path_all() { let server = McpServer::new(); - let result = server.find_path_inner("MIS", "QUBO", Some("minimize-steps"), true, 20); + let result = server.find_path_inner( + "MIS", + "QUBO", + Some("minimize-steps"), + true, + 20, + &SearchParams::default(), + ); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); // --all returns a structured envelope @@ -87,7 +153,14 @@ mod tests { #[test] fn test_find_path_all_structured_response() { let server = McpServer::new(); - let result = server.find_path_inner("MIS", "QUBO", Some("minimize-steps"), true, 20); + let result = server.find_path_inner( + "MIS", + "QUBO", + Some("minimize-steps"), + true, + 20, + &SearchParams::default(), + ); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); // Verify the structured envelope fields @@ -115,7 +188,14 @@ mod tests { let max_paths = 6usize; let server = McpServer::new(); let result = server - .find_path_inner("KSatisfiability", "QUBO", None, true, max_paths) + .find_path_inner( + "KSatisfiability", + "QUBO", + None, + true, + max_paths, + &SearchParams::default(), + ) .unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); let mcp_paths = json["paths"].as_array().unwrap(); @@ -196,8 +276,14 @@ mod tests { fn test_find_path_no_route() { let server = McpServer::new(); // Pick two problems with no path (if any). Use an unknown problem to trigger an error. - let result = - server.find_path_inner("NonExistent", "QUBO", Some("minimize-steps"), false, 20); + let result = server.find_path_inner( + "NonExistent", + "QUBO", + Some("minimize-steps"), + false, + 20, + &SearchParams::default(), + ); assert!(result.is_err()); } @@ -439,7 +525,7 @@ mod tests { fn test_reduce() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - let result = server.reduce_inner(&problem_json, "QUBO"); + let result = server.reduce_inner(&problem_json, "QUBO", &SearchParams::default()); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert!(json["target"].is_object()); @@ -451,7 +537,7 @@ mod tests { fn test_reduce_unknown_target() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - let result = server.reduce_inner(&problem_json, "NonExistent"); + let result = server.reduce_inner(&problem_json, "NonExistent", &SearchParams::default()); assert!(result.is_err()); } @@ -510,7 +596,9 @@ mod tests { let server = McpServer::new(); let problem_json = create_test_mis(&server); // Reduce first, then solve the bundle - let bundle_json = server.reduce_inner(&problem_json, "QUBO").unwrap(); + let bundle_json = server + .reduce_inner(&problem_json, "QUBO", &SearchParams::default()) + .unwrap(); let result = server.solve_inner(&bundle_json, Some("brute-force"), None); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); @@ -522,7 +610,9 @@ mod tests { fn test_solve_customized_bundle_rejects_unsupported_target_without_panicking() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - let bundle_json = server.reduce_inner(&problem_json, "QUBO").unwrap(); + let bundle_json = server + .reduce_inner(&problem_json, "QUBO", &SearchParams::default()) + .unwrap(); let result = server.solve_inner(&bundle_json, Some("customized"), None); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -536,7 +626,9 @@ mod tests { fn test_inspect_bundle() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - let bundle_json = server.reduce_inner(&problem_json, "QUBO").unwrap(); + let bundle_json = server + .reduce_inner(&problem_json, "QUBO", &SearchParams::default()) + .unwrap(); let result = server.inspect_problem_inner(&bundle_json); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); @@ -621,7 +713,11 @@ mod tests { #[test] fn test_reduce_rejects_aggregate_only_path() { let server = McpServer::new(); - let result = server.reduce_inner(&aggregate_problem_json(), "CliTestAggregateValueTarget"); + let result = server.reduce_inner( + &aggregate_problem_json(), + "CliTestAggregateValueTarget", + &SearchParams::default(), + ); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("witness"), "unexpected error: {err}"); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index ae09ecaad..1459dbfee 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -8,7 +8,7 @@ use problemreductions::models::graph::{ use problemreductions::models::misc::Factoring; use problemreductions::registry::collect_schemas; use problemreductions::rules::{ - CustomCost, MinimizeSteps, ReductionGraph, ReductionMode, TraversalFlow, + CustomCost, MinimizeSteps, ReductionGraph, ReductionMode, SearchMode, TraversalFlow, }; use problemreductions::topology::{ Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, @@ -58,6 +58,48 @@ pub struct FindPathParams { pub all: Option, #[schemars(description = "Maximum paths to return in all mode (default: 20)")] pub max_paths: Option, + #[serde(flatten)] + pub search: SearchParams, +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SearchModeParam { + Exact, + Approximate, +} + +#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)] +pub struct SearchParams { + #[schemars(description = "Search completeness: exact or approximate (default)")] + pub search_mode: Option, + pub max_hops: Option, + pub max_labels_per_node: Option, + pub max_expanded_states: Option, + #[schemars(description = "Wall-clock search timeout in seconds")] + pub timeout: Option, +} + +impl SearchParams { + fn mode(&self) -> anyhow::Result { + util::build_search_mode( + matches!(self.search_mode, Some(SearchModeParam::Exact)), + util::SearchLimitOverrides { + max_hops: self.max_hops, + max_labels_per_node: self.max_labels_per_node, + max_expanded_states: self.max_expanded_states, + timeout_seconds: self.timeout, + }, + ) + } + + fn has_nondefault_policy(&self) -> bool { + !matches!(self.search_mode, None | Some(SearchModeParam::Approximate)) + || self.max_hops.is_some() + || self.max_labels_per_node.is_some() + || self.max_expanded_states.is_some() + || self.timeout.is_some() + } } // --------------------------------------------------------------------------- @@ -98,6 +140,8 @@ pub struct ReduceParams { pub problem_json: String, #[schemars(description = "Target problem type (e.g., QUBO, ILP, SpinGlass)")] pub target: String, + #[serde(flatten)] + pub search: SearchParams, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -255,34 +299,48 @@ impl McpServer { cost: Option<&str>, all: bool, max_paths: usize, + search: &SearchParams, ) -> anyhow::Result { let graph = ReductionGraph::new(); let src_ref = resolve_problem_ref(source, &graph)?; let dst_ref = resolve_problem_ref(target, &graph)?; + if all && search.has_nondefault_policy() { + anyhow::bail!( + "search_mode and search limits apply to ranked path search, not all-path enumeration; use max_paths instead" + ); + } + let _ = search.mode()?; // No `cost` and not `all`: return the instance-free asymptotic Pareto front // (issue #1080), using the structured `Growth` serialization from #1075. if cost.is_none() && !all { - let front = graph.asymptotic_front( + let outcome = graph.asymptotic_front( &src_ref.name, &src_ref.variant, &dst_ref.name, &dst_ref.variant, ReductionMode::Witness, + search.mode()?, ); - if front.is_empty() { + if outcome.value.is_empty() { + if !outcome.completeness.is_exact() { + anyhow::bail!( + "Bounded search was incomplete ({:?}); use exact mode or raise the limits", + outcome.completeness.reasons() + ); + } anyhow::bail!( "No reduction path from {} to {}", src_ref.name, dst_ref.name ); } - return Ok(serde_json::to_string_pretty(&format_front_json( - &graph, - &src_ref.name, - &dst_ref.name, - &front, - ))?); + let json = util::add_search_metadata( + format_front_json(&graph, &src_ref.name, &dst_ref.name, &outcome.value), + &outcome.completeness, + &outcome.stats, + )?; + return Ok(serde_json::to_string_pretty(&json)?); } if all { @@ -347,6 +405,7 @@ impl McpServer { &dst_ref.variant, &input_size, &MinimizeSteps, + search.mode()?, ), Some(ref f) => { let cost_fn = CustomCost( @@ -361,16 +420,27 @@ impl McpServer { &dst_ref.variant, &input_size, &cost_fn, + search.mode()?, ) } }; - match best_path { + match &best_path.value { Some(ref reduction_path) => { - let json = format_path_json(&graph, reduction_path); + let json = util::add_search_metadata( + format_path_json(&graph, reduction_path), + &best_path.completeness, + &best_path.stats, + )?; Ok(serde_json::to_string_pretty(&json)?) } None => { + if !best_path.completeness.is_exact() { + anyhow::bail!( + "Bounded search was incomplete ({:?}); use exact mode or raise the limits", + best_path.completeness.reasons() + ); + } anyhow::bail!( "No reduction path from {} to {}", src_ref.name, @@ -815,7 +885,12 @@ impl McpServer { Ok(serde_json::to_string_pretty(&json)?) } - pub fn reduce_inner(&self, problem_json: &str, target: &str) -> anyhow::Result { + pub fn reduce_inner( + &self, + problem_json: &str, + target: &str, + search: &SearchParams, + ) -> anyhow::Result { let pj: ProblemJson = serde_json::from_str(problem_json)?; let source = load_problem(&pj.problem_type, &pj.variant, pj.data.clone())?; @@ -835,9 +910,16 @@ impl McpServer { ReductionMode::Witness, &input_size, &MinimizeSteps, + search.mode()?, ); - let reduction_path = best_path.ok_or_else(|| { + let reduction_path = best_path.value.as_ref().ok_or_else(|| { + if !best_path.completeness.is_exact() { + return anyhow::anyhow!( + "Bounded search was incomplete ({:?}); use exact mode or raise the limits", + best_path.completeness.reasons() + ); + } anyhow::anyhow!( "No witness-capable reduction path from {} to {}", source_name, @@ -884,7 +966,12 @@ impl McpServer { .collect(), }; - Ok(serde_json::to_string_pretty(&bundle)?) + let json = util::add_search_metadata( + serde_json::to_value(&bundle)?, + &best_path.completeness, + &best_path.stats, + )?; + Ok(serde_json::to_string_pretty(&json)?) } pub fn solve_inner( @@ -1000,6 +1087,7 @@ impl McpServer { params.cost.as_deref(), all, max_paths, + ¶ms.search, ) .map_err(|e| e.to_string()) } @@ -1055,7 +1143,7 @@ impl McpServer { annotations(read_only_hint = true, open_world_hint = false) )] fn reduce(&self, Parameters(params): Parameters) -> Result { - self.reduce_inner(¶ms.problem_json, ¶ms.target) + self.reduce_inner(¶ms.problem_json, ¶ms.target, ¶ms.search) .map_err(|e| e.to_string()) } diff --git a/problemreductions-cli/src/util.rs b/problemreductions-cli/src/util.rs index 0f9b08a3d..06e79dace 100644 --- a/problemreductions-cli/src/util.rs +++ b/problemreductions-cli/src/util.rs @@ -3,6 +3,9 @@ use anyhow::{bail, Result}; use num_bigint::BigUint; use problemreductions::prelude::*; +use problemreductions::rules::{ + ApproximationPolicy, SearchCompleteness, SearchLimits, SearchMode, SearchStats, +}; use problemreductions::topology::SimpleGraph; use problemreductions::variant::{K2, K3, KN}; use serde::Serialize; @@ -237,6 +240,70 @@ pub fn lcg_choose(state: &mut u64, n: usize, k: usize) -> Vec { // Small shared helpers // --------------------------------------------------------------------------- +#[derive(Clone, Copy, Debug, Default)] +pub struct SearchLimitOverrides { + pub max_hops: Option, + pub max_labels_per_node: Option, + pub max_expanded_states: Option, + pub timeout_seconds: Option, +} + +pub fn build_search_mode(exact: bool, overrides: SearchLimitOverrides) -> Result { + if exact { + if overrides.max_hops.is_some() + || overrides.max_labels_per_node.is_some() + || overrides.max_expanded_states.is_some() + || overrides.timeout_seconds.is_some() + { + bail!("Search limits are accepted only in approximate mode"); + } + return Ok(SearchMode::Exact); + } + + let mut limits = SearchLimits::interactive(); + if let Some(max_hops) = overrides.max_hops { + limits.max_hops = Some(max_hops); + } + if let Some(max_labels) = overrides.max_labels_per_node { + limits.max_labels_per_node = Some(max_labels); + } + limits.max_expanded_states = overrides.max_expanded_states; + limits.timeout = overrides + .timeout_seconds + .map(std::time::Duration::from_secs); + Ok(SearchMode::Approximate(ApproximationPolicy::Bounded( + limits, + ))) +} + +pub fn add_search_metadata( + mut json: serde_json::Value, + completeness: &SearchCompleteness, + stats: &SearchStats, +) -> Result { + if let Some(object) = json.as_object_mut() { + object.insert( + "completeness".to_string(), + serde_json::to_value(completeness)?, + ); + object.insert( + "limit_reasons".to_string(), + serde_json::to_value(completeness.reasons())?, + ); + object.insert("stats".to_string(), serde_json::to_value(stats)?); + } + Ok(json) +} + +pub fn append_search_warning(text: &mut String, completeness: &SearchCompleteness) { + if !completeness.is_exact() { + text.push_str(&format!( + "\nWarning: bounded search is incomplete ({:?}).\n", + completeness.reasons() + )); + } +} + pub fn ser(problem: T) -> Result { Ok(serde_json::to_value(problem)?) } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 3b8809c86..35377d20a 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -248,13 +248,23 @@ fn test_path_asymptotic_front_deterministic() { // The JSON surface carries the structured Growth serialization (issue #1075). let json_out = pred() - .args(["path", "KSatisfiability", "QUBO", "--json"]) + .args([ + "path", + "KSatisfiability", + "QUBO", + "--search-mode", + "exact", + "--json", + ]) .output() .unwrap(); assert!(json_out.status.success()); let json: serde_json::Value = serde_json::from_str(&String::from_utf8(json_out.stdout).unwrap()).unwrap(); assert_eq!(json["mode"], "asymptotic"); + assert_eq!(json["completeness"]["status"], "exact"); + assert_eq!(json["limit_reasons"], serde_json::json!([])); + assert!(json["stats"]["expanded_states"].is_number()); let front = json["front"].as_array().expect("front array"); assert!(!front.is_empty(), "front must have ≥ 1 path"); assert!( @@ -266,9 +276,8 @@ fn test_path_asymptotic_front_deterministic() { } /// The asymptotic front reports one path per distinct growth vector, not per route. -/// `MVC → ILP` has dozens of reduction chains that compose to only a few Big-O -/// profiles; the front must collapse to that small handful with no duplicate growth -/// vectors. (Regression: before dedup this printed 32 paths, most identical.) +/// `MVC → ILP` has many reduction chains that compose to fewer Big-O profiles; the +/// front must contain no duplicate growth vectors. #[test] fn test_path_front_dedups_by_growth_vector() { let output = pred() @@ -280,12 +289,7 @@ fn test_path_front_dedups_by_growth_vector() { serde_json::from_str(&String::from_utf8(output.stdout).unwrap()).unwrap(); let front = json["front"].as_array().expect("front array"); - // A proper Pareto front is a small handful (issue #1080: "typically 1–3 paths"). - assert!( - (1..=4).contains(&front.len()), - "expected 1..=4 distinct growth vectors, got {}", - front.len() - ); + assert!(!front.is_empty()); // No two entries share a growth vector (the Big-O per size field). let vectors: Vec = front.iter().map(|p| p["big_o"].to_string()).collect(); let mut unique = vectors.clone(); @@ -298,6 +302,40 @@ fn test_path_front_dedups_by_growth_vector() { ); } +#[test] +fn test_path_exact_rejects_approximate_limit_flags() { + let output = pred() + .args([ + "path", + "MIS", + "QUBO", + "--search-mode", + "exact", + "--timeout", + "1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Search limits are accepted only in approximate mode"), + "{stderr}" + ); +} + +#[test] +fn test_path_empty_bounded_result_is_reported_as_incomplete() { + let output = pred() + .args(["path", "MIS", "QUBO", "--max-hops", "0"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("Bounded search was incomplete"), "{stderr}"); + assert!(!stderr.contains("No reduction path from"), "{stderr}"); +} + #[test] fn test_path_save() { let tmp = std::env::temp_dir().join("pred_test_path.json"); @@ -334,6 +372,17 @@ fn test_path_all() { assert!(stdout.contains("paths from")); } +#[test] +fn test_path_all_rejects_ranked_search_policy() { + let output = pred() + .args(["path", "MIS", "QUBO", "--all", "--search-mode", "exact"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("not --all"), "{stderr}"); +} + #[test] fn test_path_all_save() { let dir = std::env::temp_dir().join("pred_test_all_paths"); @@ -1310,6 +1359,21 @@ fn test_reduce_via_path() { assert_eq!(bundle["source"]["type"], "MaximumIndependentSet"); assert_eq!(bundle["target"]["type"], "QUBO"); + let rejected = pred() + .args([ + "reduce", + problem_file.to_str().unwrap(), + "--via", + path_file.to_str().unwrap(), + "--search-mode", + "exact", + ]) + .output() + .unwrap(); + assert!(!rejected.status.success()); + let stderr = String::from_utf8(rejected.stderr).unwrap(); + assert!(stderr.contains("cannot be used with --via"), "{stderr}"); + std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&path_file).ok(); std::fs::remove_file(&output_file).ok(); diff --git a/src/rules/cost.rs b/src/rules/cost.rs index c44846efe..1d59a4fd7 100644 --- a/src/rules/cost.rs +++ b/src/rules/cost.rs @@ -7,11 +7,9 @@ use crate::types::ProblemSize; pub trait PathCostFn { /// Compute cost of taking an edge given current problem size. /// - /// Implementations **must** be monotone in `current_size` (a componentwise-larger - /// size never yields a smaller edge cost). The Pareto search prunes by `(cost, size)` - /// dominance, and this monotonicity is what gives the isotonicity that makes such - /// pruning sound. (A nonnegative cost is also expected — all shipped implementations - /// return one — though the kernel no longer branch-and-bounds on it.) + /// This need not be monotone in `current_size`: intermediate strict dominance is not + /// used by the exact search. The value controls agenda ordering and contributes to + /// the completed path's final cost. fn edge_cost(&self, overhead: &ReductionOverhead, current_size: &ProblemSize) -> f64; } diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 32e34cfdd..8840c51cb 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -9,17 +9,17 @@ //! //! This module implements: //! - Variant-level graph construction from `VariantEntry` and `ReductionEntry` inventory -//! - Dijkstra's algorithm with custom cost functions for optimal paths +//! - Exact and bounded-approximate Pareto path search with custom cost functions //! - JSON export for documentation and visualization use crate::rules::cost::PathCostFn; -use crate::rules::pareto::{ - CostLabel, GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, HOP_CAP, -}; +use crate::rules::pareto::{CostLabel, GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge}; use crate::rules::registry::{ AggregateReduceFn, EdgeCapabilities, ReduceFn, ReductionEntry, ReductionOverhead, }; +use crate::rules::search::SearchTracker; use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; +use crate::rules::{LimitReached, SearchMode, SearchOutcome}; use crate::types::ProblemSize; use ordered_float::OrderedFloat; use petgraph::algo::all_simple_paths; @@ -282,7 +282,7 @@ pub struct NeighborTree { /// /// The graph supports: /// - Auto-discovery of reductions from `inventory::iter::` -/// - Dijkstra with custom cost functions +/// - Exact and bounded-approximate Pareto search with custom cost functions /// - Path finding by problem type or by name pub struct ReductionGraph { /// Graph with node indices as node data, edge weights as ReductionEdgeData. @@ -295,7 +295,79 @@ pub struct ReductionGraph { default_variants: HashMap>, } +struct ExactParetoDfs<'a, 'b, L> { + graph: &'a ReductionGraph, + dst: NodeIndex, + adjacency: &'a [Vec<(NodeIndex, EdgeIndex)>], + front: &'b mut Vec<(ReductionPath, L)>, + tracker: &'b mut SearchTracker, +} + +impl ExactParetoDfs<'_, '_, L> { + fn visit( + &mut self, + node: NodeIndex, + label: L, + path: &mut Vec, + visited: &mut [bool], + ) { + if node == self.dst { + let candidate = (self.graph.node_path_to_reduction_path(path), label); + self.graph + .insert_terminal_candidate(self.front, candidate, self.tracker); + return; + } + + let edge_count = self.adjacency[node.index()].len(); + if edge_count == 0 { + return; + } + self.tracker.record_expanded(); + + for edge_pos in 0..edge_count { + let (target, edge_idx) = self.adjacency[node.index()][edge_pos]; + if visited[target.index()] { + continue; + } + let weight = &self.graph.graph[edge_idx]; + let target_node = &self.graph.nodes[self.graph.graph[target]]; + let edge = ReductionEdge { + overhead: &weight.overhead, + reduce_fn: weight.reduce_fn, + capabilities: weight.capabilities, + target_name: target_node.name, + target_variant: &target_node.variant, + }; + let Some(next_label) = label.extend(&edge) else { + self.tracker.record_infeasible(); + continue; + }; + self.tracker.record_generated(); + visited[target.index()] = true; + path.push(target); + self.visit(target, next_label, path, visited); + path.pop(); + visited[target.index()] = false; + } + } +} + impl ReductionGraph { + fn measured_path_from_label( + path: ReductionPath, + label: MeasuredLabel<'_>, + ) -> Option { + let steps = label.chain(); + if steps.is_empty() { + return None; + } + Some(MeasuredPath { + path, + size: label.measured_size().clone(), + steps, + }) + } + /// Create a new reduction graph with all registered reductions from inventory. pub fn new() -> Self { let mut graph = DiGraph::new(); @@ -434,6 +506,25 @@ impl ReductionGraph { } } + fn ordered_outgoing_edges( + &self, + node: NodeIndex, + mode: ReductionMode, + ) -> Vec<(NodeIndex, EdgeIndex)> { + let mut edges: Vec<_> = self + .graph + .edges(node) + .filter(|edge| Self::edge_supports_mode(edge.weight(), mode)) + .map(|edge| (edge.target(), edge.id())) + .collect(); + edges.sort_by(|a, b| { + let a = &self.nodes[self.graph[a.0]]; + let b = &self.nodes[self.graph[b.0]]; + (a.name, &a.variant).cmp(&(b.name, &b.variant)) + }); + edges + } + fn node_path_supports_mode(&self, node_path: &[NodeIndex], mode: ReductionMode) -> bool { node_path.windows(2).all(|pair| { self.graph @@ -444,8 +535,13 @@ impl ReductionGraph { /// Find the cheapest path between two specific problem variants. /// - /// Uses Dijkstra's algorithm on the variant-level graph from the exact - /// source variant node to the exact target variant node. + /// Searches the variant-level graph from the exact source variant node to the exact + /// target variant node under the caller's explicit completeness policy. `Exact` + /// covers every elementary path permitted by the formula label semantics; + /// `Approximate` returns a valid best-so-far path and records every reached limit. + /// Formula-search exactness does not imply that a predicted size equals a later + /// constructed instance size. + #[allow(clippy::too_many_arguments)] pub fn find_cheapest_path( &self, source: &str, @@ -454,7 +550,8 @@ impl ReductionGraph { target_variant: &BTreeMap, input_size: &ProblemSize, cost_fn: &C, - ) -> Option { + search_mode: SearchMode, + ) -> SearchOutcome> { self.find_cheapest_path_mode( source, source_variant, @@ -463,16 +560,18 @@ impl ReductionGraph { ReductionMode::Witness, input_size, cost_fn, + search_mode, ) } /// Find the cheapest path between two specific problem variants while /// requiring a specific edge capability. /// - /// Runs the generic [Pareto label-setting search](Self::pareto_search) with a - /// scalar [`CostLabel`], reproducing Dijkstra's single-objective behavior for the - /// given [`PathCostFn`]. Returns the front's best element under the deterministic + /// Runs the generic [multi-label elementary-path search](Self::pareto_search) with a + /// [`CostLabel`] domain. Returns the front's best element under the deterministic /// tie-break (smallest cost, then fewest hops, then lexicographic node names). + /// `Exact` covers the full elementary-path space for those formula semantics; + /// `Approximate` may return a best-so-far result with structured limit reasons. #[allow(clippy::too_many_arguments)] pub fn find_cheapest_path_mode( &self, @@ -483,30 +582,27 @@ impl ReductionGraph { mode: ReductionMode, input_size: &ProblemSize, cost_fn: &C, - ) -> Option { - let src = self.lookup_node(source, source_variant)?; - let dst = self.lookup_node(target, target_variant)?; + search_mode: SearchMode, + ) -> SearchOutcome> { + let mut tracker = SearchTracker::new(&search_mode); + let (Some(src), Some(dst)) = ( + self.lookup_node(source, source_variant), + self.lookup_node(target, target_variant), + ) else { + return tracker.finish(None); + }; let initial = CostLabel::new(input_size.clone(), cost_fn); - let mut front = self.pareto_search(src, dst, mode, initial, false); - self.pick_best_front(&mut front).map(|(path, _)| path) + let mut front = self.pareto_search(src, dst, mode, initial, &mut tracker); + tracker.finish(self.pick_best_front(&mut front).map(|(path, _)| path)) } - /// Generic Pareto label-setting search from `src` to `dst`. - /// - /// Maintains a per-node **bag** (an antichain of non-dominated labels); a label is - /// discarded only when another label at the same node [dominates](PathLabel::dominates) - /// it. Each surviving label carries a predecessor pointer for path reconstruction. - /// Pruning is by dominance alone — always sound for any label domain, unlike a - /// branch-and-bound bound, which would require a monotone scalar `cost` that the - /// measured domain does not have. The frontier is explored in ascending - /// [`cost`](PathLabel::cost) order (a heuristic that finds good paths early). - /// Deterministic safety caps apply: [`HOP_CAP`] bounds path length, and [`BAG_CAP`] - /// bounds each bag with a deterministic tie-break (never iteration-order truncation). - /// Edges are visited in a deterministic (target-name, target-variant) order. + /// Generic multi-label elementary-path search from `src` to `dst`. /// - /// When `exhaustive` is `true`, the componentwise dominance guard is disabled (bags - /// retain all labels up to the cap); the sound guards inside [`PathLabel::extend`] - /// still apply. + /// Intermediate pruning and coalescing are forbidden because arbitrary reduction + /// overheads are not guaranteed to be isotone and labels do not identify complete + /// constructed problems. Pareto dominance is applied only to completed destination + /// labels. Exact search has no configurable truncation; approximate limits are + /// explicit and reported. /// /// Returns the Pareto front at `dst`: `(path, label)` pairs, deterministically /// ordered by (cost, hops, node-name path). @@ -516,16 +612,18 @@ impl ReductionGraph { dst: NodeIndex, mode: ReductionMode, initial: L, - exhaustive: bool, + tracker: &mut SearchTracker, ) -> Vec<(ReductionPath, L)> { - // `label` is `Option` so an evicted entry (dominated or cap-truncated) can free its - // label immediately via `take()`. Invariant: any arena index that is a current - // member of some bag has `label == Some`; only non-members may be `None`. + if tracker.is_exact_mode() { + return self.pareto_search_exact(src, dst, mode, initial, tracker); + } + struct Entry { node: NodeIndex, label: Option, pred: Option, hops: usize, + visited: Vec, } let mut arena: Vec> = Vec::new(); @@ -533,71 +631,68 @@ impl ReductionGraph { let mut frontier: BinaryHeap, usize)>> = BinaryHeap::new(); let mut adjacency: HashMap> = HashMap::new(); + tracker.record_generated(); + if tracker.label_limit() == Some(0) { + tracker.reach(LimitReached::LabelsPerNodeLimit); + return Vec::new(); + } + + let mut initial_visited = vec![false; self.graph.node_count()]; + initial_visited[src.index()] = true; arena.push(Entry { node: src, label: Some(initial.clone()), pred: None, hops: 0, + visited: initial_visited, }); bags.entry(src).or_default().push(0); + tracker.observe_bag(1); frontier.push(Reverse((OrderedFloat(initial.cost()), 0))); - // Reconstruct the node-name path for an arena entry (used for deterministic - // tie-breaks). Returns the sequence of node names from source to `idx`. - let name_path = |arena: &Vec>, idx: usize| -> Vec<&'static str> { - let mut names = Vec::new(); + let node_path = |arena: &Vec>, idx: usize| -> Vec { + let mut nodes = Vec::new(); let mut cur = Some(idx); while let Some(i) = cur { - names.push(self.nodes[self.graph[arena[i].node]].name); + nodes.push(arena[i].node); cur = arena[i].pred; } - names.reverse(); - names + nodes.reverse(); + nodes }; - while let Some(Reverse((_cost, idx))) = frontier.pop() { let node = arena[idx].node; - // Skip stale entries (removed from their bag because dominated / capped out). - if !bags.get(&node).is_some_and(|b| b.contains(&idx)) { + if arena[idx].label.is_none() { continue; } - // Clone the current label ONCE, up front. A live bag member always has - // `Some` (invariant above), so the `else` is unreachable. Using this local for - // every extend below means we never read `arena[idx].label` inside the edge - // loop — which also removes the self-edge hazard where extending a target == - // `node` edge could `take()` this entry's label mid-loop. - let Some(cur_label) = arena[idx].label.clone() else { - continue; - }; - // The destination is terminal: keep it in the front, never expand it. if node == dst { continue; } - if arena[idx].hops >= HOP_CAP { - continue; - } - // Deterministic edge order, cached because many labels can visit one node. let edges = adjacency .entry(node) - .or_insert_with(|| { - let mut edges: Vec<(NodeIndex, EdgeIndex)> = self - .graph - .edges(node) - .filter(|e| Self::edge_supports_mode(e.weight(), mode)) - .map(|e| (e.target(), e.id())) - .collect(); - edges.sort_by(|a, b| { - let na = &self.nodes[self.graph[a.0]]; - let nb = &self.nodes[self.graph[b.0]]; - (na.name, &na.variant).cmp(&(nb.name, &nb.variant)) - }); - edges - }) - .clone(); + .or_insert_with(|| self.ordered_outgoing_edges(node, mode)); + if edges.is_empty() { + continue; + } + if tracker.timed_out() || tracker.expansion_limited() { + break; + } + if tracker.hop_limited(arena[idx].hops) { + continue; + } + tracker.record_expanded(); + + let Some(cur_label) = arena[idx].label.clone() else { + continue; + }; + let cur_visited = arena[idx].visited.clone(); let hops = arena[idx].hops; - for (target, edge_idx) in edges { + for &(target, edge_idx) in edges.iter() { + if cur_visited[target.index()] { + continue; + } let weight = &self.graph[edge_idx]; let target_node = &self.nodes[self.graph[target]]; let redge = ReductionEdge { @@ -608,54 +703,32 @@ impl ReductionGraph { target_variant: &target_node.variant, }; let Some(new_label) = cur_label.extend(&redge) else { + tracker.record_infeasible(); continue; }; + tracker.record_generated(); let new_cost = new_label.cost(); - // Componentwise dominance against the target's bag. - if !exhaustive { - let bag = bags.entry(target).or_default(); - // Dominated by an existing bag member? (Bag members are always `Some`.) - if bag.iter().any(|&j| { - arena[j] - .label - .as_ref() - .is_some_and(|l| l.dominates(&new_label)) - }) { - continue; - } - // Evict every bag member the new label dominates. `Vec::retain` does - // not surface the removed elements, so collect their indices, drop them - // from the bag, then free their labels (`take()`) so nothing dominated - // lingers in the arena. - let mut evicted: Vec = Vec::new(); - bag.retain(|&j| { - let dominated = arena[j] - .label - .as_ref() - .is_some_and(|l| new_label.dominates(l)); - if dominated { - evicted.push(j); - } - !dominated - }); - for j in evicted { - arena[j].label = None; - } - } + let mut new_visited = cur_visited.clone(); + new_visited[target.index()] = true; + let nidx = arena.len(); arena.push(Entry { node: target, label: Some(new_label), pred: Some(idx), hops: hops + 1, + visited: new_visited, }); bags.entry(target).or_default().push(nidx); frontier.push(Reverse((OrderedFloat(new_cost), nidx))); + tracker.observe_bag(bags[&target].len()); - // Enforce the per-node bag cap with a deterministic tie-break. - if bags[&target].len() > BAG_CAP { + if let Some(limit) = tracker.label_limit() { + if bags[&target].len() <= limit { + continue; + } + tracker.reach(LimitReached::LabelsPerNodeLimit); let mut entries = bags[&target].clone(); - // Bag members are always `Some`; the `unwrap_or(INFINITY)` is defensive. let entry_cost = |i: usize| { arena[i] .label @@ -668,32 +741,30 @@ impl ReductionGraph { .partial_cmp(&entry_cost(b)) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| arena[a].hops.cmp(&arena[b].hops)) - .then_with(|| name_path(&arena, a).cmp(&name_path(&arena, b))) + .then_with(|| { + self.path_order_key(&node_path(&arena, a)) + .cmp(&self.path_order_key(&node_path(&arena, b))) + }) }); - // Free the labels of the truncated tail before dropping their indices. - for &j in &entries[BAG_CAP..] { + for &j in &entries[limit..] { arena[j].label = None; } - entries.truncate(BAG_CAP); + entries.truncate(limit); bags.insert(target, entries); } } } - // The front is the (live) bag at the destination. - let mut front: Vec<(ReductionPath, L)> = bags + // Collect every retained destination label. Strict dominance is safe here because + // completed labels have no future extension whose non-monotonicity could reverse + // the order. + let mut completed: Vec<(ReductionPath, L)> = bags .get(&dst) .map(|b| b.as_slice()) .unwrap_or(&[]) .iter() .map(|&idx| { - let mut node_path = Vec::new(); - let mut cur = Some(idx); - while let Some(i) = cur { - node_path.push(arena[i].node); - cur = arena[i].pred; - } - node_path.reverse(); + let node_path = node_path(&arena, idx); ( self.node_path_to_reduction_path(&node_path), // Live dst bag members are always `Some` (bag-member invariant). @@ -705,17 +776,85 @@ impl ReductionGraph { }) .collect(); - // Deterministic ordering of the front. - front.sort_by(|a, b| { - a.1.cost() - .partial_cmp(&b.1.cost()) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.0.len().cmp(&b.0.len())) - .then_with(|| a.0.type_names().cmp(&b.0.type_names())) - }); + completed.sort_by(Self::compare_front_entries); + + let mut front = Vec::new(); + for candidate in completed { + self.insert_terminal_candidate(&mut front, candidate, tracker); + } + front.sort_by(Self::compare_front_entries); front } + /// Exact elementary-path traversal with working memory proportional to path depth. + /// + /// No intermediate state is compared with another. A single visited set and path are + /// mutated during deterministic DFS backtracking; only terminal Pareto labels remain + /// live after their branch returns. + fn pareto_search_exact( + &self, + src: NodeIndex, + dst: NodeIndex, + mode: ReductionMode, + initial: L, + tracker: &mut SearchTracker, + ) -> Vec<(ReductionPath, L)> { + let mut adjacency = vec![Vec::new(); self.graph.node_count()]; + for node in self.graph.node_indices() { + adjacency[node.index()] = self.ordered_outgoing_edges(node, mode); + } + + tracker.record_generated(); + tracker.observe_bag(1); + let mut path = vec![src]; + let mut visited = vec![false; self.graph.node_count()]; + visited[src.index()] = true; + let mut front = Vec::new(); + ExactParetoDfs { + graph: self, + dst, + adjacency: &adjacency, + front: &mut front, + tracker, + } + .visit(src, initial, &mut path, &mut visited); + front.sort_by(Self::compare_front_entries); + front + } + + fn compare_front_entries( + a: &(ReductionPath, L), + b: &(ReductionPath, L), + ) -> std::cmp::Ordering { + a.1.cost() + .partial_cmp(&b.1.cost()) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.len().cmp(&b.0.len())) + .then_with(|| a.0.type_names().cmp(&b.0.type_names())) + } + + fn insert_terminal_candidate( + &self, + front: &mut Vec<(ReductionPath, L)>, + candidate: (ReductionPath, L), + tracker: &mut SearchTracker, + ) { + let precedes = |a: &(ReductionPath, L), b: &(ReductionPath, L)| { + a.1.final_dominates(&b.1) + && (!b.1.final_dominates(&a.1) + || Self::compare_front_entries(a, b) != std::cmp::Ordering::Greater) + }; + if front.iter().any(|existing| precedes(existing, &candidate)) { + tracker.record_dominated(1); + return; + } + + let before = front.len(); + front.retain(|existing| !precedes(&candidate, existing)); + tracker.record_dominated(before - front.len()); + front.push(candidate); + } + /// Name-keyed entry to [`pareto_search`](Self::pareto_search): resolves the source /// and target variant nodes, then runs the generic search. Returns an empty vector /// if either endpoint is not registered. Test-only: drives the generic kernel with a @@ -730,15 +869,17 @@ impl ReductionGraph { target_variant: &BTreeMap, mode: ReductionMode, initial: L, - exhaustive: bool, - ) -> Vec<(ReductionPath, L)> { + search_mode: SearchMode, + ) -> SearchOutcome> { + let mut tracker = SearchTracker::new(&search_mode); let (Some(src), Some(dst)) = ( self.lookup_node(source, source_variant), self.lookup_node(target, target_variant), ) else { - return vec![]; + return tracker.finish(vec![]); }; - self.pareto_search(src, dst, mode, initial, exhaustive) + let front = self.pareto_search(src, dst, mode, initial, &mut tracker); + tracker.finish(front) } /// Pick the best element of a Pareto front under the deterministic tie-break @@ -796,7 +937,7 @@ impl ReductionGraph { ReductionPath { steps } } - /// Enumerate every witness-capable simple path from `src` to `dst`, executing each + /// Enumerate witness-capable simple paths from `src` to any target, executing each /// reduction as it is reached and retaining the measured-smallest completed target. /// /// This is deliberately separate from [`pareto_search`](Self::pareto_search): no @@ -807,16 +948,28 @@ impl ReductionGraph { fn measured_best_simple_path<'a>( &self, src: NodeIndex, - dst: NodeIndex, + targets: &HashSet, mode: ReductionMode, initial: MeasuredLabel<'a>, + tracker: &mut SearchTracker, ) -> Option<(ReductionPath, MeasuredLabel<'a>)> { + tracker.record_generated(); + if tracker.label_limit() == Some(0) { + tracker.reach(LimitReached::LabelsPerNodeLimit); + return None; + } let mut stack = vec![(src, vec![src], initial)]; + let mut retained_per_node: HashMap = HashMap::new(); + retained_per_node.insert(src, 1); + tracker.observe_bag(1); let mut adjacency: HashMap> = HashMap::new(); let mut best: Option<(Vec, MeasuredLabel<'a>)> = None; while let Some((node, node_path, label)) = stack.pop() { - if node == dst { + if let Some(retained) = retained_per_node.get_mut(&node) { + *retained -= 1; + } + if targets.contains(&node) { let candidate_key = ( label.measured_size().total(), node_path.len(), @@ -838,24 +991,20 @@ impl ReductionGraph { let edges = adjacency .entry(node) - .or_insert_with(|| { - let mut edges: Vec<(NodeIndex, EdgeIndex)> = self - .graph - .edges(node) - .filter(|e| Self::edge_supports_mode(e.weight(), mode)) - .map(|e| (e.target(), e.id())) - .collect(); - edges.sort_by(|a, b| { - let na = &self.nodes[self.graph[a.0]]; - let nb = &self.nodes[self.graph[b.0]]; - (na.name, &na.variant).cmp(&(nb.name, &nb.variant)) - }); - edges - }) - .clone(); + .or_insert_with(|| self.ordered_outgoing_edges(node, mode)); + if edges.is_empty() { + continue; + } + if tracker.timed_out() || tracker.expansion_limited() { + break; + } + if tracker.hop_limited(node_path.len() - 1) { + continue; + } + tracker.record_expanded(); // Reverse push order so DFS visits the deterministic ascending edge order. - for (target, edge_idx) in edges.into_iter().rev() { + for &(target, edge_idx) in edges.iter().rev() { if node_path.contains(&target) { continue; } @@ -869,11 +1018,22 @@ impl ReductionGraph { target_variant: &target_node.variant, }; let Some(next_label) = label.extend(&edge) else { + tracker.record_infeasible(); continue; }; + tracker.record_generated(); + if tracker.label_limit().is_some_and(|limit| { + retained_per_node.get(&target).copied().unwrap_or(0) >= limit + }) { + tracker.reach(LimitReached::LabelsPerNodeLimit); + continue; + } let mut next_path = node_path.clone(); next_path.push(target); stack.push((target, next_path, next_label)); + let retained = retained_per_node.entry(target).or_default(); + *retained += 1; + tracker.observe_bag(*retained); } } @@ -1955,10 +2115,10 @@ impl ReductionGraph { /// /// `budget` is the hard total-size limit (sum of `ProblemSize` components); use /// [`DEFAULT_SIZE_BUDGET`](crate::rules::DEFAULT_SIZE_BUDGET) for the default. - /// The search exhaustively enumerates witness-capable simple paths. It does not use - /// dominance pruning, branch-and-bound, or the generic Pareto kernel's bag/hop caps: - /// neither size vectors nor serialized state equality discard a route. The - /// post-construction measured-budget guard still applies. + /// Exact search enumerates witness-capable simple paths without dominance pruning or + /// branch-and-bound. Approximate search applies only the limits explicitly carried by + /// `search_mode`. Neither size vectors nor serialized state equality discard a route. + /// The post-construction measured-budget guard still applies. /// Because the target must be built before it can be measured, the budget is not an /// anti-OOM guarantee. /// @@ -1973,30 +2133,31 @@ impl ReductionGraph { mode: ReductionMode, source_instance: &dyn Any, budget: usize, - ) -> Option { - let src = self.lookup_node(source, source_variant)?; - let dst = self.lookup_node(target, target_variant)?; + search_mode: SearchMode, + ) -> SearchOutcome> { + let mut tracker = SearchTracker::new(&search_mode); + let (Some(src), Some(dst)) = ( + self.lookup_node(source, source_variant), + self.lookup_node(target, target_variant), + ) else { + return tracker.finish(None); + }; if src == dst { - return None; + return tracker.finish(None); } let source_size = Self::compute_source_size(source, source_instance); let initial = MeasuredLabel::new(source_instance, source_size, budget); - let (path, label) = self.measured_best_simple_path(src, dst, mode, initial)?; - let steps: Vec> = label.chain().to_vec(); - if steps.is_empty() { - return None; - } - Some(MeasuredPath { - path, - size: label.measured_size().clone(), - steps, - }) + let targets = HashSet::from([dst]); + let result = self + .measured_best_simple_path(src, &targets, mode, initial, &mut tracker) + .and_then(|(path, label)| Self::measured_path_from_label(path, label)); + tracker.finish(result) } /// Compute the **asymptotic Pareto front** of reduction paths from `source` to /// `target` — the instance-free path search (design doc M3/F3a). /// - /// Runs the generic [Pareto label-setting search](Self::pareto_search) with the + /// Runs the generic [multi-label elementary-path search](Self::pareto_search) with the /// [`GrowthLabel`] domain: no concrete instance is needed, and each returned path /// carries its composed Big-O per target size field (in the source problem's size /// variables), read off the returned label. Because asymptotic growth over several @@ -2006,22 +2167,25 @@ impl ReductionGraph { /// exponent, factorial) are still returned, with those fields marked `Unknown` — /// never a fabricated bound. /// - /// The front reports **one representative path per distinct growth vector**: the - /// asymptotic front is a Pareto set over *growth vectors*, not routes. Many + /// The terminal front reports **one representative path per distinct growth vector**: + /// the asymptotic front is a Pareto set over *growth vectors*, not routes. Many /// syntactically different reduction chains compose to the exact same Big-O per size /// field (e.g. dozens of `MinimumVertexCover → … → ILP` routes all yield /// `num_constraints = O(num_edges), num_vars = O(num_vertices)`); reporting each - /// route would drown the ~1–3 genuinely distinct trade-offs the user cares about. - /// So equal-growth paths are deduplicated ([`GrowthLabel`] derives `PartialEq`), - /// keeping the deterministic best per group: fewest hops, then lexicographic - /// node-name path. Deduplication is purely by the growth vector, so two paths that + /// route would drown the genuinely distinct trade-offs the user cares about. + /// So terminal equality filtering keeps the deterministic best per group: fewest + /// hops, then lexicographic node-name path. Equality is purely by the growth vector, + /// so two paths that /// reach *different* target variants (e.g. `ILP/bool` vs `ILP/i32`) with the same /// composed Big-O collapse to a single representative — the endpoint variant is not /// part of the asymptotic identity. /// /// The front is ordered deterministically by (hops, lexicographic node names), so /// the output is byte-identical across runs and platforms. Returns an empty vector - /// if either endpoint is unregistered or no path exists. + /// if either endpoint is unregistered or no path exists. `Exact` covers every + /// elementary path under the symbolic growth domain; `Approximate` may return a + /// best-so-far front and reports any reached limits. Symbolic exactness is not a + /// statement about concrete constructed target sizes. pub fn asymptotic_front( &self, source: &str, @@ -2029,44 +2193,38 @@ impl ReductionGraph { target: &str, target_variant: &BTreeMap, mode: ReductionMode, - ) -> Vec<(ReductionPath, GrowthLabel)> { + search_mode: SearchMode, + ) -> SearchOutcome> { + let mut tracker = SearchTracker::new(&search_mode); let (Some(src), Some(dst)) = ( self.lookup_node(source, source_variant), self.lookup_node(target, target_variant), ) else { - return vec![]; + return tracker.finish(vec![]); }; let source_fields = self.size_field_names(source); let initial = GrowthLabel::source(&source_fields); - let mut front = self.pareto_search(src, dst, mode, initial, false); - // Order per the issue's contract: (hops, lexicographic node names). The kernel's - // own ordering leads with `cost()`, which is only a search heuristic. Sorting - // first also puts the deterministic best route of each equal-growth group ahead - // of its duplicates, so the dedup below keeps the right representative. + let mut front = self.pareto_search(src, dst, mode, initial, &mut tracker); + // Order per the public contract: (hops, lexicographic node names). The kernel's + // own ordering leads with `cost()`, which is only an agenda heuristic. front.sort_by(|a, b| { a.0.len() .cmp(&b.0.len()) .then_with(|| a.0.type_names().cmp(&b.0.type_names())) }); - // Collapse to one representative per distinct growth vector. `GrowthLabel`'s - // `PartialEq` compares the field → growth map, i.e. the composed Big-O per size - // field; genuinely incomparable vectors are never equal, so they all survive. - // O(n^2), but a front is a handful of entries. - let mut deduped: Vec<(ReductionPath, GrowthLabel)> = Vec::new(); - for entry in front { - if !deduped.iter().any(|(_, label)| *label == entry.1) { - deduped.push(entry); - } - } - deduped + tracker.finish(front) } /// Find the measured-smallest path from `source` to **any** variant of the target /// problem name `target`. /// - /// Runs [`find_measured_best_path`](Self::find_measured_best_path) once per target - /// variant and returns the overall measured-smallest result, with a deterministic - /// tie-break by (measured total size, hops, node-name path). + /// Performs one traversal whose terminal set contains every target variant, so limits, + /// statistics, and constructed prefixes are shared across the whole request. Returns + /// the overall measured-smallest result with a deterministic tie-break by measured + /// total size, hops, and node-name path. Exactness is relative to in-budget elementary + /// paths: the concrete budget is checked after each intermediate is constructed and + /// is not an allocation-safety guarantee. + #[allow(clippy::too_many_arguments)] pub fn find_measured_best_path_to_name( &self, source: &str, @@ -2075,33 +2233,28 @@ impl ReductionGraph { mode: ReductionMode, source_instance: &dyn Any, budget: usize, - ) -> Option { - let mut best: Option = None; - for tv in self.variants_for(target) { - let Some(candidate) = self.find_measured_best_path( - source, - source_variant, - target, - &tv, - mode, - source_instance, - budget, - ) else { - continue; - }; - let better = match &best { - None => true, - Some(cur) => { - let c = (candidate.size.total(), candidate.path.len()); - let b = (cur.size.total(), cur.path.len()); - c < b || (c == b && candidate.path.type_names() < cur.path.type_names()) - } - }; - if better { - best = Some(candidate); - } + search_mode: SearchMode, + ) -> SearchOutcome> { + let mut tracker = SearchTracker::new(&search_mode); + let Some(src) = self.lookup_node(source, source_variant) else { + return tracker.finish(None); + }; + let targets: HashSet = self + .variants_for(target) + .into_iter() + .filter_map(|variant| self.lookup_node(target, &variant)) + .filter(|target_node| *target_node != src) + .collect(); + if targets.is_empty() { + return tracker.finish(None); } - best + + let source_size = Self::compute_source_size(source, source_instance); + let initial = MeasuredLabel::new(source_instance, source_size, budget); + let result = self + .measured_best_simple_path(src, &targets, mode, initial, &mut tracker) + .and_then(|(path, label)| Self::measured_path_from_label(path, label)); + tracker.finish(result) } } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index d66a636ee..a04db9eaf 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -4,6 +4,7 @@ pub mod analysis; pub mod cost; pub mod pareto; pub mod registry; +pub mod search; pub use cost::{ CustomCost, Minimize, MinimizeOutputSize, MinimizeSteps, MinimizeStepsThenOverhead, PathCostFn, }; @@ -410,8 +411,11 @@ pub use graph::{ ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, }; pub use pareto::{ - CostLabel, GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge, BAG_CAP, DEFAULT_SIZE_BUDGET, - HOP_CAP, + CostLabel, GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge, DEFAULT_SIZE_BUDGET, +}; +pub use search::{ + ApproximationPolicy, LimitReached, SearchCompleteness, SearchLimits, SearchMode, SearchOutcome, + SearchStats, }; pub use traits::{ AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index d85a3aff8..7d52ee539 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -1,4 +1,4 @@ -//! Pareto label-setting search over the reduction graph. +//! Multi-label elementary-path search over the reduction graph. //! //! This module replaces the old scalar Dijkstra (`ReductionGraph::dijkstra`) with a //! generic multi-label search. The core motivation (issue #788, design doc @@ -8,18 +8,19 @@ //! label per node, so a cheaper-but-larger intermediate state can poison downstream //! choices — it can miss the path whose *final* target is smallest. //! -//! The fix is the standard algorithm for partial-order path costs — **multi-label -//! Pareto search** (Martins 1984; McRAPTOR-style per-node label bags). Each node keeps -//! an antichain of non-dominated labels (a "bag"); a label is only pruned when another -//! label at the same node dominates it. See [`ReductionGraph::pareto_search`]. +//! The search keeps multiple path states per node and filters the Pareto front only at +//! the destination. Intermediate strict dominance is deliberately forbidden: arbitrary +//! reduction overheads may shrink, subtract, or otherwise reverse an apparent order. +//! The current labels do not carry complete constructed instances, so even equal labels +//! are retained as distinct intermediate states. See [`ReductionGraph::pareto_search`]. //! //! Two search domains are provided: //! - [`CostLabel`]: a scalar formula label that reproduces Dijkstra's behavior for the //! existing `PathCostFn` cost functions (used by `find_cheapest_path*`). It carries the //! accumulated `ProblemSize` (from overhead formulas) and an additive scalar cost. -//! - [`MeasuredLabel`]: concrete-instance state used by a separate exhaustive simple-path -//! search. It *actually executes* each reduction and measures the real constructed target -//! size. Asymptotic overhead formulas are not used as concrete budget bounds. +//! - [`MeasuredLabel`]: concrete-instance state used by a separate simple-path search. It +//! *actually executes* each reduction and measures the real constructed target size. +//! Asymptotic overhead formulas are not used as concrete budget bounds. use crate::expr::Expr; use crate::growth::Growth; @@ -75,13 +76,6 @@ pub(crate) fn catch_reduction(f: impl FnOnce() -> R) -> Option { /// construction itself from exhausting memory. pub const DEFAULT_SIZE_BUDGET: usize = 10_000_000; -/// Maximum number of reduction steps (hops) explored along any path. -pub const HOP_CAP: usize = 16; - -/// Maximum number of non-dominated labels retained per node. On overflow, the bag is -/// truncated by a deterministic tie-break (never by iteration order). -pub const BAG_CAP: usize = 32; - /// A borrowed view of one reduction edge, handed to [`PathLabel::extend`]. /// /// It exposes exactly what a label needs to advance: the overhead formula (for symbolic @@ -101,46 +95,38 @@ pub struct ReductionEdge<'g> { pub target_variant: &'g BTreeMap, } -/// A path cost that composes along reduction edges under a partial order. -/// -/// **Isotonicity invariant (correctness condition for dominance pruning):** if label -/// `A` dominates label `B`, then for any edge `e`, `A.extend(e)` dominates `B.extend(e)` -/// (when both are `Some`). This follows from the monotonicity of overhead / reduction -/// size in the source size. The Pareto search relies on it to safely discard dominated -/// labels. +/// Abstract state carried along a reduction path. /// -/// The kernel prunes by [`dominates`](PathLabel::dominates) alone — it does **not** -/// branch-and-bound on [`cost`](PathLabel::cost). Dominance is exact for every label -/// domain, whereas a scalar B&B bound would only be sound for a monotone `cost`; a label's -/// scalar summary may shrink across an edge or summarize an incomparable growth vector. -/// `cost` is used only for frontier ordering and the deterministic final tie-break. +/// The kernel never prunes or coalesces an intermediate state: the built-in labels do +/// not contain enough information to prove that two constructed problems are identical. +/// Terminal dominance is applied only after a path reaches the destination, where no +/// future extension can reverse the order. [`cost`](PathLabel::cost) is used only for +/// agenda ordering and deterministic result ordering. pub trait PathLabel: Clone { /// Advance this label across `edge`. Returns `None` when a label-domain guard rejects - /// the edge. A `None` must be *isotone*: - /// if `A` dominates `B` and `A.extend(e)` is `None`, that is fine, but a guard must - /// never prune a dominating label while keeping a dominated one. + /// the edge. fn extend(&self, edge: &ReductionEdge) -> Option; - /// Partial order used to keep each node's bag an antichain. Implementations must - /// satisfy the isotonicity invariant above. - fn dominates(&self, other: &Self) -> bool; + /// Weak Pareto order used only to filter completed labels at the destination. + /// + /// Implementations must provide a reflexive and transitive relation. Mutual + /// dominance denotes the same terminal objective vector; the kernel then retains the + /// deterministic best path representative. + fn final_dominates(&self, other: &Self) -> bool; /// Scalar summary used only for frontier ordering and the deterministic final - /// tie-break — never for pruning (the kernel prunes by [`dominates`] alone). Smaller + /// tie-break — never for pruning. Smaller /// is better. It need not be monotone along `extend`. /// - /// [`dominates`]: PathLabel::dominates fn cost(&self) -> f64; } /// Formula-based label for a [`PathCostFn`]. /// /// Carries the accumulated `ProblemSize` (advanced through overhead formulas) and the -/// additive scalar cost. Because a future edge's [`edge_cost`](PathCostFn::edge_cost) -/// depends on the carried size, dominance is **componentwise Pareto over `(cost, size)`**, -/// not scalar: a cheaper-but-larger prefix must not evict a costlier-but-smaller one whose -/// continuation is globally cheapest. Each node therefore keeps the antichain of -/// non-dominated `(cost, size)` labels rather than a single minimum-cost representative. +/// additive scalar cost. Neither value identifies the actual constructed problem, so +/// equal or componentwise-better labels are never used to remove an intermediate path. +/// Componentwise Pareto order over `(cost, size)` is used only at the destination. pub struct CostLabel<'c, C: PathCostFn> { size: ProblemSize, cost: f64, @@ -180,11 +166,7 @@ impl PathLabel for CostLabel<'_, C> { }) } - fn dominates(&self, other: &Self) -> bool { - // Path-dependent costs: a future edge's `edge_cost` depends on the carried size, - // so `self` may only evict `other` when it is componentwise no worse in BOTH the - // accumulated cost and the carried size. Scalar `cost <= other.cost` alone would - // let a cheap-but-large prefix evict the globally optimal small one. + fn final_dominates(&self, other: &Self) -> bool { self.cost <= other.cost && size_le(&self.size, &other.size) } @@ -200,7 +182,14 @@ enum MeasuredPos<'a> { Source(&'a dyn Any), /// At a reduced node: the last reduction step's result. The current problem instance /// is `result.target_problem_any()`. - Reduced(Rc), + Reduced(Rc), +} + +/// One persistent reduction-chain link. Sharing predecessors makes label extension O(1) +/// in path depth while keeping every constructed intermediate alive as long as needed. +struct MeasuredStep { + result: Rc, + previous: Option>, } /// The concrete-instance measured label (design doc M3/F3b). @@ -212,22 +201,20 @@ enum MeasuredPos<'a> { /// /// 1. **Execute + measure:** run `reduce_to()`, measure the real target size; over budget /// → `None`. -/// 2. **No comparative pruning:** measured states are enumerated by a separate exhaustive +/// 2. **No comparative pruning:** measured states are enumerated by a separate /// simple-path search. Neither size vectors nor serialized representations discard a -/// constructed route before its downstream reductions are measured, and Pareto bag/hop -/// caps do not apply. +/// constructed route before its downstream reductions are measured. Exact mode has no +/// search caps; approximate mode applies only its explicit reported limits. /// /// **Memory.** The budget is checked only after a reduction has constructed its target, /// so it cannot prevent a reduction itself from exhausting memory. It limits which -/// constructed instances remain eligible for further search. Exhaustive simple-path -/// enumeration can take exponential time and retain large constructed chains. +/// constructed instances remain eligible for further search. Exact simple-path +/// enumeration can take exponential time; persistent chain links release completed +/// branches instead of copying every prefix. #[derive(Clone)] pub struct MeasuredLabel<'a> { /// Measured size of the problem instance at the current node. size: ProblemSize, - /// The reduction steps executed so far (empty at the source). Shared via `Rc` so - /// cloning a label is cheap and never re-executes a reduction. - chain: Vec>, /// Current constructed position. pos: MeasuredPos<'a>, /// Hard total-size budget. @@ -242,15 +229,24 @@ impl<'a> MeasuredLabel<'a> { pub fn new(source: &'a dyn Any, source_size: ProblemSize, budget: usize) -> Self { Self { size: source_size, - chain: Vec::new(), pos: MeasuredPos::Source(source), budget, } } - /// The reduction chain executed to reach this label (one entry per hop). - pub(crate) fn chain(&self) -> &[Rc] { - &self.chain + /// Reconstruct the reduction chain executed to reach this label. + pub(crate) fn chain(&self) -> Vec> { + let mut chain = Vec::new(); + let mut step = match &self.pos { + MeasuredPos::Source(_) => None, + MeasuredPos::Reduced(step) => Some(Rc::clone(step)), + }; + while let Some(current) = step { + chain.push(Rc::clone(¤t.result)); + step = current.previous.as_ref().map(Rc::clone); + } + chain.reverse(); + chain } /// The measured problem size at this label's node. @@ -270,7 +266,7 @@ impl<'a> MeasuredLabel<'a> { let reduce_fn = edge.reduce_fn?; let current: &dyn Any = match &self.pos { MeasuredPos::Source(s) => *s, - MeasuredPos::Reduced(r) => r.target_problem_any(), + MeasuredPos::Reduced(step) => step.result.target_problem_any(), }; let target_name = edge.target_name; let (result, measured) = catch_reduction(|| { @@ -285,12 +281,14 @@ impl<'a> MeasuredLabel<'a> { return None; } - let mut chain = self.chain.clone(); - chain.push(result.clone()); + let previous = match &self.pos { + MeasuredPos::Source(_) => None, + MeasuredPos::Reduced(step) => Some(Rc::clone(step)), + }; + let step = Rc::new(MeasuredStep { result, previous }); Some(Self { size: measured, - chain, - pos: MeasuredPos::Reduced(result), + pos: MeasuredPos::Reduced(step), budget: self.budget, }) } @@ -321,17 +319,13 @@ fn size_le(a: &ProblemSize, b: &ProblemSize) -> bool { /// exponent, factorial) has no `Expr`; any target field depending on it becomes /// `Unknown` too — the bound is never fabricated. /// -/// [`dominates`](PathLabel::dominates) is componentwise in the **search** sense -/// (smaller growth = better): `self` dominates `other` iff for *every* field `self` -/// grows no faster than `other`, and strictly slower on at least one. Because +/// [`final_dominates`](PathLabel::final_dominates) is componentwise in the **search** +/// sense (smaller growth = better): `self` terminally dominates `other` iff for every field +/// `self` grows no faster than `other`. It is used only at the destination. Because /// `Unknown` is the top of the growth order, a label with an `Unknown` field is /// dominated by any fully-known label — undecidable paths rank last, the honest /// ranking. /// -/// **Isotonicity** (the correctness condition for the kernel's dominance pruning) -/// follows from the growth domain's monotonicity axiom: `from_expr` composed with -/// substitution into weakly-monotone overhead expressions preserves the growth -/// order, so `A ⪰ B ⇒ extend(A,e) ⪰ extend(B,e)`. #[derive(Clone, Debug, PartialEq)] pub struct GrowthLabel { /// Current node's size fields → growth in the source problem's variables. @@ -403,15 +397,14 @@ impl PathLabel for GrowthLabel { Some(GrowthLabel { fields: new_fields }) } - fn dominates(&self, other: &Self) -> bool { - // Search-sense componentwise dominance over the union of fields (labels - // compared are at the same node, so their field sets coincide; the union is - // defensive). `self` dominates `other` iff `self` grows no faster on every - // field and strictly slower on at least one. + fn final_dominates(&self, other: &Self) -> bool { + // Search-sense componentwise terminal dominance over the union of fields + // (labels compared are at the same node, so their field sets coincide; the + // union is defensive). Equality counts so the terminal front has one + // deterministic representative per growth vector. // // `Growth::dominates(a, b)` means "a grows ≥ b", with `Unknown` as top. So: // self ≤ other on field f ⟺ other_f.dominates(self_f) - // and self is strictly better on f iff additionally NOT self_f.dominates(other_f). let o1 = Growth::Terms(Vec::new()); // O(1): the bottom, for absent fields. let keys: BTreeSet<&'static str> = self .fields @@ -419,7 +412,6 @@ impl PathLabel for GrowthLabel { .chain(other.fields.keys()) .copied() .collect(); - let mut strict = false; for k in keys { let s = self.fields.get(k).unwrap_or(&o1); let o = other.fields.get(k).unwrap_or(&o1); @@ -427,20 +419,14 @@ impl PathLabel for GrowthLabel { // self grows strictly faster than other here → self does not dominate. return false; } - if !s.dominates(o) { - // other ≥ self but self ⋡ other ⇒ self strictly slower on this field. - strict = true; - } } - strict + true } fn cost(&self) -> f64 { // Heuristic scalar summary for frontier ordering and the deterministic final - // tie-break ONLY — never for pruning (dominance is the exact partial order, and - // asymptotic growth is incomparable so no scalar bound could separate front - // members). Summed field magnitudes; `Unknown` fields dominate the sum, ranking - // undecidable paths last. + // tie-break ONLY — never for intermediate pruning. Summed field magnitudes; + // `Unknown` fields dominate the sum, ranking undecidable paths last. self.fields.values().map(|g| g.magnitude()).sum() } } diff --git a/src/rules/search.rs b/src/rules/search.rs new file mode 100644 index 000000000..2a83d2350 --- /dev/null +++ b/src/rules/search.rs @@ -0,0 +1,222 @@ +//! Completeness policy and accounting for reduction-path search. + +use serde::Serialize; +use std::collections::BTreeSet; +use std::time::{Duration, Instant}; + +/// Whether a path search must be complete or may use explicit resource limits. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SearchMode { + /// Search every elementary path allowed by the selected label semantics. + Exact, + /// Return valid best-so-far results under an approximation policy. + Approximate(ApproximationPolicy), +} + +/// Policy used by an approximate search. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ApproximationPolicy { + /// Deterministic count limits and/or a wall-clock timeout. + Bounded(SearchLimits), +} + +/// Optional limits for bounded approximate search. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SearchLimits { + /// Maximum number of edges in an explored path. + pub max_hops: Option, + /// Maximum number of live labels retained at one graph node. + pub max_labels_per_node: Option, + /// Maximum number of states whose outgoing edges are expanded. + pub max_expanded_states: Option, + /// Wall-clock duration checked between state expansions. + pub timeout: Option, +} + +impl SearchLimits { + /// Legacy interactive bounds, now made explicit at the caller boundary. + pub fn interactive() -> Self { + Self { + max_hops: Some(16), + max_labels_per_node: Some(32), + max_expanded_states: None, + timeout: None, + } + } +} + +/// A resource limit that made a search incomplete. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LimitReached { + HopLimit, + LabelsPerNodeLimit, + ExpandedStatesLimit, + Timeout, +} + +/// Whether the returned value is complete for the declared search semantics. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SearchCompleteness { + Exact, + Approximate { reasons: BTreeSet }, +} + +impl SearchCompleteness { + /// Whether no configured approximation limit affected exploration. + pub fn is_exact(&self) -> bool { + matches!(self, Self::Exact) + } + + /// Limits that affected exploration, empty for an exact outcome. + pub fn reasons(&self) -> BTreeSet { + match self { + Self::Exact => BTreeSet::new(), + Self::Approximate { reasons } => reasons.clone(), + } + } +} + +/// Search work and pruning statistics. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +pub struct SearchStats { + /// Initial and successfully extended states created by the search. + pub generated_states: usize, + /// States whose outgoing edges were examined. + pub expanded_states: usize, + /// Completed target states removed by terminal Pareto dominance. + pub dominated_states: usize, + /// Label extensions rejected by domain feasibility checks. + pub infeasible_extensions: usize, + /// Largest number of simultaneously retained states at one node. Exact DFS retains + /// at most the current branch; bounded search may retain a per-node candidate bag. + pub peak_labels_per_node: usize, + /// Elapsed wall-clock time for the whole public request. + /// + /// This diagnostic is intentionally omitted from serialized output so + /// count-limited responses remain byte-stable across runs and platforms. + #[serde(skip_serializing)] + pub elapsed: Duration, +} + +/// A search value together with its completeness guarantee and work statistics. +#[must_use] +#[derive(Debug)] +pub struct SearchOutcome { + /// Complete result or valid best-so-far result. + pub value: T, + /// Whether configured limits affected the explored search space. + pub completeness: SearchCompleteness, + /// Work performed across the whole request. + pub stats: SearchStats, +} + +/// Per-request mutable accounting shared by all traversals for that request. +pub(crate) struct SearchTracker { + limits: Option, + reached: BTreeSet, + stats: SearchStats, + started: Instant, +} + +impl SearchTracker { + pub(crate) fn new(mode: &SearchMode) -> Self { + let limits = match mode { + SearchMode::Exact => None, + SearchMode::Approximate(ApproximationPolicy::Bounded(limits)) => Some(limits.clone()), + }; + Self { + limits, + reached: BTreeSet::new(), + stats: SearchStats::default(), + started: Instant::now(), + } + } + + pub(crate) fn record_generated(&mut self) { + self.stats.generated_states += 1; + } + + pub(crate) fn is_exact_mode(&self) -> bool { + self.limits.is_none() + } + + pub(crate) fn record_expanded(&mut self) { + self.stats.expanded_states += 1; + } + + pub(crate) fn record_dominated(&mut self, count: usize) { + self.stats.dominated_states += count; + } + + pub(crate) fn record_infeasible(&mut self) { + self.stats.infeasible_extensions += 1; + } + + pub(crate) fn observe_bag(&mut self, size: usize) { + self.stats.peak_labels_per_node = self.stats.peak_labels_per_node.max(size); + } + + pub(crate) fn reach(&mut self, reason: LimitReached) { + self.reached.insert(reason); + } + + pub(crate) fn hop_limited(&mut self, hops: usize) -> bool { + let limited = self + .limits + .as_ref() + .and_then(|limits| limits.max_hops) + .is_some_and(|limit| hops >= limit); + if limited { + self.reach(LimitReached::HopLimit); + } + limited + } + + pub(crate) fn expansion_limited(&mut self) -> bool { + let limited = self + .limits + .as_ref() + .and_then(|limits| limits.max_expanded_states) + .is_some_and(|limit| self.stats.expanded_states >= limit); + if limited { + self.reach(LimitReached::ExpandedStatesLimit); + } + limited + } + + pub(crate) fn timed_out(&mut self) -> bool { + let timed_out = self + .limits + .as_ref() + .and_then(|limits| limits.timeout) + .is_some_and(|timeout| self.started.elapsed() >= timeout); + if timed_out { + self.reach(LimitReached::Timeout); + } + timed_out + } + + pub(crate) fn label_limit(&self) -> Option { + self.limits + .as_ref() + .and_then(|limits| limits.max_labels_per_node) + } + + pub(crate) fn finish(mut self, value: T) -> SearchOutcome { + self.stats.elapsed = self.started.elapsed(); + let completeness = if self.reached.is_empty() { + SearchCompleteness::Exact + } else { + SearchCompleteness::Approximate { + reasons: self.reached, + } + }; + SearchOutcome { + value, + completeness, + stats: self.stats, + } + } +} diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 08ebbacb6..02a3a0afa 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -257,15 +257,22 @@ impl ILPSolver { .variants_for("ILP") .into_iter() .filter_map(|target_variant| { - graph.find_cheapest_path_mode( - name, - variant, - "ILP", - &target_variant, - ReductionMode::Witness, - &input_size, - &crate::rules::MinimizeSteps, - ) + graph + .find_cheapest_path_mode( + name, + variant, + "ILP", + &target_variant, + ReductionMode::Witness, + &input_size, + &crate::rules::MinimizeSteps, + crate::rules::SearchMode::Approximate( + crate::rules::ApproximationPolicy::Bounded( + crate::rules::SearchLimits::interactive(), + ), + ), + ) + .value }) .collect(); candidates.sort_by(|a, b| { @@ -312,14 +319,18 @@ impl ILPSolver { // A preferred shortest path can be instance-infeasible even when another route // works. Fall back to the uncapped, execution-aware measured enumeration before // reporting that no witness path exists. - if let Some(measured) = graph.find_measured_best_path_to_name( - name, - variant, - "ILP", - ReductionMode::Witness, - instance, - crate::rules::DEFAULT_SIZE_BUDGET, - ) { + if let Some(measured) = graph + .find_measured_best_path_to_name( + name, + variant, + "ILP", + ReductionMode::Witness, + instance, + crate::rules::DEFAULT_SIZE_BUDGET, + crate::rules::SearchMode::Exact, + ) + .value + { let ilp_solution = self .solve_dyn(measured.target_problem_any()) .ok_or_else(|| SolveViaReductionError::NoSolution { @@ -359,7 +370,9 @@ impl ILPSolver { ReductionMode::Aggregate, &input_size, &crate::rules::MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_some() }) } diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 43dc121f4..45a3d6b24 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -586,24 +586,30 @@ fn rule_specs_solution_pairs_are_consistent() { // Try witness path first; fall back to aggregate for aggregate-only edges. // Some authored direct reductions are proof-only and intentionally have // no runtime capability in any mode. - let witness_path = graph.find_cheapest_path( - &example.source.problem, - &example.source.variant, - &example.target.problem, - &example.target.variant, - &crate::types::ProblemSize::new(vec![]), - &crate::rules::MinimizeSteps, - ); - if witness_path.is_none() { - let aggregate_path = graph.find_cheapest_path_mode( + let witness_path = graph + .find_cheapest_path( &example.source.problem, &example.source.variant, &example.target.problem, &example.target.variant, - crate::rules::ReductionMode::Aggregate, &crate::types::ProblemSize::new(vec![]), &crate::rules::MinimizeSteps, - ); + crate::rules::SearchMode::Exact, + ) + .value; + if witness_path.is_none() { + let aggregate_path = graph + .find_cheapest_path_mode( + &example.source.problem, + &example.source.variant, + &example.target.problem, + &example.target.variant, + crate::rules::ReductionMode::Aggregate, + &crate::types::ProblemSize::new(vec![]), + &crate::rules::MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; if aggregate_path.is_none() { assert!( graph.has_direct_reduction_by_name(&example.source.problem, &example.target.problem), diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 88454a153..6569f08c6 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -59,14 +59,17 @@ fn test_find_path_with_cost_function() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &input_size, - &MinimizeSteps, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "MinimumVertexCover", + &dst, + &input_size, + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_some(), "Should find path from IS to VC"); let path = path.unwrap(); @@ -82,14 +85,17 @@ fn test_multi_step_path() { // Factoring -> CircuitSAT -> SpinGlass is a 2-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_cheapest_path( - "Factoring", - &src, - "SpinGlass", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let path = graph + .find_cheapest_path( + "Factoring", + &src, + "SpinGlass", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!( path.is_some(), @@ -118,7 +124,9 @@ fn aggregate_mode_rejects_witness_only_real_edge() { ReductionMode::Witness, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_some()); assert!(graph .find_cheapest_path_mode( @@ -129,7 +137,9 @@ fn aggregate_mode_rejects_witness_only_real_edge() { ReductionMode::Aggregate, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_none()); } @@ -150,7 +160,9 @@ fn natural_edge_supports_both_modes_public_api() { ReductionMode::Witness, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_some()); assert!(graph .find_cheapest_path_mode( @@ -161,7 +173,9 @@ fn natural_edge_supports_both_modes_public_api() { ReductionMode::Aggregate, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_some()); } @@ -173,28 +187,34 @@ fn test_problem_size_propagation() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &input_size, - &MinimizeSteps, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "MinimumVertexCover", + &dst, + &input_size, + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_some()); let src2 = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst2 = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); - let path2 = graph.find_cheapest_path( - "MaximumIndependentSet", - &src2, - "MaximumSetPacking", - &dst2, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let path2 = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src2, + "MaximumSetPacking", + &dst2, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path2.is_some()); } @@ -293,14 +313,17 @@ fn test_find_indirect_path() { let paths = graph.find_all_paths("MaximumSetPacking", &src, "MinimumVertexCover", &dst); assert!(!paths.is_empty()); - let shortest = graph.find_cheapest_path( - "MaximumSetPacking", - &src, - "MinimumVertexCover", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let shortest = graph + .find_cheapest_path( + "MaximumSetPacking", + &src, + "MinimumVertexCover", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(shortest.is_some()); assert_eq!(shortest.unwrap().len(), 2); } @@ -330,7 +353,9 @@ fn test_reduction_path_display() { &dst_var, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .unwrap(); let s = format!("{path}"); @@ -386,7 +411,9 @@ fn test_3sat_to_mis_triangular_overhead() { &dst_var, &input_size, &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path from 3-SAT to MIS on triangular lattice"); // Path: K3SAT → KN_SAT (cast) → SAT → MIS{SimpleGraph,One} → MIS{TriangularSubgraph,i32} diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index e915ac482..1914b9bd6 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -359,7 +359,9 @@ fn witness_path_search_rejects_aggregate_only_edge() { ReductionMode::Witness, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_none()); assert!(graph .find_cheapest_path_mode( @@ -370,7 +372,9 @@ fn witness_path_search_rejects_aggregate_only_edge() { ReductionMode::Aggregate, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_some()); } @@ -400,7 +404,9 @@ fn aggregate_path_search_rejects_witness_only_edge() { ReductionMode::Aggregate, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_none()); assert!(graph .find_cheapest_path_mode( @@ -411,7 +417,9 @@ fn aggregate_path_search_rejects_witness_only_edge() { ReductionMode::Witness, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_some()); } @@ -432,24 +440,30 @@ fn natural_edge_supports_both_modes() { }, ); - let witness_path = graph.find_cheapest_path_mode( - NaturalVariantProblem::NAME, - &source_variant, - NaturalVariantProblem::NAME, - &target_variant, - ReductionMode::Witness, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); - let aggregate_path = graph.find_cheapest_path_mode( - NaturalVariantProblem::NAME, - &source_variant, - NaturalVariantProblem::NAME, - &target_variant, - ReductionMode::Aggregate, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let witness_path = graph + .find_cheapest_path_mode( + NaturalVariantProblem::NAME, + &source_variant, + NaturalVariantProblem::NAME, + &target_variant, + ReductionMode::Witness, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; + let aggregate_path = graph + .find_cheapest_path_mode( + NaturalVariantProblem::NAME, + &source_variant, + NaturalVariantProblem::NAME, + &target_variant, + ReductionMode::Aggregate, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(witness_path.is_some()); let aggregate_path = aggregate_path.expect("expected aggregate path"); @@ -532,14 +546,17 @@ fn test_find_shortest_path() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MaximumSetPacking", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "MaximumSetPacking", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_some()); let path = path.unwrap(); assert_eq!(path.len(), 1); // Direct path exists @@ -550,14 +567,17 @@ fn test_knapsack_to_ilp_path_exists() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&Knapsack::variant()); let dst = ReductionGraph::variant_to_map(&ILP::::variant()); - let path = graph.find_cheapest_path( - "Knapsack", - &src, - "ILP", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let path = graph + .find_cheapest_path( + "Knapsack", + &src, + "ILP", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; let path = path.expect("Knapsack should reduce to ILP"); assert_eq!( @@ -580,14 +600,17 @@ fn test_is_to_qubo_path() { let graph = ReductionGraph::new(); let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&QUBO::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "QUBO", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "QUBO", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_some()); let path = path.unwrap(); assert!( @@ -634,14 +657,17 @@ fn test_find_shortest_path_variants() { let dst = ReductionGraph::variant_to_map( &crate::models::graph::SpinGlass::::variant(), ); - let shortest = graph.find_cheapest_path( - "MaxCut", - &src, - "SpinGlass", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let shortest = graph + .find_cheapest_path( + "MaxCut", + &src, + "SpinGlass", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(shortest.is_some()); assert_eq!(shortest.unwrap().len(), 1); // Direct path @@ -649,14 +675,17 @@ fn test_find_shortest_path_variants() { let dst = ReductionGraph::variant_to_map( &crate::models::graph::SpinGlass::::variant(), ); - let shortest = graph.find_cheapest_path( - "Factoring", - &src, - "SpinGlass", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let shortest = graph + .find_cheapest_path( + "Factoring", + &src, + "SpinGlass", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!(shortest.is_some()); assert_eq!(shortest.unwrap().len(), 2); // Factoring -> CircuitSAT -> SpinGlass } @@ -692,7 +721,9 @@ fn test_reduction_path_methods() { &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .unwrap(); assert!(!path.is_empty()); @@ -843,7 +874,9 @@ fn test_circuit_reductions() { &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .unwrap(); assert_eq!(shortest.len(), 2); // Factoring -> CircuitSAT -> SpinGlass } @@ -1005,8 +1038,10 @@ fn test_unknown_name_returns_empty() { "MaximumIndependentSet", &is_var, &ProblemSize::new(vec![]), - &MinimizeSteps + &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .is_none()); } @@ -1058,14 +1093,17 @@ fn test_circuitsat_to_satisfiability_direct_edge() { assert!(graph.has_direct_reduction_by_name("CircuitSAT", "Satisfiability")); - let path = graph.find_cheapest_path( - "CircuitSAT", - &src, - "Satisfiability", - &dst, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let path = graph + .find_cheapest_path( + "CircuitSAT", + &src, + "Satisfiability", + &dst, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!( path.is_some(), "CircuitSAT -> Satisfiability path should exist" @@ -1214,14 +1252,17 @@ fn test_find_cheapest_path_minimize_steps() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MinimumVertexCover", - &dst, - &input_size, - &cost_fn, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "MinimumVertexCover", + &dst, + &input_size, + &cost_fn, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_some()); let path = path.unwrap(); @@ -1236,14 +1277,17 @@ fn test_find_cheapest_path_multi_step() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "MaximumSetPacking", - &dst, - &input_size, - &cost_fn, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "MaximumSetPacking", + &dst, + &input_size, + &cost_fn, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_some()); let path = path.unwrap(); @@ -1258,14 +1302,17 @@ fn test_find_cheapest_path_is_to_qubo() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst = ReductionGraph::variant_to_map(&QUBO::::variant()); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "QUBO", - &dst, - &input_size, - &cost_fn, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "QUBO", + &dst, + &input_size, + &cost_fn, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_some()); let path = path.unwrap(); @@ -1287,14 +1334,17 @@ fn test_find_cheapest_path_unknown_source() { let unknown = BTreeMap::new(); let dst = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let path = graph.find_cheapest_path( - "UnknownProblem", - &unknown, - "MinimumVertexCover", - &dst, - &input_size, - &cost_fn, - ); + let path = graph + .find_cheapest_path( + "UnknownProblem", + &unknown, + "MinimumVertexCover", + &dst, + &input_size, + &cost_fn, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_none()); } @@ -1307,14 +1357,17 @@ fn test_find_cheapest_path_unknown_target() { let src = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let unknown = BTreeMap::new(); - let path = graph.find_cheapest_path( - "MaximumIndependentSet", - &src, - "UnknownProblem", - &unknown, - &input_size, - &cost_fn, - ); + let path = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src, + "UnknownProblem", + &unknown, + &input_size, + &cost_fn, + crate::rules::SearchMode::Exact, + ) + .value; assert!(path.is_none()); } @@ -1353,7 +1406,9 @@ fn test_reduce_along_path_direct() { &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .unwrap(); // Just verify the path can produce a chain with a dummy source let source = MaximumIndependentSet::new( @@ -1380,7 +1435,9 @@ fn test_reduction_chain_direct() { &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .unwrap(); let problem = MaximumIndependentSet::new( @@ -1415,7 +1472,9 @@ fn test_reduction_chain_multi_step() { &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .unwrap(); let problem = MaximumIndependentSet::new( @@ -1451,14 +1510,17 @@ fn test_reduction_chain_with_variant_casts() { ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); let dst_var = ReductionGraph::variant_to_map(&MinimumVertexCover::::variant()); - let rpath = graph.find_cheapest_path( - "MaximumIndependentSet", - &src_var, - "MinimumVertexCover", - &dst_var, - &ProblemSize::new(vec![]), - &MinimizeSteps, - ); + let rpath = graph + .find_cheapest_path( + "MaximumIndependentSet", + &src_var, + "MinimumVertexCover", + &dst_var, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!( rpath.is_some(), "Should find path from MIS to MVC via variant cast" @@ -1491,14 +1553,17 @@ fn test_reduction_chain_with_variant_casts() { ReductionGraph::variant_to_map(&KSatisfiability::::variant()); let ksat_dst = ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); - let ksat_rpath = graph.find_cheapest_path( - "KSatisfiability", - &ksat_src, - "MaximumIndependentSet", - &ksat_dst, - &crate::types::ProblemSize::new(vec![]), - &crate::rules::MinimizeSteps, - ); + let ksat_rpath = graph + .find_cheapest_path( + "KSatisfiability", + &ksat_src, + "MaximumIndependentSet", + &ksat_dst, + &crate::types::ProblemSize::new(vec![]), + &crate::rules::MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value; assert!( ksat_rpath.is_some(), "Should find path from KSat to MIS" @@ -1674,7 +1739,9 @@ fn test_evaluate_path_overhead() { &dst, &input_size, &MinimizeStepsThenOverhead, + crate::rules::SearchMode::Exact, ) + .value .expect("should find path"); let final_size = graph @@ -1709,7 +1776,9 @@ fn test_evaluate_path_overhead_multistep() { ReductionMode::Witness, &input_size, &MinimizeStepsThenOverhead, + crate::rules::SearchMode::Exact, ) + .value .expect("should find path"); assert!( diff --git a/src/unit_tests/rules/maximumindependentset_ilp.rs b/src/unit_tests/rules/maximumindependentset_ilp.rs index ce3c165c5..f2af01601 100644 --- a/src/unit_tests/rules/maximumindependentset_ilp.rs +++ b/src/unit_tests/rules/maximumindependentset_ilp.rs @@ -20,7 +20,9 @@ fn reduce_mis_to_ilp( &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path MaximumIndependentSet -> ILP"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) diff --git a/src/unit_tests/rules/maximumindependentset_qubo.rs b/src/unit_tests/rules/maximumindependentset_qubo.rs index 1e297ba80..2d8ecfd9b 100644 --- a/src/unit_tests/rules/maximumindependentset_qubo.rs +++ b/src/unit_tests/rules/maximumindependentset_qubo.rs @@ -23,7 +23,9 @@ fn reduce_mis_to_qubo( ("num_edges", problem.graph().num_edges()), ]), &Minimize("num_vars"), + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path MaximumIndependentSet -> QUBO"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) diff --git a/src/unit_tests/rules/minimumvertexcover_ilp.rs b/src/unit_tests/rules/minimumvertexcover_ilp.rs index 736072a62..9f2810255 100644 --- a/src/unit_tests/rules/minimumvertexcover_ilp.rs +++ b/src/unit_tests/rules/minimumvertexcover_ilp.rs @@ -20,7 +20,9 @@ fn reduce_vc_to_ilp( &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path MinimumVertexCover -> ILP"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) diff --git a/src/unit_tests/rules/minimumvertexcover_qubo.rs b/src/unit_tests/rules/minimumvertexcover_qubo.rs index 8b4dd1711..c610e3ced 100644 --- a/src/unit_tests/rules/minimumvertexcover_qubo.rs +++ b/src/unit_tests/rules/minimumvertexcover_qubo.rs @@ -23,7 +23,9 @@ fn reduce_vc_to_qubo( ("num_edges", problem.graph().num_edges()), ]), &Minimize("num_vars"), + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path MinimumVertexCover -> QUBO"); let chain = graph .reduce_along_path(&path, problem as &dyn std::any::Any) diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 1fc843570..19c19a914 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -1,4 +1,4 @@ -//! Tests for the Pareto label-setting search (`src/rules/pareto.rs`) and its two label +//! Tests for the multi-label elementary-path search (`src/rules/pareto.rs`) and its two label //! domains. Covers: //! - The measured concrete-instance search (issue #788 known-answer and budget semantics). //! - The generic kernel's correctness on a hand-built diamond (negative control): a @@ -168,7 +168,9 @@ fn test_hamiltoniancircuit_to_ilp_measured_optimum_788() { ReductionMode::Witness, &hc as &dyn Any, 1_000, + crate::rules::SearchMode::Exact, ) + .value .expect("a measured witness path from HamiltonianCircuit to ILP"); // Measured final ILP size is the current-graph optimum. @@ -193,6 +195,33 @@ fn test_hamiltoniancircuit_to_ilp_measured_optimum_788() { assert_eq!(ilp.num_vars, 105); } +#[test] +fn test_measured_any_target_uses_one_request_limit_tracker() { + let hc = prism_hamiltonian_circuit(); + let graph = ReductionGraph::new(); + let variant = ReductionGraph::variant_to_map(&[("graph", "SimpleGraph")]); + let outcome = graph.find_measured_best_path_to_name( + "HamiltonianCircuit", + &variant, + "ILP", + ReductionMode::Witness, + &hc as &dyn Any, + 1_000, + crate::rules::SearchMode::Approximate(crate::rules::ApproximationPolicy::Bounded( + crate::rules::SearchLimits { + max_expanded_states: Some(1), + ..Default::default() + }, + )), + ); + + assert_eq!(outcome.stats.expanded_states, 1); + assert!(outcome + .completeness + .reasons() + .contains(&crate::rules::LimitReached::ExpandedStatesLimit)); +} + // --------------------------------------------------------------------------- // Verification 2: measured search does not discard equal-size concrete states. // --------------------------------------------------------------------------- @@ -255,7 +284,9 @@ fn test_measured_search_keeps_equal_size_structure_dependent_instances() { ReductionMode::Witness, &source, 1_000, + crate::rules::SearchMode::Exact, ) + .value .expect("the structure-dependent small continuation must survive"); assert_eq!( @@ -287,7 +318,9 @@ fn test_asymptotic_overhead_is_not_a_concrete_budget_guard() { ReductionMode::Witness, &source, 1, + crate::rules::SearchMode::Exact, ) + .value .expect("a loose asymptotic expression must not prune an actually in-budget target"); assert_eq!(measured.size.total(), 1); @@ -298,10 +331,8 @@ fn test_asymptotic_overhead_is_not_a_concrete_budget_guard() { // --------------------------------------------------------------------------- /// A test label whose objective is the *final* measured size `s`, while carrying a -/// separate accumulated step cost `c`. Dominance is componentwise Pareto over `(c, s)`, -/// so two labels that trade off `c` against `s` are incomparable and both survive — the -/// exact structure a scalar Dijkstra collapses (keeping only the min-`c` label, and thus -/// its `s`). +/// separate accumulated step cost `c`. All intermediate labels survive; componentwise +/// Pareto order over `(c, s)` is applied only to completed paths. #[derive(Clone)] struct DiamondLabel { /// Accumulated step cost. @@ -331,7 +362,7 @@ impl PathLabel for DiamondLabel { }) } - fn dominates(&self, other: &Self) -> bool { + fn final_dominates(&self, other: &Self) -> bool { self.c <= other.c && self.s <= other.s } @@ -382,7 +413,9 @@ fn test_negative_control_diamond_pareto_beats_scalar() { &CustomCost(|oh: &ReductionOverhead, sz: &ProblemSize| { oh.get("c").map(|e| e.eval(sz)).unwrap_or(0.0) }), + crate::rules::SearchMode::Exact, ) + .value .expect("scalar path S -> T"); assert_eq!( scalar.type_names(), @@ -392,15 +425,17 @@ fn test_negative_control_diamond_pareto_beats_scalar() { // (b) The measured Pareto search returns P2 (strictly smaller final size). let initial = DiamondLabel { c: 0.0, s: 0.0 }; - let front = graph.pareto_search_by_name( - "S", - &empty, - "T", - &empty, - ReductionMode::Witness, - initial, - false, - ); + let front = graph + .pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + crate::rules::SearchMode::Exact, + ) + .value; assert!(!front.is_empty(), "front should reach T"); let (best_path, best_label) = &front[0]; assert_eq!( @@ -411,11 +446,10 @@ fn test_negative_control_diamond_pareto_beats_scalar() { assert_eq!(best_label.cost(), 6.0, "P2's final measured size is 6"); } -/// The `exhaustive` flag disables only the heuristic componentwise-dominance guard; the -/// front still contains the true optimum. On the diamond, both routes into M survive -/// regardless, so the answer is unchanged. +/// Exact multi-label search retains both routes into M and returns the true optimum on +/// the negative-control diamond. #[test] -fn test_diamond_exhaustive_matches_pruned() { +fn test_diamond_exact_multi_label_keeps_optimum() { let empty = std::collections::BTreeMap::new(); let graph = ReductionGraph::from_test_edges( &["S", "M", "P", "T"], @@ -426,15 +460,17 @@ fn test_diamond_exhaustive_matches_pruned() { ("M", "T", diamond_edge(1.0, Expr::Var("s"))), ], ); - let front = graph.pareto_search_by_name( - "S", - &empty, - "T", - &empty, - ReductionMode::Witness, - DiamondLabel { c: 0.0, s: 0.0 }, - true, - ); + let front = graph + .pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + DiamondLabel { c: 0.0, s: 0.0 }, + crate::rules::SearchMode::Exact, + ) + .value; assert_eq!(front[0].0.type_names(), vec!["S", "P", "M", "T"]); assert_eq!(front[0].1.cost(), 6.0); } @@ -555,14 +591,14 @@ fn test_growth_label_unknown_ranks_last() { m }); // Known is strictly better on field b (n^0? no: bounded vs Unknown) ⇒ known dominates. - assert!(known.dominates(&with_unknown)); - assert!(!with_unknown.dominates(&known)); + assert!(known.final_dominates(&with_unknown)); + assert!(!with_unknown.final_dominates(&known)); } -/// Componentwise search-sense dominance: `self` dominates `other` iff it grows no -/// faster on every field and strictly slower on at least one. +/// Componentwise terminal dominance: `self` dominates `other` iff it grows no faster on +/// every field, including equality. #[test] -fn test_growth_label_dominance_partial_order() { +fn test_growth_label_terminal_dominance_partial_order() { let a = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); m.insert("v", Growth::from_expr(&Expr::Var("n"))); // n @@ -576,10 +612,9 @@ fn test_growth_label_dominance_partial_order() { m }); // a (n, m) grows slower in v, equal in e ⇒ a dominates b; b does not dominate a. - assert!(a.dominates(&b)); - assert!(!b.dominates(&a)); - // Reflexivity is *not* strict dominance: equal labels do not dominate each other. - assert!(!a.dominates(&a.clone())); + assert!(a.final_dominates(&b)); + assert!(!b.final_dominates(&a)); + assert!(a.final_dominates(&a.clone())); // Incomparable pair: one better in v, the other better in e. let c = GrowthLabel::from_fields({ @@ -594,8 +629,8 @@ fn test_growth_label_dominance_partial_order() { m.insert("e", Growth::from_expr(&powk("m", 2.0))); // m^2 m }); - assert!(!c.dominates(&d)); - assert!(!d.dominates(&c)); + assert!(!c.final_dominates(&d)); + assert!(!d.final_dominates(&c)); } /// **Negative control (issue #1080):** two S→T paths whose composed growths are @@ -641,15 +676,17 @@ fn test_growth_negative_control_incomparable_front() { ); let initial = GrowthLabel::source(&["n", "m"]); - let front = graph.pareto_search_by_name( - "S", - &empty, - "T", - &empty, - ReductionMode::Witness, - initial, - false, - ); + let front = graph + .pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + crate::rules::SearchMode::Exact, + ) + .value; // The front must contain BOTH incomparable paths — not one representative. assert_eq!( @@ -689,8 +726,7 @@ fn test_growth_negative_control_incomparable_front() { // magnitude 4). A scalar branch-and-bound (were the kernel to use one) would let the // cheaper path A complete first and then prune B (cost 4 ≥ 3), silently dropping a // Pareto-optimal path. This is the case the equal-magnitude negative control above -// does NOT catch; it passes because the kernel prunes by exact dominance only, never -// by the scalar `cost`. +// does NOT catch; it passes because the kernel never uses scalar `cost` to prune. #[test] fn test_growth_asymmetric_incomparable_front_complete() { let empty = BTreeMap::new(); @@ -728,15 +764,17 @@ fn test_growth_asymmetric_incomparable_front_complete() { ], ); - let front = graph.pareto_search_by_name( - "S", - &empty, - "T", - &empty, - ReductionMode::Witness, - GrowthLabel::source(&["n", "m"]), - false, - ); + let front = graph + .pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + GrowthLabel::source(&["n", "m"]), + crate::rules::SearchMode::Exact, + ) + .value; let mut seen: Vec<(String, String)> = front .iter() @@ -762,11 +800,11 @@ fn test_growth_asymmetric_incomparable_front_complete() { ); } -/// Isotonicity of `extend` (design invariant): if `A` dominates `B`, then -/// `extend(A, e)` dominates `extend(B, e)` for the same edge — the correctness -/// condition for the kernel's dominance pruning. +/// Positive monotone overheads preserve GrowthLabel's terminal order. This is useful in +/// the symbolic domain, but the kernel does not rely on it for intermediate pruning +/// because repository overheads are not restricted to this subset. #[test] -fn test_growth_label_extend_isotone() { +fn test_growth_label_monotone_overhead_preserves_order() { // A = (n, m) dominates B = (n^2, m^2) componentwise. let a = GrowthLabel::source(&["n", "m"]); let b = GrowthLabel::from_fields({ @@ -775,7 +813,7 @@ fn test_growth_label_extend_isotone() { mm.insert("m", Growth::from_expr(&powk("m", 2.0))); mm }); - assert!(a.dominates(&b)); + assert!(a.final_dominates(&b)); let tv = BTreeMap::new(); // A monotone overhead in both fields. @@ -795,18 +833,16 @@ fn test_growth_label_extend_isotone() { // A ⪰ B ⇒ extend(A) ⪰ extend(B) (dominates-or-equal). Equality is possible // when the overhead collapses the difference, so accept dominate-or-equal. assert!( - ea.dominates(&eb) || ea == eb, - "isotonicity violated: {ea:?} vs {eb:?}" + ea.final_dominates(&eb) || ea == eb, + "monotone overhead reversed growth order: {ea:?} vs {eb:?}" ); } } /// `asymptotic_front` reports **one representative per distinct growth vector**, not -/// one per route. On the real graph, `MinimumVertexCover → ILP` has dozens of -/// syntactically distinct reduction chains that compose to only a handful of Big-O -/// profiles; the front must (a) contain no two entries with identical growth vectors -/// and (b) collapse to that small handful — while the raw kernel front (same search, -/// no dedup) still holds the many redundant routes. +/// one per route. On the real graph, `MinimumVertexCover → ILP` has many syntactically +/// distinct chains that compose to the same Big-O profile; terminal equality filtering +/// must leave no duplicate growth vectors. #[test] fn test_asymptotic_front_dedups_by_growth_vector() { let graph = ReductionGraph::new(); @@ -819,16 +855,19 @@ fn test_asymptotic_front_dedups_by_growth_vector() { .or_else(|| graph.variants_for("ILP").into_iter().next()) .expect("ILP registered"); - let front = graph.asymptotic_front( - "MinimumVertexCover", - &src_v, - "ILP", - &dst_v, - ReductionMode::Witness, - ); + let front = graph + .asymptotic_front( + "MinimumVertexCover", + &src_v, + "ILP", + &dst_v, + ReductionMode::Witness, + crate::rules::SearchMode::Exact, + ) + .value; assert!(!front.is_empty(), "MVC -> ILP must have a path"); - // (a) No two front entries share a growth vector (GrowthLabel PartialEq). + // No two front entries share a growth vector (GrowthLabel PartialEq). for i in 0..front.len() { for j in (i + 1)..front.len() { assert!( @@ -839,31 +878,21 @@ fn test_asymptotic_front_dedups_by_growth_vector() { ); } } - // (b) A proper Pareto front is a small handful, not the dozens of redundant routes. - assert!( - (1..=4).contains(&front.len()), - "expected 1..=4 distinct growth vectors, got {}", - front.len() - ); - - // The dedup genuinely collapsed routes: the raw kernel front (same search, no - // dedup) is strictly larger and does contain repeated growth vectors. + // The generic kernel itself performs terminal filtering, so the public wrapper does + // not need a second deduplication pass. let src_fields = graph.size_field_names("MinimumVertexCover"); - let raw = graph.pareto_search_by_name( - "MinimumVertexCover", - &src_v, - "ILP", - &dst_v, - ReductionMode::Witness, - GrowthLabel::source(&src_fields), - false, - ); - assert!( - raw.len() > front.len(), - "dedup should collapse redundant routes: raw {} vs deduped {}", - raw.len(), - front.len() - ); + let raw = graph + .pareto_search_by_name( + "MinimumVertexCover", + &src_v, + "ILP", + &dst_v, + ReductionMode::Witness, + GrowthLabel::source(&src_fields), + crate::rules::SearchMode::Exact, + ) + .value; + assert_eq!(raw.len(), front.len()); } /// A composed front label must express every size field's growth purely in the @@ -894,13 +923,16 @@ fn test_asymptotic_front_uses_only_source_variables_mfvs_ilp() { .or_else(|| graph.variants_for("ILP").into_iter().next()) .expect("ILP registered"); - let front = graph.asymptotic_front( - "MinimumFeedbackVertexSet", - &src_v, - "ILP", - &dst_v, - ReductionMode::Witness, - ); + let front = graph + .asymptotic_front( + "MinimumFeedbackVertexSet", + &src_v, + "ILP", + &dst_v, + ReductionMode::Witness, + crate::rules::SearchMode::Exact, + ) + .value; // The direct route (MFVS → ILP/i32 → ILP/bool; the ILP variants collapse in the // deduplicated node-name view) is the one exercised by the fixed cast. @@ -938,7 +970,7 @@ fn test_asymptotic_front_uses_only_source_variables_mfvs_ilp() { } // --------------------------------------------------------------------------- -// Fix A: the kernel prunes by dominance only — never (unsound) branch-and-bound. +// Fix A: the kernel never applies intermediate pruning or branch-and-bound. // --------------------------------------------------------------------------- /// A test label whose `cost` is the label's current absolute value — a value a late edge @@ -949,6 +981,296 @@ struct ShrinkLabel { v: f64, } +#[derive(Clone)] +struct ContractLabel { + agenda_cost: f64, + downstream_cost: f64, +} + +impl PathLabel for ContractLabel { + fn extend(&self, edge: &ReductionEdge) -> Option { + let empty = ProblemSize::new(vec![]); + let downstream_cost = edge + .overhead + .get("downstream") + .map(|expr| expr.eval(&empty)) + .unwrap_or(self.downstream_cost); + let agenda_cost = if edge.target_name == "T" { + downstream_cost + } else { + edge.overhead + .get("agenda") + .map(|expr| expr.eval(&empty)) + .unwrap_or(self.agenda_cost) + }; + Some(Self { + agenda_cost, + downstream_cost, + }) + } + + fn final_dominates(&self, other: &Self) -> bool { + self.agenda_cost <= other.agenda_cost && self.downstream_cost <= other.downstream_cost + } + + fn cost(&self) -> f64 { + self.agenda_cost + } +} + +/// Contract regression for explicit completeness. Exact crosses both former hidden +/// limits. Bounded approximate search reports the precise limit that removes a route, +/// and generous limits upgrade to an exact outcome. +#[test] +fn test_search_mode_exact_and_approximate_contract() { + use crate::rules::{ + ApproximationPolicy, LimitReached, SearchCompleteness, SearchLimits, SearchMode, + }; + + let empty = BTreeMap::new(); + let node_names = [ + "N00", "N01", "N02", "N03", "N04", "N05", "N06", "N07", "N08", "N09", "N10", "N11", "N12", + "N13", "N14", "N15", "N16", "N17", + ]; + let long_edges: Vec<_> = node_names + .windows(2) + .map(|pair| (pair[0], pair[1], growth_edge(vec![]))) + .collect(); + let long_graph = ReductionGraph::from_test_edges(&node_names, &long_edges); + let initial = ContractLabel { + agenda_cost: 0.0, + downstream_cost: 0.0, + }; + + let exact_long = long_graph.pareto_search_by_name( + "N00", + &empty, + "N17", + &empty, + ReductionMode::Witness, + initial.clone(), + SearchMode::Exact, + ); + assert_eq!(exact_long.completeness, SearchCompleteness::Exact); + assert_eq!(exact_long.value[0].0.len(), 17); + + let capped_long = long_graph.pareto_search_by_name( + "N00", + &empty, + "N17", + &empty, + ReductionMode::Witness, + initial.clone(), + SearchMode::Approximate(ApproximationPolicy::Bounded(SearchLimits { + max_hops: Some(16), + ..Default::default() + })), + ); + assert!(capped_long.value.is_empty()); + assert!(capped_long + .completeness + .reasons() + .contains(&LimitReached::HopLimit)); + + let generous_long = long_graph.pareto_search_by_name( + "N00", + &empty, + "N17", + &empty, + ReductionMode::Witness, + initial.clone(), + SearchMode::Approximate(ApproximationPolicy::Bounded(SearchLimits { + max_hops: Some(17), + max_labels_per_node: Some(34), + max_expanded_states: Some(100), + timeout: None, + })), + ); + assert_eq!(generous_long.completeness, SearchCompleteness::Exact); + assert_eq!(generous_long.value[0].0.len(), 17); + + let make_bag_graph = |reverse: bool| { + let mut edges = (0..33) + .map(|i| { + ( + "S", + "M", + growth_edge(vec![ + ("agenda", Expr::Const((i + 1) as f64)), + ("downstream", Expr::Const((33 - i) as f64)), + ]), + ) + }) + .collect::>(); + if reverse { + edges.reverse(); + } + edges.push(("M", "T", growth_edge(vec![]))); + ReductionGraph::from_test_edges(&["S", "M", "T"], &edges) + }; + + let exact_bag = make_bag_graph(false).pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial.clone(), + SearchMode::Exact, + ); + assert_eq!(exact_bag.completeness, SearchCompleteness::Exact); + assert_eq!(exact_bag.value[0].1.cost(), 1.0); + + let capped_bag = make_bag_graph(false).pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial.clone(), + SearchMode::Approximate(ApproximationPolicy::Bounded(SearchLimits { + max_labels_per_node: Some(32), + ..Default::default() + })), + ); + assert_eq!(capped_bag.value[0].1.cost(), 2.0); + assert!(capped_bag + .completeness + .reasons() + .contains(&LimitReached::LabelsPerNodeLimit)); + + let reversed = make_bag_graph(true).pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + SearchMode::Exact, + ); + assert_eq!(reversed.completeness, SearchCompleteness::Exact); + assert_eq!(reversed.value[0].1.cost(), exact_bag.value[0].1.cost()); + let serialize = |outcome: &crate::rules::SearchOutcome>| { + serde_json::to_string(&serde_json::json!({ + "path": outcome.value[0].0.type_names(), + "cost": outcome.value[0].1.cost(), + "completeness": &outcome.completeness, + "stats": &outcome.stats, + })) + .unwrap() + }; + assert_eq!(serialize(&reversed), serialize(&exact_bag)); +} + +/// Equal coarse labels with different paths must both survive. The route through Y is the +/// only one that can still visit X after M and reach final size zero. +#[test] +fn test_equal_labels_keep_incomparable_continuation_state() { + let empty = BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "X", "Y", "M", "T"], + &[ + ("S", "X", diamond_edge(0.0, Expr::Const(1.0))), + ("X", "M", diamond_edge(0.0, Expr::Var("s"))), + ("S", "Y", diamond_edge(0.0, Expr::Const(1.0))), + ("Y", "M", diamond_edge(0.0, Expr::Var("s"))), + ("M", "X", diamond_edge(0.0, Expr::Const(0.0))), + ("X", "T", diamond_edge(0.0, Expr::Var("s"))), + ], + ); + + let outcome = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + DiamondLabel { c: 0.0, s: 0.0 }, + crate::rules::SearchMode::Exact, + ); + assert_eq!(outcome.value[0].1.s, 0.0); + assert_eq!( + outcome.value[0].0.type_names(), + vec!["S", "Y", "M", "X", "T"] + ); +} + +#[test] +fn test_equal_intermediate_labels_are_not_coalesced() { + let empty = BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "M", "X", "T"], + &[ + ("S", "M", diamond_edge(0.0, Expr::Const(1.0))), + ("S", "X", diamond_edge(0.0, Expr::Const(1.0))), + ("X", "M", diamond_edge(0.0, Expr::Var("s"))), + ("M", "T", diamond_edge(0.0, Expr::Var("s"))), + ], + ); + + let outcome = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + DiamondLabel { c: 0.0, s: 0.0 }, + crate::rules::SearchMode::Exact, + ); + assert_eq!(outcome.stats.generated_states, 6); + assert_eq!(outcome.stats.dominated_states, 1); + assert_eq!(outcome.value[0].0.type_names(), vec!["S", "M", "T"]); +} + +#[test] +fn test_state_and_timeout_limits_are_reported_before_expansion() { + use crate::rules::{ApproximationPolicy, LimitReached, SearchLimits, SearchMode}; + use std::time::Duration; + + let empty = BTreeMap::new(); + let graph = ReductionGraph::from_test_edges(&["S", "T"], &[("S", "T", growth_edge(vec![]))]); + let initial = ContractLabel { + agenda_cost: 0.0, + downstream_cost: 0.0, + }; + + let state_limited = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial.clone(), + SearchMode::Approximate(ApproximationPolicy::Bounded(SearchLimits { + max_expanded_states: Some(0), + ..Default::default() + })), + ); + assert_eq!(state_limited.stats.expanded_states, 0); + assert!(state_limited + .completeness + .reasons() + .contains(&LimitReached::ExpandedStatesLimit)); + + let timed_out = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + initial, + SearchMode::Approximate(ApproximationPolicy::Bounded(SearchLimits { + timeout: Some(Duration::ZERO), + ..Default::default() + })), + ); + assert_eq!(timed_out.stats.expanded_states, 0); + assert!(timed_out + .completeness + .reasons() + .contains(&LimitReached::Timeout)); +} + impl PathLabel for ShrinkLabel { fn extend(&self, edge: &ReductionEdge) -> Option { // The edge sets a new absolute value (`v`), which may be smaller than the current. @@ -957,7 +1279,7 @@ impl PathLabel for ShrinkLabel { Some(ShrinkLabel { v }) } - fn dominates(&self, other: &Self) -> bool { + fn final_dominates(&self, other: &Self) -> bool { self.v <= other.v } @@ -970,10 +1292,9 @@ impl PathLabel for ShrinkLabel { /// higher than a rival route that completes early at 50, but a final edge drops it to 10) /// must survive to the front. A kernel that applied branch-and-bound would prune the /// intermediate node (100 ≥ best-so-far 50) and silently drop the true optimum. Because -/// the kernel prunes by dominance only, the shrink-late route reaches the front even under -/// `exhaustive = true` (which disables only the dominance guard). +/// the kernel retains every intermediate label, the shrink-late route reaches the front. #[test] -fn test_kernel_keeps_shrink_late_route_dominance_only() { +fn test_kernel_keeps_shrink_late_route_without_intermediate_pruning() { let empty = std::collections::BTreeMap::new(); let graph = ReductionGraph::from_test_edges( &["S", "A", "T"], @@ -987,15 +1308,17 @@ fn test_kernel_keeps_shrink_late_route_dominance_only() { ], ); - let front = graph.pareto_search_by_name( - "S", - &empty, - "T", - &empty, - ReductionMode::Witness, - ShrinkLabel { v: 0.0 }, - true, - ); + let front = graph + .pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + ShrinkLabel { v: 0.0 }, + crate::rules::SearchMode::Exact, + ) + .value; // The shrink-late route S -> A -> T (final value 10) must be present in the front. let shrink_late = front @@ -1013,16 +1336,14 @@ fn test_kernel_keeps_shrink_late_route_dominance_only() { } // --------------------------------------------------------------------------- -// Fix B: CostLabel dominance is componentwise over (cost, size). +// Fix B: CostLabel retains every intermediate route. // --------------------------------------------------------------------------- -/// Fix B regression: an edge cost that DEPENDS on the carried size makes a cheaper-so-far -/// prefix with a *larger* intermediate size a trap — a scalar `cost <= other.cost` -/// dominance would evict the costlier-but-smaller prefix whose continuation is globally -/// cheapest. With componentwise `(cost, size)` dominance both prefixes survive at the hub -/// and `find_cheapest_path` returns the globally optimal route. +/// Fix B regression: an edge cost that depends on carried size makes a cheaper-so-far +/// prefix with a larger intermediate size a trap. Retaining both prefixes lets +/// `find_cheapest_path` return the globally optimal route. #[test] -fn test_cost_label_path_dependent_dominance() { +fn test_cost_label_path_dependent_cost_keeps_winner() { let empty = std::collections::BTreeMap::new(); // Edges carry `c` (base edge cost), `wf` (weight on the size-dependent term) and `w` // (the tracked size field). The cost function is `c + wf * current_w`, so the M -> T @@ -1074,7 +1395,7 @@ fn test_cost_label_path_dependent_dominance() { ); // Cost function: c + wf * current_w. Depends on the carried size, so the two prefixes - // into M are incomparable and must both be kept. + // into M must both be kept. let cost_fn = CustomCost(|oh: &ReductionOverhead, sz: &ProblemSize| { let c = oh.get("c").map(|e| e.eval(sz)).unwrap_or(0.0); let wf = oh.get("wf").map(|e| e.eval(sz)).unwrap_or(0.0); @@ -1089,17 +1410,105 @@ fn test_cost_label_path_dependent_dominance() { &empty, &ProblemSize::new(vec![("w", 0)]), &cost_fn, + crate::rules::SearchMode::Exact, ) + .value .expect("cheapest path S -> T"); // Globally cheapest: S -> P -> M -> T (total 3 + 1 + 1 = 5), NOT the cheap-prefix trap - // S -> M -> T (total 1 + 100 = 101). A scalar-dominance CostLabel would evict the - // small-w prefix at M and return the S -> M -> T trap. + // S -> M -> T (total 1 + 100 = 101). Intermediate pruning could evict the small-w + // prefix at M and return the S -> M -> T trap. assert_eq!( best.type_names(), vec!["S", "P", "M", "T"], - "componentwise (cost, size) dominance must keep the globally optimal small-w prefix" + "exact search must keep the globally optimal small-w prefix" + ); +} + +/// A legitimate reduction overhead may reverse componentwise size order. The smaller, +/// cheaper prefix at M must not discard the larger prefix, because complementing the +/// edge count makes that larger prefix the final winner. +#[test] +fn test_cost_label_nonmonotone_overhead_does_not_prune_intermediate_winner() { + let empty = BTreeMap::new(); + let graph = ReductionGraph::from_test_edges( + &["S", "A", "B", "M", "T"], + &[ + ( + "S", + "A", + growth_edge(vec![ + ("n", Expr::Const(10.0)), + ("m", Expr::Const(2.0)), + ("edge_cost", Expr::Const(0.0)), + ]), + ), + ( + "A", + "M", + growth_edge(vec![ + ("n", Expr::Var("n")), + ("m", Expr::Var("m")), + ("edge_cost", Expr::Const(0.0)), + ]), + ), + ( + "S", + "B", + growth_edge(vec![ + ("n", Expr::Const(10.0)), + ("m", Expr::Const(8.0)), + ("edge_cost", Expr::Const(1.0)), + ]), + ), + ( + "B", + "M", + growth_edge(vec![ + ("n", Expr::Var("n")), + ("m", Expr::Var("m")), + ("edge_cost", Expr::Const(0.0)), + ]), + ), + ( + "M", + "T", + growth_edge(vec![ + ( + "m", + Expr::Var("n") * (Expr::Var("n") - Expr::Const(1.0)) / Expr::Const(2.0) + - Expr::Var("m"), + ), + ("terminal", Expr::Const(1.0)), + ]), + ), + ], ); + let cost_fn = CustomCost(|overhead: &ReductionOverhead, size: &ProblemSize| { + if overhead.get("terminal").is_some() { + overhead.evaluate_output_size(size).get("m").unwrap_or(0) as f64 + } else { + overhead + .get("edge_cost") + .map(|expr| expr.eval(size)) + .unwrap_or(0.0) + } + }); + + let best = graph + .find_cheapest_path( + "S", + &empty, + "T", + &empty, + &ProblemSize::new(vec![]), + &cost_fn, + crate::rules::SearchMode::Exact, + ) + .value + .expect("non-monotone formula path"); + + assert_eq!(best.type_names(), vec!["S", "B", "M", "T"]); } // --------------------------------------------------------------------------- @@ -1183,9 +1592,9 @@ impl Drop for DropToken { } } -/// A label carrying an `Rc` and a two-component `(c, s)` value. The engineered -/// `(c, s)` pairs are pairwise incomparable, so no label evicts another by dominance and -/// the per-node bag grows until the cap truncates it — exercising the truncation free path. +/// A label carrying an `Rc` and a two-component `(c, s)` value. No +/// intermediate label is pruned, so an explicit approximate bag limit exercises the +/// truncation free path. #[derive(Clone)] struct TokenLabel { c: f64, @@ -1205,7 +1614,7 @@ impl PathLabel for TokenLabel { }) } - fn dominates(&self, other: &Self) -> bool { + fn final_dominates(&self, other: &Self) -> bool { self.c <= other.c && self.s <= other.s } @@ -1215,7 +1624,7 @@ impl PathLabel for TokenLabel { } /// Fix D regression: drive the kernel on a graph that generates far more labels at one hub -/// than `BAG_CAP`, all incomparable so the bag truncates repeatedly. Because evicted / +/// than an explicit bag limit, all incomparable so the bag truncates repeatedly. Because /// truncated arena entries free their labels immediately, the *peak* number of live /// `DropToken` instances stays well below the *total* ever created. If the arena pinned /// evicted labels (the bug), peak would equal total. @@ -1225,7 +1634,7 @@ fn test_arena_frees_evicted_labels_bounds_live_memory() { TOK_PEAK.with(|c| c.set(0)); TOK_CREATED.with(|c| c.set(0)); - // One hub M fed by N ≫ BAG_CAP parallel S -> M edges with pairwise-incomparable + // One hub M fed by N ≫ 32 parallel S -> M edges with pairwise-incomparable // (c = i+1, s = N-i) labels, then M -> T (identity). The M bag truncates repeatedly. let n: usize = 200; let mut edges: Vec<(&'static str, &'static str, ReductionEdgeData)> = Vec::new(); @@ -1253,15 +1662,25 @@ fn test_arena_frees_evicted_labels_bounds_live_memory() { s: 0.0, _tok: Rc::new(DropToken::new()), }; - let front = graph.pareto_search_by_name( + let outcome = graph.pareto_search_by_name( "S", &empty, "T", &empty, ReductionMode::Witness, initial, - false, + crate::rules::SearchMode::Approximate(crate::rules::ApproximationPolicy::Bounded( + crate::rules::SearchLimits { + max_labels_per_node: Some(32), + ..Default::default() + }, + )), ); + assert!(outcome + .completeness + .reasons() + .contains(&crate::rules::LimitReached::LabelsPerNodeLimit)); + let front = outcome.value; // Sanity: the search reached T. assert!(!front.is_empty(), "front should reach T"); @@ -1274,7 +1693,7 @@ fn test_arena_frees_evicted_labels_bounds_live_memory() { ); // Eviction frees labels: peak live is strictly below total created. With the bug // (arena pins evicted labels) peak would equal created; the margin here is large - // (peak is bounded by ~BAG_CAP per live node, created scales with N) so this is not + // (peak is bounded by ~32 per live node, created scales with N) so this is not // flaky. assert!( peak < created, @@ -1290,3 +1709,50 @@ fn test_arena_frees_evicted_labels_bounds_live_memory() { "retained tokens {live_after} must be bounded well below total {created}" ); } + +#[test] +fn test_exact_dfs_releases_completed_prefixes() { + TOK_LIVE.with(|c| c.set(0)); + TOK_PEAK.with(|c| c.set(0)); + TOK_CREATED.with(|c| c.set(0)); + + let n = 200; + let mut edges = Vec::new(); + for _ in 0..n { + edges.push(( + "S", + "M", + growth_edge(vec![("c", Expr::Const(1.0)), ("s", Expr::Const(1.0))]), + )); + } + edges.push(( + "M", + "T", + growth_edge(vec![("c", Expr::Var("c")), ("s", Expr::Var("s"))]), + )); + let graph = ReductionGraph::from_test_edges(&["S", "M", "T"], &edges); + let empty = BTreeMap::new(); + let outcome = graph.pareto_search_by_name( + "S", + &empty, + "T", + &empty, + ReductionMode::Witness, + TokenLabel { + c: 0.0, + s: 0.0, + _tok: Rc::new(DropToken::new()), + }, + crate::rules::SearchMode::Exact, + ); + + assert_eq!(outcome.stats.generated_states, 1 + 2 * n); + assert_eq!(outcome.stats.peak_labels_per_node, 1); + assert_eq!(outcome.value.len(), 1); + let created = TOK_CREATED.with(|c| c.get()); + let peak = TOK_PEAK.with(|c| c.get()); + assert!( + peak * 10 < created, + "exact DFS should release branch prefixes: peak {peak}, created {created}" + ); +} diff --git a/src/unit_tests/rules/reduction_path_parity.rs b/src/unit_tests/rules/reduction_path_parity.rs index 9a7721594..4d5d6ef2e 100644 --- a/src/unit_tests/rules/reduction_path_parity.rs +++ b/src/unit_tests/rules/reduction_path_parity.rs @@ -27,7 +27,9 @@ fn test_jl_parity_maxcut_to_spinglass_path() { &dst_var, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path MaxCut -> SpinGlass"); // Petersen graph: 10 vertices, 15 edges @@ -82,7 +84,9 @@ fn test_jl_parity_maxcut_to_qubo_path() { &dst_var, &ProblemSize::new(vec![("num_vertices", 10), ("num_edges", 15)]), &MinimizeStepsThenOverhead, + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path MaxCut -> QUBO"); // Use a small graph for brute-force feasibility @@ -133,7 +137,9 @@ fn test_jl_parity_factoring_to_spinglass_path() { &dst_var, &ProblemSize::new(vec![]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path Factoring -> SpinGlass"); // Julia: Factoring(2, 1, 3) — factor 3 with 2-bit x 1-bit @@ -205,7 +211,9 @@ fn test_find_cheapest_path_with_problem_size() { &dst_var, &input_size, &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("Should find path MaxCut -> SpinGlass"); assert!(!rpath.type_names().is_empty()); diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index 0873a4b65..8cb9e22dc 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -160,7 +160,9 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { ("num_triples", problem.num_triples()), ]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("reduction graph should find a direct 3DM -> ILP path"); assert_eq!(path.type_names(), vec!["ThreeDimensionalMatching", "ILP"]); diff --git a/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs b/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs index e88f0b415..36670d34a 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threematroidintersection.rs @@ -92,7 +92,9 @@ fn test_threedimensionalmatching_to_threematroidintersection_direct_path_exists( ("num_triples", source.num_triples()), ]), &MinimizeSteps, + crate::rules::SearchMode::Exact, ) + .value .expect("reduction graph should find the direct 3DM -> 3MI edge"); assert_eq!( diff --git a/tests/suites/reductions.rs b/tests/suites/reductions.rs index 3e164ecde..0d7bbea7a 100644 --- a/tests/suites/reductions.rs +++ b/tests/suites/reductions.rs @@ -556,7 +556,9 @@ mod qubo_reductions { ("num_edges", is.graph().num_edges()), ]), &Minimize("num_vars"), + problemreductions::rules::SearchMode::Exact, ) + .value .expect("Should find path MaximumIndependentSet -> QUBO"); let chain = graph .reduce_along_path(&path, &is as &dyn std::any::Any) @@ -847,7 +849,9 @@ mod qubo_reductions { ("num_edges", vc.graph().num_edges()), ]), &Minimize("num_vars"), + problemreductions::rules::SearchMode::Exact, ) + .value .expect("Should find path MVC -> QUBO"); assert_eq!( path.type_names(), diff --git a/tests/suites/register_assignment_reductions.rs b/tests/suites/register_assignment_reductions.rs index a124edb00..67f139d0b 100644 --- a/tests/suites/register_assignment_reductions.rs +++ b/tests/suites/register_assignment_reductions.rs @@ -19,7 +19,9 @@ fn ksat_to_fra_path() -> ReductionPath { &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + problemreductions::rules::SearchMode::Exact, ) + .value .expect("expected a direct KSatisfiability -> FeasibleRegisterAssignment path") } @@ -35,7 +37,9 @@ fn fra_to_ilp_path() -> ReductionPath { &dst, &ProblemSize::new(vec![]), &MinimizeSteps, + problemreductions::rules::SearchMode::Exact, ) + .value .expect("expected a direct FeasibleRegisterAssignment -> ILP path") } From 8ac9f1b48606074e305d572efa5100a294e4db5c Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 21 Jul 2026 04:14:00 +0800 Subject: [PATCH 21/31] Add deterministic solver backend registry --- problemreductions-cli/src/cli.rs | 23 +- problemreductions-cli/src/commands/inspect.rs | 60 +- problemreductions-cli/src/commands/solve.rs | 190 ++-- problemreductions-cli/src/dispatch.rs | 140 +-- problemreductions-cli/src/main.rs | 2 +- problemreductions-cli/src/mcp/tests.rs | 94 +- problemreductions-cli/src/mcp/tools.rs | 133 ++- problemreductions-cli/tests/cli_tests.rs | 180 ++-- src/models/misc/timetable_design.rs | 1 - src/rules/mod.rs | 1 + src/solvers/customized/mod.rs | 11 - src/solvers/ilp/mod.rs | 1 - src/solvers/ilp/solver.rs | 149 +--- src/solvers/mod.rs | 15 +- .../fd_subset_search.rs | 0 src/solvers/native/mod.rs | 9 + .../partial_feedback_edge_set.rs | 0 .../rooted_tree_arrangement.rs | 0 src/solvers/{customized => native}/solver.rs | 167 ++-- src/solvers/pipelines.rs | 824 ++++++++++++++++++ src/solvers/registry.rs | 383 ++++++++ src/solvers/resolver.rs | 154 ++++ src/unit_tests/example_db.rs | 16 +- .../models/misc/timetable_design.rs | 18 +- src/unit_tests/solvers/ilp/solver.rs | 89 +- .../solvers/{customized => native}/solver.rs | 126 +-- src/unit_tests/solvers/registry.rs | 232 +++++ src/unit_tests/solvers/resolver.rs | 193 ++++ 28 files changed, 2414 insertions(+), 797 deletions(-) delete mode 100644 src/solvers/customized/mod.rs rename src/solvers/{customized => native}/fd_subset_search.rs (100%) create mode 100644 src/solvers/native/mod.rs rename src/solvers/{customized => native}/partial_feedback_edge_set.rs (100%) rename src/solvers/{customized => native}/rooted_tree_arrangement.rs (100%) rename src/solvers/{customized => native}/solver.rs (66%) create mode 100644 src/solvers/pipelines.rs create mode 100644 src/solvers/registry.rs create mode 100644 src/solvers/resolver.rs rename src/unit_tests/solvers/{customized => native}/solver.rs (76%) create mode 100644 src/unit_tests/solvers/registry.rs create mode 100644 src/unit_tests/solvers/resolver.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 70fa1e5af..1ab880628 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -1220,12 +1220,12 @@ impl CreateArgs { #[derive(clap::Args)] #[command(after_help = "\ Examples: - pred solve problem.json # ILP solver (default, auto-reduces to ILP) + pred solve problem.json # deterministic registered backend or fallback pred solve problem.json --solver brute-force # brute-force (exhaustive search) - pred solve problem.json --solver customized # customized (structure-exploiting exact solver) + pred solve problem.json --solver ilp # require the registered fixed ILP pipeline pred solve reduced.json # solve a reduction bundle pred solve reduced.json -o solution.json # save result to file - pred create MIS --graph 0-1,1-2 | pred solve - # read from stdin when an ILP path exists + pred create MIS --graph 0-1,1-2 | pred solve - # read from stdin pred create GroupingBySwapping --string \"0,1,2,0,1,2\" --bound 5 | pred solve - --solver brute-force pred create StringToStringCorrection --source-string \"0,1,2,3,1,0\" --target-string \"0,1,3,2,1\" --bound 2 | pred solve - --solver brute-force pred create TwoDimensionalConsecutiveSets --alphabet-size 6 --sets \"0,1,2;3,4,5;1,3;2,4;0,5\" | pred solve - --solver brute-force @@ -1241,14 +1241,9 @@ Solve via explicit reduction: Input: a problem JSON from `pred create`, or a reduction bundle from `pred reduce`. When given a bundle, the target is solved and the solution is mapped back to the source. -The ILP solver auto-reduces non-ILP problems before solving. -Problems without an ILP reduction path, such as `GroupingBySwapping`, -`LengthBoundedDisjointPaths`, `MinMaxMulticenter`, and `StringToStringCorrection`, -currently need `--solver brute-force`. - -Customized solver: exact witness recovery for select problems via structure-exploiting -backends. Currently supports MinimumCardinalityKey, AdditionalKey, PrimeAttributeName, -BoyceCoddNormalFormViolation, PartialFeedbackEdgeSet, and RootedTreeArrangement. +By default, solve deterministically selects the exact variant's registered native +backend, then its fixed ILP pipeline, and otherwise brute force. `--solver ilp` +requires a registered ILP pipeline; it never searches the reduction graph. ILP backend (default: HiGHS). To use CPLEX instead: cargo install problemreductions-cli --features cplex @@ -1256,9 +1251,9 @@ ILP backend (default: HiGHS). To use CPLEX instead: pub struct SolveArgs { /// Problem JSON file (from `pred create`) or reduction bundle (from `pred reduce`). Use - for stdin. pub input: PathBuf, - /// Solver: ilp (default), brute-force, or customized - #[arg(long, default_value = "ilp")] - pub solver: String, + /// Solver override: ilp or brute-force. Omit for deterministic default dispatch. + #[arg(long)] + pub solver: Option, /// Timeout in seconds (0 = no limit) #[arg(long, default_value = "0")] pub timeout: u64, diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs index d7e5daf8d..a88a9522d 100644 --- a/problemreductions-cli/src/commands/inspect.rs +++ b/problemreductions-cli/src/commands/inspect.rs @@ -2,6 +2,7 @@ use crate::dispatch::{load_problem, read_input, ProblemJson, ReductionBundle}; use crate::output::OutputConfig; use anyhow::Result; use problemreductions::rules::ReductionGraph; +use problemreductions::solvers::{solver_capabilities, ExactProblemKey}; use std::path::Path; pub fn inspect(input: &Path, out: &OutputConfig) -> Result<()> { @@ -40,19 +41,48 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { } text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn())); - let solvers = problem.available_solvers(); - let solver_summary = solvers - .iter() - .map(|solver| { - if *solver == "ilp" { - "ilp (default)".to_string() - } else { - (*solver).to_string() - } + let key = ExactProblemKey::new(name, variant.clone()); + let capabilities = solver_capabilities(&key) + .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}"))?; + let native = capabilities.native.as_ref().map(|entry| { + serde_json::json!({ + "implementation": entry.implementation, }) - .collect::>() - .join(", "); - text.push_str(&format!("Solvers: {solver_summary}\n")); + }); + let ilp = capabilities.ilp.as_ref().map(|pipeline| { + serde_json::json!({ + "reduction_path": pipeline.path_labels(), + }) + }); + let default_solver = if capabilities.native.is_some() { + "native" + } else if capabilities.ilp.is_some() { + "ilp" + } else { + "brute-force" + }; + let mut solvers = Vec::new(); + if capabilities.native.is_some() { + solvers.push("native"); + } + if capabilities.ilp.is_some() { + solvers.push("ilp"); + } + solvers.push("brute-force"); + text.push_str(&format!("Default solver: {default_solver}\n")); + text.push_str(&format!("Solvers: {}\n", solvers.join(", "))); + if let Some(native) = capabilities.native.as_ref() { + text.push_str(&format!( + "Native implementation: {}\n", + native.implementation + )); + } + if let Some(ilp) = capabilities.ilp.as_ref() { + text.push_str(&format!( + "ILP pipeline: {}\n", + ilp.path_labels().join(" -> ") + )); + } // Reductions let outgoing = graph.outgoing_reductions(name); @@ -68,6 +98,12 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), "solvers": solvers, + "default_solver": default_solver, + "solver_capabilities": { + "native": native, + "ilp": ilp, + "brute_force": true, + }, "reduces_to": targets, }); diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 80207d44c..411ad22fa 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -1,6 +1,7 @@ use crate::dispatch::{load_problem, read_input, BundleReplay, ProblemJson, ReductionBundle}; use crate::output::OutputConfig; use anyhow::{Context, Result}; +use problemreductions::solvers::{DeterministicSolveResult, SolverExecution, SolverRequest}; use std::path::Path; use std::time::Duration; @@ -28,8 +29,22 @@ fn parse_input(path: &Path) -> Result { } } -fn solve_result_text(problem: &str, solver: &str, result: &crate::dispatch::SolveResult) -> String { - let mut text = format!("Problem: {}\nSolver: {}", problem, solver); +fn solver_text(solver: &SolverExecution) -> String { + match solver { + SolverExecution::Native { implementation } => format!("native ({implementation})"), + SolverExecution::Ilp { reduction_path } => { + format!("ilp ({})", reduction_path.join(" -> ")) + } + SolverExecution::BruteForce => "brute-force".to_string(), + } +} + +fn solve_result_text(problem: &str, result: &DeterministicSolveResult) -> String { + let mut text = format!( + "Problem: {}\nSolver: {}", + problem, + solver_text(&result.solver) + ); if let Some(config) = &result.config { text.push_str(&format!("\nSolution: {:?}", config)); } @@ -37,14 +52,10 @@ fn solve_result_text(problem: &str, solver: &str, result: &crate::dispatch::Solv text } -fn solve_result_json( - problem: &str, - solver: &str, - result: &crate::dispatch::SolveResult, -) -> serde_json::Value { +fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> serde_json::Value { let mut json = serde_json::json!({ "problem": problem, - "solver": solver, + "solver": &result.solver, "evaluation": result.evaluation, }); if let Some(config) = &result.config { @@ -55,35 +66,44 @@ fn solve_result_json( fn plain_problem_output( problem: &str, - solver: &str, - result: &crate::dispatch::SolveResult, + result: &DeterministicSolveResult, ) -> (String, serde_json::Value) { ( - solve_result_text(problem, solver, result), - solve_result_json(problem, solver, result), + solve_result_text(problem, result), + solve_result_json(problem, result), ) } -pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) -> Result<()> { - if solver_name != "brute-force" && solver_name != "ilp" && solver_name != "customized" { - anyhow::bail!( - "Unknown solver: {}. Available solvers: brute-force, ilp, customized", - solver_name - ); +fn solver_request(solver_name: Option<&str>) -> Result { + match solver_name { + None => Ok(SolverRequest::Default), + Some("ilp") => Ok(SolverRequest::Ilp), + Some("brute-force") => Ok(SolverRequest::BruteForce), + Some(other) => { + anyhow::bail!("Unknown solver: {other}. Available solver overrides: brute-force, ilp") + } } +} + +pub fn solve( + input: &Path, + solver_name: Option<&str>, + timeout: u64, + out: &OutputConfig, +) -> Result<()> { + let request = solver_request(solver_name)?; let parsed = parse_input(input)?; if timeout > 0 { - let solver_name = solver_name.to_string(); let out = out.clone(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = match parsed { SolveInput::Problem(pj) => { - solve_problem(&pj.problem_type, &pj.variant, pj.data, &solver_name, &out) + solve_problem(&pj.problem_type, &pj.variant, pj.data, request, &out) } - SolveInput::Bundle(b) => solve_bundle(b, &solver_name, &out), + SolveInput::Bundle(b) => solve_bundle(b, request, &out), }; tx.send(result).ok(); }); @@ -94,9 +114,9 @@ pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) } else { match parsed { SolveInput::Problem(pj) => { - solve_problem(&pj.problem_type, &pj.variant, pj.data, solver_name, out) + solve_problem(&pj.problem_type, &pj.variant, pj.data, request, out) } - SolveInput::Bundle(b) => solve_bundle(b, solver_name, out), + SolveInput::Bundle(b) => solve_bundle(b, request, out), } } } @@ -106,85 +126,44 @@ fn solve_problem( problem_type: &str, variant: &std::collections::BTreeMap, data: serde_json::Value, - solver_name: &str, + request: SolverRequest, out: &OutputConfig, ) -> Result<()> { let problem = load_problem(problem_type, variant, data)?; let name = problem.problem_name(); - - match solver_name { - "brute-force" => { - let result = problem.solve_brute_force(); - let (text, json) = plain_problem_output(name, "brute-force", &result); - let result = out.emit_with_default_name("", &text, &json); - if out.output.is_none() && crate::output::stderr_is_tty() { - out.info("\nHint: use -o to save full solution details as JSON."); - } - result - } - "ilp" => { - let result = problem.solve_with_ilp().map_err(add_ilp_solver_hint)?; - let solver_desc = if name == "ILP" { - "ilp".to_string() - } else { - "ilp (via ILP)".to_string() - }; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let text = solve_result_text(name, &solver_desc, &result); - let mut json = solve_result_json(name, "ilp", &result); - if name != "ILP" { - json["reduced_to"] = serde_json::json!("ILP"); - } - let result = out.emit_with_default_name("", &text, &json); - if out.output.is_none() && crate::output::stderr_is_tty() { - out.info("\nHint: use -o to save full solution details as JSON."); - } - result - } - "customized" => { - let result = problem - .solve_with_customized() - .map_err(add_customized_solver_hint)?; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let (text, json) = plain_problem_output(name, "customized", &result); - let result = out.emit_with_default_name("", &text, &json); - if out.output.is_none() && crate::output::stderr_is_tty() { - out.info("\nHint: use -o to save full solution details as JSON."); - } - result - } - _ => unreachable!(), + let result = problem + .solve_deterministically(request) + .map_err(add_solver_hint)?; + let (text, json) = plain_problem_output(name, &result); + let emitted = out.emit_with_default_name("", &text, &json); + if out.output.is_none() && crate::output::stderr_is_tty() { + out.info("\nHint: use -o to save full solution details as JSON."); } + emitted } /// Solve a reduction bundle: solve the target problem, then map the solution back. -fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) -> Result<()> { +fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputConfig) -> Result<()> { let replay = BundleReplay::prepare(&bundle)?; - let target_result = match solver_name { - "brute-force" => replay.target.solve_brute_force_witness().ok_or_else(|| { - anyhow::anyhow!( - "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", - replay.target_name - ) - })?, - "ilp" => replay.target.solve_with_ilp().map_err(add_ilp_solver_hint)?, - "customized" => replay - .target - .solve_with_customized() - .map_err(add_customized_solver_hint)?, - _ => unreachable!(), - }; + let target_result = replay + .target + .solve_deterministically(request) + .map_err(add_solver_hint)?; + let target_config = target_result.config.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", + replay.target_name + ) + })?; - let (source_config, source_eval) = replay.extract(&target_result.config); + let (source_config, source_eval) = replay.extract(target_config); - let solver_desc = format!("{} (via {})", solver_name, replay.target_name); + let solver_desc = format!( + "{} (via {})", + solver_text(&target_result.solver), + replay.target_name + ); let text = format!( "Problem: {}\nSolver: {}\nSolution: {:?}\nEvaluation: {}", replay.source_name, solver_desc, source_config, source_eval, @@ -192,13 +171,12 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) let json = serde_json::json!({ "problem": replay.source_name, - "solver": solver_name, - "reduced_to": replay.target_name, + "solver": &target_result.solver, "solution": source_config, "evaluation": source_eval, "intermediate": { "problem": replay.target_name, - "solution": target_result.config, + "solution": target_config, "evaluation": target_result.evaluation, }, }); @@ -210,22 +188,9 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) result } -fn add_customized_solver_hint(err: anyhow::Error) -> anyhow::Error { - let message = err.to_string(); - if message.contains("unsupported by customized solver") { - anyhow::anyhow!( - "{message}\n\nHint: the customized solver only supports select problems (FD-based models, PartialFeedbackEdgeSet, RootedTreeArrangement).\nTry `--solver brute-force` or `--solver ilp` instead." - ) - } else { - err - } -} - -fn add_ilp_solver_hint(err: anyhow::Error) -> anyhow::Error { +fn add_solver_hint(err: anyhow::Error) -> anyhow::Error { let message = err.to_string(); - if (message.starts_with("No reduction path from ") && message.ends_with(" to ILP")) - || message.contains("witness-capable") - { + if message.starts_with("No ILP pipeline is registered for ") { anyhow::anyhow!( "{message}\n\nHint: try `--solver brute-force` for direct exhaustive search on small instances." ) @@ -237,18 +202,17 @@ fn add_ilp_solver_hint(err: anyhow::Error) -> anyhow::Error { #[cfg(test)] mod tests { use super::*; - use crate::dispatch::SolveResult; use crate::output::OutputConfig; use crate::test_support::aggregate_bundle; #[test] fn test_solve_value_only_problem_omits_solution() { - let result = SolveResult { + let result = DeterministicSolveResult { + solver: SolverExecution::BruteForce, config: None, evaluation: "Sum(56)".to_string(), }; - let (text, json) = - plain_problem_output("CliTestAggregateValueSource", "brute-force", &result); + let (text, json) = plain_problem_output("CliTestAggregateValueSource", &result); assert!(text.contains("Evaluation: Sum(56)"), "{text}"); assert!(!text.contains("Solution:"), "{text}"); assert!(json.get("solution").is_none(), "{json}"); @@ -264,7 +228,7 @@ mod tests { auto_json: false, }; - let err = solve_bundle(bundle, "brute-force", &out).unwrap_err(); + let err = solve_bundle(bundle, SolverRequest::BruteForce, &out).unwrap_err(); assert!( err.to_string().contains("witness"), "unexpected error: {err}" diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 4849373b7..4d2ac34ab 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -1,8 +1,9 @@ use anyhow::{Context, Result}; use problemreductions::registry::{DynProblem, LoadedDynProblem}; -use problemreductions::rules::{MinimizeSteps, ReductionGraph, ReductionMode}; -use problemreductions::solvers::{CustomizedSolver, ILPSolver}; -use problemreductions::types::ProblemSize; +use problemreductions::rules::ReductionGraph; +use problemreductions::solvers::{ + solve_deterministically, DeterministicSolveResult, SolverRequest, +}; use serde_json::Value; use std::any::Any; use std::collections::BTreeMap; @@ -37,80 +38,11 @@ impl std::ops::Deref for LoadedProblem { } impl LoadedProblem { - pub fn solve_brute_force_value(&self) -> String { - self.inner.solve_brute_force_value() - } - - pub fn solve_brute_force_witness(&self) -> Option { - let (config, evaluation) = self.inner.solve_brute_force_witness()?; - Some(WitnessSolveResult { config, evaluation }) - } - - pub fn solve_brute_force(&self) -> SolveResult { - let evaluation = self.solve_brute_force_value(); - let config = self.solve_brute_force_witness().map(|result| result.config); - SolveResult { config, evaluation } - } - - pub fn supports_ilp_solver(&self) -> bool { - let name = self.problem_name(); - let variant = self.variant_map(); - name == "ILP" || { - let graph = ReductionGraph::new(); - let ilp_variants = graph.variants_for("ILP"); - let input_size = ProblemSize::new(vec![]); - ilp_variants.iter().any(|dv| { - graph - .find_cheapest_path_mode( - name, - &variant, - "ILP", - dv, - ReductionMode::Witness, - &input_size, - &MinimizeSteps, - ) - .is_some() - }) - } - } - - pub fn supports_customized_solver(&self) -> bool { - CustomizedSolver::supports_problem(self.as_any()) - } - - pub fn solve_with_customized(&self) -> Result { - let solver = CustomizedSolver::new(); - let config = solver - .solve_dyn(self.as_any()) - .ok_or_else(|| anyhow::anyhow!("Problem unsupported by customized solver"))?; - let evaluation = self.evaluate_dyn(&config); - Ok(WitnessSolveResult { config, evaluation }) - } - - #[cfg_attr(not(feature = "mcp"), allow(dead_code))] - pub fn available_solvers(&self) -> Vec<&'static str> { - let mut solvers = Vec::new(); - if self.supports_ilp_solver() { - solvers.push("ilp"); - } - solvers.push("brute-force"); - if self.supports_customized_solver() { - solvers.push("customized"); - } - solvers - } - - /// Solve using the ILP solver. If the problem is not ILP, auto-reduce to ILP first. - pub fn solve_with_ilp(&self) -> Result { - let name = self.problem_name(); - let variant = self.variant_map(); - let solver = ILPSolver::new(); - let config = solver - .try_solve_via_reduction(name, &variant, self.as_any()) - .map_err(|err| anyhow::anyhow!(err))?; - let evaluation = self.evaluate_dyn(&config); - Ok(WitnessSolveResult { config, evaluation }) + pub fn solve_deterministically( + &self, + request: SolverRequest, + ) -> Result { + solve_deterministically(&self.inner, request).map_err(anyhow::Error::from) } } @@ -298,24 +230,6 @@ pub struct PathStep { pub variant: BTreeMap, } -/// Result of solving a problem. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SolveResult { - /// The solution configuration when the problem supports witness extraction. - pub config: Option>, - /// Evaluation of the solution. - pub evaluation: String, -} - -/// Result of solving a witness-capable problem. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WitnessSolveResult { - /// The solution configuration. - pub config: Vec, - /// Evaluation of the solution. - pub evaluation: String, -} - #[cfg(test)] mod tests { use super::*; @@ -406,25 +320,15 @@ mod tests { ) .unwrap(); - let result = loaded.solve_brute_force(); + let result = loaded + .solve_deterministically(SolverRequest::BruteForce) + .unwrap(); assert_eq!(result.config, None); assert_eq!(result.evaluation, "Sum(56)"); } #[test] - fn test_available_solvers_excludes_customized_for_unsupported_problem() { - let loaded = load_problem( - AGGREGATE_SOURCE_NAME, - &BTreeMap::new(), - serde_json::to_value(AggregateValueSource::sample()).unwrap(), - ) - .unwrap(); - - assert!(!loaded.available_solvers().contains(&"customized")); - } - - #[test] - fn test_solve_with_customized_rejects_unsupported_problem() { + fn test_default_uses_brute_force_without_registered_backend() { let loaded = load_problem( AGGREGATE_SOURCE_NAME, &BTreeMap::new(), @@ -432,15 +336,17 @@ mod tests { ) .unwrap(); - let err = loaded.solve_with_customized().unwrap_err(); - assert!( - err.to_string().contains("unsupported by customized solver"), - "unexpected error: {err}" + let result = loaded + .solve_deterministically(SolverRequest::Default) + .unwrap(); + assert_eq!( + result.solver, + problemreductions::solvers::SolverExecution::BruteForce ); } #[test] - fn test_solve_with_ilp_rejects_aggregate_only_problem() { + fn test_explicit_ilp_requires_registered_pipeline() { let loaded = load_problem( AGGREGATE_SOURCE_NAME, &BTreeMap::new(), @@ -448,9 +354,11 @@ mod tests { ) .unwrap(); - let err = loaded.solve_with_ilp().unwrap_err(); + let err = loaded + .solve_deterministically(SolverRequest::Ilp) + .unwrap_err(); assert!( - err.to_string().contains("witness-capable"), + err.to_string().contains("No ILP pipeline is registered"), "unexpected error: {err}" ); } diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 702199e49..afcd3a294 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -70,7 +70,7 @@ fn main() -> anyhow::Result<()> { Commands::Inspect(args) => commands::inspect::inspect(&args.input, &out), Commands::Create(args) => commands::create::create(&args, &out), Commands::Solve(args) => { - commands::solve::solve(&args.input, &args.solver, args.timeout, &out) + commands::solve::solve(&args.input, args.solver.as_deref(), args.timeout, &out) } Commands::Reduce(args) => { commands::reduce::reduce(&args.input, args.to.as_deref(), args.via.as_deref(), &out) diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index f03e93dda..06df66dea 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -286,7 +286,7 @@ mod tests { let server = McpServer::new(); let problem_json = create_test_mis(&server); let result = server.inspect_problem_inner(&problem_json); - assert!(result.is_ok()); + assert!(result.is_ok(), "inspect failed: {result:?}"); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert_eq!(json["type"], "MaximumIndependentSet"); assert_eq!(json["kind"], "problem"); @@ -340,7 +340,7 @@ mod tests { assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); assert!(json["solution"].is_array()); - assert_eq!(json["solver"], "brute-force"); + assert_eq!(json["solver"]["kind"], "brute-force"); } #[test] @@ -354,7 +354,7 @@ mod tests { } #[test] - fn test_solve_customized_supported_problem() { + fn deterministic_solver_dispatch_defaults_supported_problem_to_native() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinimumCardinalityKey", @@ -367,10 +367,14 @@ mod tests { }) .to_string(); - let result = server.solve_inner(&problem_json, Some("customized"), None); + let result = server.solve_inner(&problem_json, None, None); assert!(result.is_ok(), "solve failed: {:?}", result); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["solver"], "customized"); + assert_eq!(json["solver"]["kind"], "native"); + assert_eq!( + json["solver"]["implementation"], + "fd-minimum-cardinality-key" + ); assert!(json["solution"].is_array(), "{json}"); } @@ -378,8 +382,37 @@ mod tests { fn test_solve_unknown_solver() { let server = McpServer::new(); let problem_json = create_test_mis(&server); - let result = server.solve_inner(&problem_json, Some("unknown"), None); - assert!(result.is_err()); + for rejected in ["auto", "customized", "native", "fd-minimum-cardinality-key"] { + let error = server + .solve_inner(&problem_json, Some(rejected), None) + .unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("Unknown solver: {rejected}")), + "unexpected error for {rejected}: {error}" + ); + } + } + + #[test] + fn deterministic_solver_dispatch_mcp_output_is_repeatable_for_each_solver_class() { + let server = McpServer::new(); + let problem_json = serde_json::json!({ + "type": "RootedTreeArrangement", + "variant": {"graph": "SimpleGraph"}, + "data": { + "graph": {"num_vertices": 3, "edges": [[0, 1], [1, 2]]}, + "bound": 3 + } + }) + .to_string(); + + for solver in [None, Some("ilp"), Some("brute-force")] { + let first = server.solve_inner(&problem_json, solver, None).unwrap(); + let second = server.solve_inner(&problem_json, solver, None).unwrap(); + assert_eq!(first, second, "{solver:?} MCP output changed"); + } } #[test] @@ -396,7 +429,7 @@ mod tests { } #[test] - fn test_solve_customized_bundle_rejects_unsupported_target_without_panicking() { + fn test_solve_bundle_rejects_removed_customized_override() { let server = McpServer::new(); let problem_json = create_test_mis(&server); let bundle_json = server.reduce_inner(&problem_json, "QUBO").unwrap(); @@ -404,7 +437,7 @@ mod tests { assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( - err.contains("unsupported by customized solver"), + err.contains("Unknown solver: customized"), "unexpected error: {err}" ); } @@ -422,19 +455,15 @@ mod tests { } #[test] - fn test_inspect_minmaxmulticenter_lists_bruteforce_only() { + fn test_inspect_minmaxmulticenter_reports_registered_ilp_pipeline() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinMaxMulticenter", "variant": {"graph": "SimpleGraph", "weight": "i32"}, "data": { "graph": { - "inner": { - "nodes": [null, null, null, null], - "node_holes": [], - "edge_property": "undirected", - "edges": [[0, 1, null], [1, 2, null], [2, 3, null]] - } + "num_vertices": 4, + "edges": [[0, 1], [1, 2], [2, 3]] }, "vertex_weights": [1, 1, 1, 1], "edge_lengths": [1, 1, 1], @@ -444,19 +473,14 @@ mod tests { .to_string(); let result = server.inspect_problem_inner(&problem_json); - assert!(result.is_ok()); + assert!(result.is_ok(), "inspect failed: {result:?}"); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - let solvers: Vec<&str> = json["solvers"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert_eq!(solvers, vec!["brute-force"]); + assert_eq!(json["default_solver"], "ilp"); + assert!(json["solver_capabilities"]["ilp"]["reduction_path"].is_array()); } #[test] - fn test_inspect_minimum_cardinality_key_lists_customized_solver() { + fn test_inspect_minimum_cardinality_key_reports_native_solver() { let server = McpServer::new(); let problem_json = serde_json::json!({ "type": "MinimumCardinalityKey", @@ -472,15 +496,10 @@ mod tests { let result = server.inspect_problem_inner(&problem_json); assert!(result.is_ok(), "inspect failed: {:?}", result); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - let solvers: Vec<&str> = json["solvers"] - .as_array() - .unwrap() - .iter() - .map(|v| v.as_str().unwrap()) - .collect(); - assert!( - solvers.contains(&"customized"), - "inspect should list customized when supported, got: {json}" + assert_eq!(json["default_solver"], "native"); + assert_eq!( + json["solver_capabilities"]["native"]["implementation"], + "fd-minimum-cardinality-key" ); } @@ -492,7 +511,7 @@ mod tests { let result = server.solve_inner(&problem_json, Some("brute-force"), None); assert!(result.is_ok()); let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(json["solver"], "brute-force"); + assert_eq!(json["solver"]["kind"], "brute-force"); } #[test] @@ -520,7 +539,10 @@ mod tests { let result = server.solve_inner(&aggregate_problem_json(), Some("ilp"), None); assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(err.contains("witness-capable"), "unexpected error: {err}"); + assert!( + err.contains("No ILP pipeline is registered"), + "unexpected error: {err}" + ); } #[test] diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index a0e2f1135..e50498fd3 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -10,6 +10,9 @@ use problemreductions::registry::collect_schemas; use problemreductions::rules::{ CustomCost, MinimizeSteps, ReductionGraph, ReductionMode, TraversalFlow, }; +use problemreductions::solvers::{ + solver_capabilities, DeterministicSolveResult, ExactProblemKey, SolverRequest, +}; use problemreductions::topology::{ Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; @@ -104,7 +107,7 @@ pub struct ReduceParams { pub struct SolveParams { #[schemars(description = "Problem JSON string (from create_problem or reduce)")] pub problem_json: String, - #[schemars(description = "Solver: 'ilp' (default), 'brute-force', or 'customized'")] + #[schemars(description = "Solver override: 'ilp' or 'brute-force'; omit for default dispatch")] pub solver: Option, #[schemars(description = "Timeout in seconds (0 = no limit, default: 0)")] pub timeout: Option, @@ -752,7 +755,32 @@ impl McpServer { let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); targets.sort(); targets.dedup(); - let solvers = problem.available_solvers(); + let key = ExactProblemKey::new(name, variant.clone()); + let capabilities = solver_capabilities(&key) + .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}"))?; + let native = capabilities + .native + .as_ref() + .map(|entry| serde_json::json!({"implementation": entry.implementation})); + let ilp = capabilities + .ilp + .as_ref() + .map(|pipeline| serde_json::json!({"reduction_path": pipeline.path_labels()})); + let default_solver = if capabilities.native.is_some() { + "native" + } else if capabilities.ilp.is_some() { + "ilp" + } else { + "brute-force" + }; + let mut solvers = Vec::new(); + if capabilities.native.is_some() { + solvers.push("native"); + } + if capabilities.ilp.is_some() { + solvers.push("ilp"); + } + solvers.push("brute-force"); let result = serde_json::json!({ "kind": "problem", @@ -761,6 +789,12 @@ impl McpServer { "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), "solvers": solvers, + "default_solver": default_solver, + "solver_capabilities": { + "native": native, + "ilp": ilp, + "brute_force": true, + }, "reduces_to": targets, }); Ok(serde_json::to_string_pretty(&result)?) @@ -866,13 +900,14 @@ impl McpServer { solver: Option<&str>, timeout: Option, ) -> anyhow::Result { - let solver_name = solver.unwrap_or("ilp"); - if solver_name != "brute-force" && solver_name != "ilp" && solver_name != "customized" { - anyhow::bail!( - "Unknown solver: {}. Available solvers: brute-force, ilp, customized", - solver_name - ); - } + let request = match solver { + None => SolverRequest::Default, + Some("ilp") => SolverRequest::Ilp, + Some("brute-force") => SolverRequest::BruteForce, + Some(other) => anyhow::bail!( + "Unknown solver: {other}. Available solver overrides: brute-force, ilp" + ), + }; let json: serde_json::Value = serde_json::from_str(problem_json)?; let timeout_secs = timeout.unwrap_or(0); @@ -884,22 +919,18 @@ impl McpServer { if timeout_secs > 0 { let json_clone = json.clone(); - let solver_name = solver_name.to_string(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let result = if is_bundle { match serde_json::from_value::(json_clone) { - Ok(b) => solve_bundle_inner(b, &solver_name), + Ok(b) => solve_bundle_inner(b, request), Err(e) => Err(anyhow::Error::from(e)), } } else { match serde_json::from_value::(json_clone) { - Ok(pj) => solve_problem_inner( - &pj.problem_type, - &pj.variant, - pj.data, - &solver_name, - ), + Ok(pj) => { + solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, request) + } Err(e) => Err(anyhow::Error::from(e)), } }; @@ -911,10 +942,10 @@ impl McpServer { } } else if is_bundle { let bundle: ReductionBundle = serde_json::from_value(json)?; - solve_bundle_inner(bundle, solver_name) + solve_bundle_inner(bundle, request) } else { let pj: ProblemJson = serde_json::from_value(json)?; - solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, solver_name) + solve_problem_inner(&pj.problem_type, &pj.variant, pj.data, request) } } } @@ -1027,7 +1058,7 @@ impl McpServer { .map_err(|e| e.to_string()) } - /// Solve a problem instance using brute-force, ILP, or customized solver + /// Solve a problem using deterministic default dispatch or an explicit override #[tool( name = "solve", annotations(read_only_hint = true, open_world_hint = false) @@ -1145,14 +1176,10 @@ fn ser(problem: T) -> anyhow::Result { util::ser(problem) } -fn solve_result_json( - problem: &str, - solver: &str, - result: &crate::dispatch::SolveResult, -) -> serde_json::Value { +fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> serde_json::Value { let mut json = serde_json::json!({ "problem": problem, - "solver": solver, + "solver": &result.solver, "evaluation": result.evaluation, }); if let Some(config) = &result.config { @@ -1474,69 +1501,37 @@ fn solve_problem_inner( problem_type: &str, variant: &BTreeMap, data: serde_json::Value, - solver_name: &str, + request: SolverRequest, ) -> anyhow::Result { let problem = load_problem(problem_type, variant, data)?; let name = problem.problem_name(); - - match solver_name { - "brute-force" => { - let result = problem.solve_brute_force(); - let json = solve_result_json(name, "brute-force", &result); - Ok(serde_json::to_string_pretty(&json)?) - } - "ilp" => { - let result = problem.solve_with_ilp()?; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let mut json = solve_result_json(name, "ilp", &result); - if name != "ILP" { - json["reduced_to"] = serde_json::json!("ILP"); - } - Ok(serde_json::to_string_pretty(&json)?) - } - "customized" => { - let result = problem.solve_with_customized()?; - let result = crate::dispatch::SolveResult { - config: Some(result.config), - evaluation: result.evaluation, - }; - let json = solve_result_json(name, "customized", &result); - Ok(serde_json::to_string_pretty(&json)?) - } - _ => unreachable!(), - } + let result = problem.solve_deterministically(request)?; + let json = solve_result_json(name, &result); + Ok(serde_json::to_string_pretty(&json)?) } /// Solve a reduction bundle: solve the target, then map the solution back. -fn solve_bundle_inner(bundle: ReductionBundle, solver_name: &str) -> anyhow::Result { +fn solve_bundle_inner(bundle: ReductionBundle, request: SolverRequest) -> anyhow::Result { let replay = BundleReplay::prepare(&bundle)?; - let target_result = match solver_name { - "brute-force" => replay.target.solve_brute_force_witness().ok_or_else(|| { + let target_result = replay.target.solve_deterministically(request)?; + let target_config = target_result.config.as_ref().ok_or_else(|| { anyhow::anyhow!( "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", replay.target_name ) - })?, - "ilp" => replay.target.solve_with_ilp()?, - "customized" => replay.target.solve_with_customized()?, - _ => unreachable!(), - }; + })?; - let (source_config, source_eval) = replay.extract(&target_result.config); + let (source_config, source_eval) = replay.extract(target_config); let json = serde_json::json!({ "problem": replay.source_name, - "solver": solver_name, - "reduced_to": replay.target_name, + "solver": &target_result.solver, "solution": source_config, "evaluation": source_eval, "intermediate": { "problem": replay.target_name, - "solution": target_result.config, + "solution": target_config, "evaluation": target_result.evaluation, }, }); diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7bc3f386a..8081a35f7 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -189,8 +189,12 @@ fn test_solve_balanced_complete_bipartite_subgraph_default_solver_uses_ilp() { let stdout = String::from_utf8(solve.stdout).unwrap(); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(json["problem"], "BalancedCompleteBipartiteSubgraph"); - assert_eq!(json["solver"], "ilp"); - assert_eq!(json["reduced_to"], "ILP"); + assert_eq!(json["solver"]["kind"], "ilp"); + assert!(json["solver"]["reduction_path"] + .as_array() + .and_then(|path| path.last()) + .and_then(|step| step.as_str()) + .is_some_and(|step| step.starts_with("ILP<"))); assert_eq!(json["evaluation"], "Or(true)"); assert!( json["solution"] @@ -1572,12 +1576,12 @@ fn test_solve_d2cif_default_solver_uses_ilp() { ); let stdout = String::from_utf8(solve_output.stdout).unwrap(); assert!( - stdout.contains("\"solver\": \"ilp\""), + stdout.contains("\"kind\": \"ilp\""), "expected ILP solver output, got: {stdout}" ); assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "expected auto-reduction marker, got: {stdout}" + stdout.contains("\"reduction_path\""), + "expected registered ILP pipeline metadata, got: {stdout}" ); std::fs::remove_file(&output_file).ok(); @@ -2712,7 +2716,7 @@ fn test_solve_brute_force() { ); let stdout = String::from_utf8(output.stdout).unwrap(); // auto_json: data commands output JSON when stdout is not a TTY (as in tests) - assert!(stdout.contains("\"solver\": \"brute-force\"")); + assert!(stdout.contains("\"kind\": \"brute-force\"")); assert!(stdout.contains("\"solution\"")); std::fs::remove_file(&problem_file).ok(); @@ -2744,11 +2748,11 @@ fn test_solve_ilp() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("\"solver\": \"ilp\"")); + assert!(stdout.contains("\"kind\": \"ilp\"")); assert!(stdout.contains("\"solution\"")); assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "MIS solved with ILP should show auto-reduction: {stdout}" + stdout.contains("\"reduction_path\""), + "MIS solved with ILP should report its registered pipeline: {stdout}" ); std::fs::remove_file(&problem_file).ok(); @@ -2756,7 +2760,7 @@ fn test_solve_ilp() { #[test] fn test_solve_ilp_default() { - // Default solver is ilp + // MIS has no native solver, so its registered ILP pipeline is the default. let problem_file = std::env::temp_dir().join("pred_test_solve_default.json"); let create_out = pred() .args([ @@ -2783,16 +2787,15 @@ fn test_solve_ilp_default() { let stdout = String::from_utf8(output.stdout).unwrap(); // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("\"solver\": \"ilp\"") && stdout.contains("\"reduced_to\": \"ILP\""), - "MIS with default solver should show auto-reduction: {stdout}" + stdout.contains("\"kind\": \"ilp\"") && stdout.contains("\"reduction_path\""), + "MIS with default solver should report its registered ILP pipeline: {stdout}" ); std::fs::remove_file(&problem_file).ok(); } #[test] -fn test_solve_ilp_shows_via_ilp() { - // When solving a non-ILP problem with ILP solver, output should show "via ILP" +fn test_solve_ilp_reports_registered_pipeline() { let problem_file = std::env::temp_dir().join("pred_test_solve_via_ilp.json"); let create_out = pred() .args([ @@ -2819,8 +2822,8 @@ fn test_solve_ilp_shows_via_ilp() { let stdout = String::from_utf8(output.stdout).unwrap(); // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "Non-ILP problem solved with ILP should show auto-reduction indicator, got: {stdout}" + stdout.contains("\"reduction_path\""), + "Non-ILP problem solved with ILP should report its registered pipeline, got: {stdout}" ); assert!(stdout.contains("\"problem\": \"MaximumIndependentSet\"")); @@ -2865,7 +2868,7 @@ fn test_solve_json_output() { let content = std::fs::read_to_string(&result_file).unwrap(); let json: serde_json::Value = serde_json::from_str(&content).unwrap(); assert!(json["solution"].is_array()); - assert_eq!(json["solver"], "brute-force"); + assert_eq!(json["solver"]["kind"], "brute-force"); std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&result_file).ok(); @@ -3021,13 +3024,13 @@ fn test_solve_direct_ilp_i32_problem() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("\"problem\": \"ILP\""), "{stdout}"); - assert!(stdout.contains("\"solver\": \"ilp\""), "{stdout}"); + assert!(stdout.contains("\"kind\": \"ilp\""), "{stdout}"); std::fs::remove_file(&problem_file).ok(); } #[test] -fn test_solve_sequencing_to_minimize_weighted_completion_time_default_solver() { +fn test_solve_partial_ilp_route_defaults_to_brute_force() { let problem_file = std::env::temp_dir() .join("pred_test_solve_sequencing_to_minimize_weighted_completion_time.json"); @@ -3066,7 +3069,7 @@ fn test_solve_sequencing_to_minimize_weighted_completion_time_default_solver() { stdout.contains("\"problem\": \"SequencingToMinimizeWeightedCompletionTime\""), "{stdout}" ); - assert!(stdout.contains("\"solver\": \"ilp\""), "{stdout}"); + assert!(stdout.contains("\"kind\": \"brute-force\""), "{stdout}"); assert!(stdout.contains("\"solution\": ["), "{stdout}"); std::fs::remove_file(&problem_file).ok(); @@ -3105,7 +3108,7 @@ fn test_solve_unknown_solver() { } #[test] -fn test_solve_help_mentions_bruteforce_only_models() { +fn test_solve_help_describes_deterministic_dispatch_and_overrides() { let output = pred().args(["solve", "--help"]).output().unwrap(); assert!( output.status.success(), @@ -3113,7 +3116,11 @@ fn test_solve_help_mentions_bruteforce_only_models() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("MinMaxMulticenter"), "stdout: {stdout}"); + assert!( + stdout.contains("deterministically selects"), + "stdout: {stdout}" + ); + assert!(stdout.contains("never searches"), "stdout: {stdout}"); assert!(stdout.contains("--solver brute-force"), "stdout: {stdout}"); } @@ -4361,11 +4368,8 @@ fn test_solve_minmaxmulticenter_default_solver_uses_ilp() { String::from_utf8_lossy(&solve_out.stderr) ); let stdout = String::from_utf8(solve_out.stdout).unwrap(); - assert!(stdout.contains("\"solver\": \"ilp\""), "stdout: {stdout}"); - assert!( - stdout.contains("\"reduced_to\": \"ILP\""), - "stdout: {stdout}" - ); + assert!(stdout.contains("\"kind\": \"ilp\""), "stdout: {stdout}"); + assert!(stdout.contains("\"reduction_path\""), "stdout: {stdout}"); std::fs::remove_file(&problem_file).ok(); } @@ -5541,11 +5545,11 @@ fn test_solve_sum_of_squares_partition_default_solver_uses_ilp() { let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stdout.contains("\"solver\": \"ilp\""), + stdout.contains("\"kind\": \"ilp\""), "stdout should report the ILP solver, got: {stdout}" ); assert!( - stdout.contains("\"reduced_to\": \"ILP\""), + stdout.contains("\"reduction_path\""), "stdout should report the ILP reduction target, got: {stdout}" ); @@ -7070,7 +7074,7 @@ fn test_solve_multiple_copy_file_allocation_brute_force() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stdout.contains("\"solver\": \"brute-force\""), + stdout.contains("\"kind\": \"brute-force\""), "MultipleCopyFileAllocation should solve with brute-force: {stdout}" ); @@ -8481,7 +8485,7 @@ fn test_create_sequencing_within_intervals_rejects_overflow() { } #[test] -fn test_solve_customized_unsupported_problem_shows_hint() { +fn deterministic_solver_dispatch_rejects_non_override_solver_names() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_unsupported.json"); let create_out = pred() .args([ @@ -8496,27 +8500,69 @@ fn test_solve_customized_unsupported_problem_shows_hint() { .unwrap(); assert!(create_out.status.success()); - let output = pred() - .args([ - "solve", - problem_file.to_str().unwrap(), - "--solver", - "customized", - ]) - .output() - .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unsupported by customized solver"), - "expected customized solver hint, got: {stderr}" - ); + for rejected in ["auto", "customized", "native", "fd-minimum-cardinality-key"] { + let output = pred() + .args([ + "solve", + problem_file.to_str().unwrap(), + "--solver", + rejected, + ]) + .output() + .unwrap(); + assert!(!output.status.success(), "accepted --solver {rejected}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!("Unknown solver: {rejected}")), + "unexpected error for {rejected}: {stderr}" + ); + } + + std::fs::remove_file(&problem_file).ok(); +} + +#[test] +fn deterministic_solver_dispatch_cli_output_is_repeatable_for_each_solver_class() { + let problem_file = std::env::temp_dir().join("pred_test_solver_repeatability.json"); + let problem = serde_json::json!({ + "type": "RootedTreeArrangement", + "variant": {"graph": "SimpleGraph"}, + "data": { + "graph": {"num_vertices": 3, "edges": [[0, 1], [1, 2]]}, + "bound": 3 + } + }); + std::fs::write(&problem_file, serde_json::to_vec(&problem).unwrap()).unwrap(); + + for solver in [None, Some("ilp"), Some("brute-force")] { + let run = || { + let mut command = pred(); + command.args(["--json", "solve", problem_file.to_str().unwrap()]); + if let Some(solver) = solver { + command.args(["--solver", solver]); + } + command.output().unwrap() + }; + let first = run(); + let second = run(); + assert!( + first.status.success(), + "first {solver:?} solve failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + assert!( + second.status.success(), + "second {solver:?} solve failed: {}", + String::from_utf8_lossy(&second.stderr) + ); + assert_eq!(first.stdout, second.stdout, "{solver:?} output changed"); + } std::fs::remove_file(&problem_file).ok(); } #[test] -fn test_solve_customized_minimum_cardinality_key() { +fn deterministic_solver_dispatch_defaults_minimum_cardinality_key_to_native() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_mck.json"); let create_out = pred() .args([ @@ -8538,12 +8584,7 @@ fn test_solve_customized_minimum_cardinality_key() { ); let output = pred() - .args([ - "solve", - problem_file.to_str().unwrap(), - "--solver", - "customized", - ]) + .args(["solve", problem_file.to_str().unwrap()]) .output() .unwrap(); assert!( @@ -8552,9 +8593,11 @@ fn test_solve_customized_minimum_cardinality_key() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("customized"), - "expected 'customized' in output, got: {stdout}" + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["solver"]["kind"], "native"); + assert_eq!( + json["solver"]["implementation"], + "fd-minimum-cardinality-key" ); assert!( stdout.contains("Min("), @@ -8565,7 +8608,7 @@ fn test_solve_customized_minimum_cardinality_key() { } #[test] -fn test_solve_customized_bundle_does_not_panic() { +fn test_solve_bundle_rejects_removed_customized_override_without_panicking() { let problem_file = std::env::temp_dir().join("pred_test_solve_customized_bundle_problem.json"); let bundle_file = std::env::temp_dir().join("pred_test_solve_customized_bundle.json"); @@ -8611,15 +8654,15 @@ fn test_solve_customized_bundle_does_not_panic() { let stderr = String::from_utf8_lossy(&solve_out.stderr); assert!( !stderr.contains("panicked at"), - "customized bundle solve should fail gracefully, got: {stderr}" + "removed override should fail gracefully, got: {stderr}" ); assert!( !solve_out.status.success(), - "customized solver should not silently succeed on unsupported bundle target" + "removed solver override should not silently succeed" ); assert!( - stderr.contains("unsupported by customized solver"), - "expected customized solver error, got: {stderr}" + stderr.contains("Unknown solver: customized"), + "expected removed solver error, got: {stderr}" ); std::fs::remove_file(&problem_file).ok(); @@ -8627,7 +8670,7 @@ fn test_solve_customized_bundle_does_not_panic() { } #[test] -fn test_inspect_minimum_cardinality_key_lists_customized_solver() { +fn test_inspect_minimum_cardinality_key_reports_native_capability() { let problem_file = std::env::temp_dir().join("pred_test_inspect_customized_mck.json"); let create_out = pred() .args([ @@ -8660,15 +8703,10 @@ fn test_inspect_minimum_cardinality_key_lists_customized_solver() { let stdout = String::from_utf8(inspect_out.stdout).unwrap(); let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let solvers: Vec<&str> = json["solvers"] - .as_array() - .unwrap() - .iter() - .map(|value| value.as_str().unwrap()) - .collect(); - assert!( - solvers.contains(&"customized"), - "inspect should list customized when supported, got: {json}" + assert_eq!(json["default_solver"], "native"); + assert_eq!( + json["solver_capabilities"]["native"]["implementation"], + "fd-minimum-cardinality-key" ); std::fs::remove_file(&problem_file).ok(); diff --git a/src/models/misc/timetable_design.rs b/src/models/misc/timetable_design.rs index ba290ce40..1235d53b1 100644 --- a/src/models/misc/timetable_design.rs +++ b/src/models/misc/timetable_design.rs @@ -158,7 +158,6 @@ impl TimetableDesign { ((craftsman * self.num_tasks) + task) * self.num_periods + period } - #[cfg(feature = "ilp-solver")] pub(crate) fn solve_via_required_assignments(&self) -> Option> { #[derive(Clone)] struct PairRequirement { diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e648997a4..6e110e612 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -406,6 +406,7 @@ pub use graph::{ AggregateReductionChain, NeighborInfo, NeighborTree, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow, }; +pub(crate) use traits::DynReductionResult; pub use traits::{ AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, }; diff --git a/src/solvers/customized/mod.rs b/src/solvers/customized/mod.rs deleted file mode 100644 index 3553e4d19..000000000 --- a/src/solvers/customized/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Customized solver module. -//! -//! Provides exact witness recovery for problems that have dedicated -//! structure-exploiting backends, without requiring ILP reduction paths. - -pub(crate) mod fd_subset_search; -pub(crate) mod partial_feedback_edge_set; -pub(crate) mod rooted_tree_arrangement; -mod solver; - -pub use solver::CustomizedSolver; diff --git a/src/solvers/ilp/mod.rs b/src/solvers/ilp/mod.rs index b09109814..f23f70ff2 100644 --- a/src/solvers/ilp/mod.rs +++ b/src/solvers/ilp/mod.rs @@ -24,4 +24,3 @@ mod solver; pub use solver::ILPSolver; -pub use solver::SolveViaReductionError; diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 51b2a0df2..de052587f 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -1,8 +1,7 @@ //! ILP solver implementation using HiGHS. use crate::models::algebraic::{Comparison, ObjectiveSense, VariableDomain, ILP}; -use crate::models::misc::TimetableDesign; -use crate::rules::{ReduceTo, ReductionMode, ReductionResult}; +use crate::rules::{ReduceTo, ReductionResult}; #[cfg(not(feature = "ilp-highs"))] use good_lp::default_solver; #[cfg(feature = "ilp-highs")] @@ -40,33 +39,6 @@ pub struct ILPSolver { pub time_limit: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SolveViaReductionError { - WitnessPathRequired { name: String }, - NoReductionPath { name: String }, - NoSolution { name: String }, -} - -impl std::fmt::Display for SolveViaReductionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SolveViaReductionError::WitnessPathRequired { name } => write!( - f, - "ILP solving requires a witness-capable source problem and reduction path; only aggregate-value solving is available for {}.", - name - ), - SolveViaReductionError::NoReductionPath { name } => { - write!(f, "No reduction path from {} to ILP", name) - } - SolveViaReductionError::NoSolution { name } => { - write!(f, "ILP solver found no solution for {}", name) - } - } - } -} - -impl std::error::Error for SolveViaReductionError {} - impl ILPSolver { /// Create a new ILP solver with default settings. pub fn new() -> Self { @@ -220,131 +192,16 @@ impl ILPSolver { Some(reduction.extract_solution(&ilp_solution)) } - /// Solve a type-erased problem directly when a native solver hook exists. - /// - /// Returns `None` if the input type has no direct solver or the solver finds no solution. - pub fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { + /// Solve a type-erased supported ILP variant directly. + pub(crate) fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } - if let Some(problem) = any.downcast_ref::() { - return problem.solve_via_required_assignments(); - } None } - - fn supports_direct_dyn(&self, any: &dyn std::any::Any) -> bool { - any.is::>() || any.is::>() || any.is::() - } - - /// Two-level path selection: - /// 1. Dijkstra finds the cheapest path to each ILP variant using - /// `MinimizeStepsThenOverhead` (additive edge costs: step count + log overhead). - /// 2. Across ILP variants, we pick the path whose composed final output size - /// is smallest — this is the actual ILP problem size the solver will face. - fn best_path_to_ilp( - &self, - graph: &crate::rules::ReductionGraph, - name: &str, - variant: &std::collections::BTreeMap, - mode: ReductionMode, - instance: &dyn std::any::Any, - ) -> Option { - let ilp_variants = graph.variants_for("ILP"); - let input_size = crate::rules::ReductionGraph::compute_source_size(name, instance); - let mut best_path: Option = None; - let mut best_cost = f64::INFINITY; - - for dv in &ilp_variants { - if let Some(path) = graph.find_cheapest_path_mode( - name, - variant, - "ILP", - dv, - mode, - &input_size, - &crate::rules::MinimizeStepsThenOverhead, - ) { - // Use composed final output size for cross-variant comparison, - // since this determines the actual ILP problem size. - let final_size = graph - .evaluate_path_overhead(&path, &input_size) - .unwrap_or_default(); - let cost = final_size.total() as f64; - if cost < best_cost { - best_cost = cost; - best_path = Some(path); - } - } - } - - best_path - } - - pub fn try_solve_via_reduction( - &self, - name: &str, - variant: &std::collections::BTreeMap, - instance: &dyn std::any::Any, - ) -> Result, SolveViaReductionError> { - if self.supports_direct_dyn(instance) { - return self - .solve_dyn(instance) - .ok_or_else(|| SolveViaReductionError::NoSolution { - name: name.to_string(), - }); - } - - let graph = crate::rules::ReductionGraph::new(); - - let Some(path) = - self.best_path_to_ilp(&graph, name, variant, ReductionMode::Witness, instance) - else { - if self - .best_path_to_ilp(&graph, name, variant, ReductionMode::Aggregate, instance) - .is_some() - { - return Err(SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - }); - } - - return Err(SolveViaReductionError::NoReductionPath { - name: name.to_string(), - }); - }; - - let chain = graph.reduce_along_path(&path, instance).ok_or_else(|| { - SolveViaReductionError::WitnessPathRequired { - name: name.to_string(), - } - })?; - let ilp_solution = self.solve_dyn(chain.target_problem_any()).ok_or_else(|| { - SolveViaReductionError::NoSolution { - name: name.to_string(), - } - })?; - Ok(chain.extract_solution(&ilp_solution)) - } - - /// Solve a type-erased problem by finding a reduction path to ILP. - /// - /// Tries all ILP variants, picks the cheapest path, reduces, solves, - /// and extracts the solution back. Falls back to direct ILP solve if - /// the problem is already an ILP type. - /// - /// Returns `None` if no path to ILP exists or the solver finds no solution. - pub fn solve_via_reduction( - &self, - name: &str, - variant: &std::collections::BTreeMap, - instance: &dyn std::any::Any, - ) -> Option> { - self.try_solve_via_reduction(name, variant, instance).ok() - } } #[cfg(test)] diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index 9a1283cfc..6d39ae8fd 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -1,14 +1,25 @@ //! Solvers for computational problems. mod brute_force; -pub mod customized; pub mod decision_search; +mod native; +#[cfg(feature = "ilp-solver")] +mod pipelines; +mod registry; +mod resolver; #[cfg(feature = "ilp-solver")] pub mod ilp; pub use brute_force::BruteForce; -pub use customized::CustomizedSolver; +pub use registry::{ + solver_capabilities, ExactProblemKey, IlpSolverCapability, NativeSolverCapability, + RegistryBuildError, SolverCapabilities, +}; +pub use resolver::{ + solve_deterministically, DeterministicSolveError, DeterministicSolveResult, SolverExecution, + SolverRequest, +}; #[cfg(feature = "ilp-solver")] pub use ilp::ILPSolver; diff --git a/src/solvers/customized/fd_subset_search.rs b/src/solvers/native/fd_subset_search.rs similarity index 100% rename from src/solvers/customized/fd_subset_search.rs rename to src/solvers/native/fd_subset_search.rs diff --git a/src/solvers/native/mod.rs b/src/solvers/native/mod.rs new file mode 100644 index 000000000..6625219fa --- /dev/null +++ b/src/solvers/native/mod.rs @@ -0,0 +1,9 @@ +//! Dedicated native solver backends. +//! +//! Each backend is registered for one exact problem variant. Dispatch is +//! performed by the solver capability registry rather than a downcast chain. + +pub(crate) mod fd_subset_search; +pub(crate) mod partial_feedback_edge_set; +pub(crate) mod rooted_tree_arrangement; +mod solver; diff --git a/src/solvers/customized/partial_feedback_edge_set.rs b/src/solvers/native/partial_feedback_edge_set.rs similarity index 100% rename from src/solvers/customized/partial_feedback_edge_set.rs rename to src/solvers/native/partial_feedback_edge_set.rs diff --git a/src/solvers/customized/rooted_tree_arrangement.rs b/src/solvers/native/rooted_tree_arrangement.rs similarity index 100% rename from src/solvers/customized/rooted_tree_arrangement.rs rename to src/solvers/native/rooted_tree_arrangement.rs diff --git a/src/solvers/customized/solver.rs b/src/solvers/native/solver.rs similarity index 66% rename from src/solvers/customized/solver.rs rename to src/solvers/native/solver.rs index a980a9bb6..c187df8fc 100644 --- a/src/solvers/customized/solver.rs +++ b/src/solvers/native/solver.rs @@ -1,68 +1,123 @@ -//! CustomizedSolver: structure-exploiting exact witness solver. -//! -//! Uses direct downcast dispatch to call dedicated backends for -//! supported problem types, returning `None` for unsupported problems. +//! Exact native solvers and their exact-variant registrations. use super::fd_subset_search::{ self, compute_closure, find_essential_attributes, find_essential_attributes_restricted, is_minimal_key, is_superkey, BranchDecision, }; use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; -use crate::models::misc::{AdditionalKey, BoyceCoddNormalFormViolation}; +use crate::models::misc::{AdditionalKey, BoyceCoddNormalFormViolation, TimetableDesign}; use crate::models::set::{MinimumCardinalityKey, PrimeAttributeName}; +use crate::solvers::registry::NativeSolverRegistration; use crate::topology::SimpleGraph; +use crate::traits::Problem; use std::collections::HashSet; -/// A solver that uses problem-specific backends for exact witness recovery. -/// -/// Unlike `BruteForce`, which enumerates all configurations, `CustomizedSolver` -/// exploits problem structure (functional-dependency closure, cycle hitting, -/// tree arrangement) to prune search and find witnesses more efficiently. -/// -/// Returns `None` for unsupported problem types. -#[derive(Default)] -pub struct CustomizedSolver; - -impl CustomizedSolver { - /// Create a new `CustomizedSolver`. - pub fn new() -> Self { - Self +fn no_variant() -> Vec<(&'static str, &'static str)> { + Vec::new() +} + +fn simple_graph_variant() -> Vec<(&'static str, &'static str)> { + vec![("graph", "SimpleGraph")] +} + +fn downcast_solve( + any: &dyn std::any::Any, + solve: fn(&P) -> Option>, +) -> Option> { + let problem = any + .downcast_ref::

() + .expect("native solver registration received the wrong concrete type"); + solve(problem) +} + +fn solve_minimum_cardinality_key_dyn(any: &dyn std::any::Any) -> Option> { + downcast_solve(any, solve_minimum_cardinality_key) +} + +fn solve_additional_key_dyn(any: &dyn std::any::Any) -> Option> { + downcast_solve(any, solve_additional_key) +} + +fn solve_prime_attribute_name_dyn(any: &dyn std::any::Any) -> Option> { + downcast_solve(any, solve_prime_attribute_name) +} + +fn solve_bcnf_violation_dyn(any: &dyn std::any::Any) -> Option> { + downcast_solve(any, solve_bcnf_violation) +} + +fn solve_partial_feedback_edge_set_dyn(any: &dyn std::any::Any) -> Option> { + downcast_solve(any, super::partial_feedback_edge_set::find_witness) +} + +fn solve_rooted_tree_arrangement_dyn(any: &dyn std::any::Any) -> Option> { + downcast_solve(any, super::rooted_tree_arrangement::find_witness) +} + +fn solve_timetable_design_dyn(any: &dyn std::any::Any) -> Option> { + downcast_solve(any, TimetableDesign::solve_via_required_assignments) +} + +inventory::submit! { + NativeSolverRegistration { + source_name: MinimumCardinalityKey::NAME, + source_variant_fn: no_variant, + implementation: "fd-minimum-cardinality-key", + solve_fn: solve_minimum_cardinality_key_dyn, } +} - /// Check whether a type-erased problem is supported by the customized solver. - pub fn supports_problem(any: &dyn std::any::Any) -> bool { - any.is::() - || any.is::() - || any.is::() - || any.is::() - || any.is::>() - || any.is::>() +inventory::submit! { + NativeSolverRegistration { + source_name: AdditionalKey::NAME, + source_variant_fn: no_variant, + implementation: "fd-additional-key", + solve_fn: solve_additional_key_dyn, } +} - /// Attempt to solve a type-erased problem using a dedicated backend. - /// - /// Returns `Some(config)` if a satisfying witness is found, `None` if - /// the problem type is unsupported or no witness exists. - pub fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { - if let Some(p) = any.downcast_ref::() { - return solve_minimum_cardinality_key(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_additional_key(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_prime_attribute_name(p); - } - if let Some(p) = any.downcast_ref::() { - return solve_bcnf_violation(p); - } - if let Some(p) = any.downcast_ref::>() { - return super::partial_feedback_edge_set::find_witness(p); - } - if let Some(p) = any.downcast_ref::>() { - return super::rooted_tree_arrangement::find_witness(p); - } - None +inventory::submit! { + NativeSolverRegistration { + source_name: PrimeAttributeName::NAME, + source_variant_fn: no_variant, + implementation: "fd-prime-attribute-name", + solve_fn: solve_prime_attribute_name_dyn, + } +} + +inventory::submit! { + NativeSolverRegistration { + source_name: BoyceCoddNormalFormViolation::NAME, + source_variant_fn: no_variant, + implementation: "fd-bcnf-violation", + solve_fn: solve_bcnf_violation_dyn, + } +} + +inventory::submit! { + NativeSolverRegistration { + source_name: PartialFeedbackEdgeSet::::NAME, + source_variant_fn: simple_graph_variant, + implementation: "partial-feedback-edge-set", + solve_fn: solve_partial_feedback_edge_set_dyn, + } +} + +inventory::submit! { + NativeSolverRegistration { + source_name: RootedTreeArrangement::::NAME, + source_variant_fn: simple_graph_variant, + implementation: "rooted-tree-arrangement", + solve_fn: solve_rooted_tree_arrangement_dyn, + } +} + +inventory::submit! { + NativeSolverRegistration { + source_name: TimetableDesign::NAME, + source_variant_fn: no_variant, + implementation: "timetable-required-assignments", + solve_fn: solve_timetable_design_dyn, } } @@ -70,7 +125,7 @@ impl CustomizedSolver { /// /// Uses iterative deepening by cardinality to guarantee the first solution /// found has the minimum number of attributes. -fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option> { +pub(crate) fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option> { let n = problem.num_attributes(); let deps = problem.dependencies().to_vec(); @@ -113,7 +168,7 @@ fn solve_minimum_cardinality_key(problem: &MinimumCardinalityKey) -> Option Option> { +pub(crate) fn solve_additional_key(problem: &AdditionalKey) -> Option> { let n_attrs = problem.num_attributes(); let deps = problem.dependencies().to_vec(); let relation_attrs = problem.relation_attrs(); @@ -176,7 +231,7 @@ fn solve_additional_key(problem: &AdditionalKey) -> Option> { } /// Solve PrimeAttributeName: find a candidate key containing the query attribute. -fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option> { +pub(crate) fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option> { let n = problem.num_attributes(); let deps = problem.dependencies().to_vec(); let query = problem.query_attribute(); @@ -220,7 +275,7 @@ fn solve_prime_attribute_name(problem: &PrimeAttributeName) -> Option /// Solve BoyceCoddNormalFormViolation: find a subset X of target_subset such that /// the closure of X contains some but not all of target_subset \ X. -fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option> { +pub(crate) fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option> { let n_attrs = problem.num_attributes(); let deps = problem.functional_deps().to_vec(); let target = problem.target_subset(); @@ -263,5 +318,5 @@ fn solve_bcnf_violation(problem: &BoyceCoddNormalFormViolation) -> Option { + inventory::submit! { + IlpPipelineRegistration { + path: &[ + $(StaticProblemStep { + name: $name, + variant: &[$(($key, $value)),*], + }),+ + ], + } + } + }; +} + +register_ilp_pipeline! { + ("AcyclicPartition", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BMF", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BalancedCompleteBipartiteSubgraph", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BicliqueCover", []), + ("BMF", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BiconnectivityAugmentation", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BinPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("BottleneckTravelingSalesman", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("BoundedComponentSpanningForest", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("CapacityAssignment", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("CircuitSAT", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ClosestString", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ClosestSubstring", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("Clustering", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveBlockMinimization", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveOnesMatrixAugmentation", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsecutiveOnesSubmatrix", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ConsistencyOfDatabaseFrequencyTables", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionMinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DecisionOptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("DirectedHamiltonianPath", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("DirectedTwoCommodityIntegralFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("DisjointConnectingPaths", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("EulerianPath", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ExactCoverBy3Sets", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ExpectedRetrievalCost", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Factoring", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("FeasibleRegisterAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("FlowShopScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("GraphPartitioning", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HamiltonianCircuit", [("graph", "SimpleGraph")]), + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HamiltonianPath", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("HighlyConnectedDeletion", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ILP", [("variable", "i32")]), +} + +// This exact variant also has a native backend. Default dispatch selects the +// native registration, while an explicit ILP override executes this pipeline. +register_ilp_pipeline! { + ("RootedTreeArrangement", [("graph", "SimpleGraph")]), + ("RootedTreeStorageAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowBundles", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowHomologousArcs", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IntegralFlowWithMultipliers", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("IsomorphicSpanningTree", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KClique", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KColoring", [("graph", "SimpleGraph"), ("k", "KN")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KColoring", [("graph", "SimpleGraph"), ("k", "K3")]), + ("Clustering", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "KN")]), + ("Satisfiability", []), + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "K2")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("KSatisfiability", [("k", "K3")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Knapsack", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LengthBoundedDisjointPaths", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestCircuit", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestCommonSubsequence", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("LongestPath", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MaximalIS", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Maximum2Satisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "One")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCoKPlex", [("graph", "SimpleGraph"), ("k", "KN"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumCommonEdgeSubgraph", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumContactMapOverlap", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumDomaticNumber", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumEdgeWeightedKClique", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumEdgeWeightedKClique", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "KingsSubgraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "KingsSubgraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "TriangularSubgraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumIndependentSet", [("graph", "UnitDiskGraph"), ("weight", "i32")]), + ("MaximumIndependentSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MaximumClique", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumLeafSpanningTree", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MaximumLikelihoodRanking", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumMatching", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "One")]), + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MaximumSetPacking", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinMaxMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumCapacitatedSpanningTree", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumCutIntoBoundedSets", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumDiscretePlanarInverseKinematics", []), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumDominatingSet", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumEdgeCostFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumExternalMacroDataCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumFaultDetectionTestSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumFeedbackVertexSet", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumGraphBandwidth", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MinimumHittingSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumInternalMacroDataCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMatrixCover", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMaximalMatching", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMetricDimension", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumMultiwayCut", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumSumMulticenter", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumTardinessSequencing", [("weight", "One")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumTardinessSequencing", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "One")]), + ("MinimumHittingSet", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumVertexCover", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("MinimumSetCovering", [("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MinimumWeightDecoding", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MixedChinesePostman", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("MonochromaticTriangle", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MultipleCopyFileAllocation", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("MultiprocessorScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Numerical3DimensionalMatching", []), + ("NumericalMatchingWithTargetSums", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("NumericalMatchingWithTargetSums", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("OpenShopScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("OptimalLinearArrangement", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("OptimumCommunicationSpanningTree", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PaintShop", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartiallyOrderedKnapsack", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("Partition", []), + ("MultiprocessorScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoCliques", [("graph", "SimpleGraph")]), + ("MinimumCoveringByCliques", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoPathsOfLength2", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PartitionIntoTriangles", [("graph", "SimpleGraph")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PathConstrainedNetworkFlow", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("PrecedenceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("PreemptiveScheduling", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("QuadraticAssignment", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RectilinearPictureCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RegisterSufficiency", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("ResourceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("RootedTreeStorageAssignment", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("RuralPostman", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("Satisfiability", []), + ("NAESatisfiability", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SchedulingToMinimizeWeightedCompletionTime", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SchedulingWithIndividualDeadlines", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeMaximumCumulativeCost", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeTardyTaskWeight", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingToMinimizeWeightedTardiness", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SequencingWithDeadlinesAndSetUpTimes", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingWithReleaseTimesAndDeadlines", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SequencingWithinIntervals", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SetSplitting", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ShortestCommonSupersequence", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ShortestWeightConstrainedPath", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SparseMatrixCompression", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("SpinGlass", [("graph", "SimpleGraph"), ("weight", "f64")]), + ("QUBO", [("weight", "f64")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StackerCrane", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StringToStringCorrection", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("StrongConnectivityAugmentation", [("weight", "i32")]), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("SubgraphIsomorphism", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("SumOfSquaresPartition", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ThreeDimensionalMatching", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("ThreePartition", []), + ("ResourceConstrainedScheduling", []), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("TravelingSalesman", [("graph", "SimpleGraph"), ("weight", "i32")]), + ("ILP", [("variable", "bool")]), +} + +register_ilp_pipeline! { + ("UndirectedFlowLowerBounds", []), + ("ILP", [("variable", "i32")]), +} + +register_ilp_pipeline! { + ("UndirectedTwoCommodityIntegralFlow", []), + ("ILP", [("variable", "i32")]), +} diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs new file mode 100644 index 000000000..5511d2fc1 --- /dev/null +++ b/src/solvers/registry.rs @@ -0,0 +1,383 @@ +//! Deterministic solver capabilities for exact problem variants. + +use crate::registry::VariantEntry; +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; + +/// Canonical identity of one concrete problem variant. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct ExactProblemKey { + pub name: String, + pub variant: BTreeMap, +} + +impl ExactProblemKey { + pub fn new(name: impl Into, variant: BTreeMap) -> Self { + Self { + name: name.into(), + variant, + } + } + + fn from_static(step: &StaticProblemStep) -> Self { + Self::new( + step.name, + step.variant + .iter() + .map(|&(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + } + + /// Format the key using the catalog's canonical problem notation. + pub fn label(&self) -> String { + if self.variant.is_empty() { + return self.name.clone(); + } + let values = self + .variant + .values() + .cloned() + .collect::>() + .join(", "); + format!("{}<{values}>", self.name) + } + + fn is_supported_ilp(&self) -> bool { + self.name == "ILP" + && matches!( + self.variant.get("variable").map(String::as_str), + Some("bool" | "i32") + ) + } +} + +/// A compile-time path node used by fixed ILP pipeline declarations. +#[derive(Clone, Copy)] +pub(crate) struct StaticProblemStep { + pub name: &'static str, + pub variant: &'static [(&'static str, &'static str)], +} + +/// A fixed ILP pipeline declaration. +/// +/// Every adjacent pair is resolved to one exact witness reduction while the +/// registry is constructed. Runtime solving executes the resolved function +/// pointers and never searches the reduction graph. +pub(crate) struct IlpPipelineRegistration { + pub(crate) path: &'static [StaticProblemStep], +} + +inventory::collect!(IlpPipelineRegistration); + +type NativeSolveFn = fn(&dyn Any) -> Option>; + +/// A dedicated solver registered for one exact problem variant. +#[derive(Debug)] +pub(crate) struct NativeSolverRegistration { + pub(crate) source_name: &'static str, + pub(crate) source_variant_fn: fn() -> Vec<(&'static str, &'static str)>, + pub(crate) implementation: &'static str, + pub(crate) solve_fn: NativeSolveFn, +} + +impl NativeSolverRegistration { + fn source_key(&self) -> ExactProblemKey { + ExactProblemKey::new( + self.source_name, + (self.source_variant_fn)() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + } +} + +inventory::collect!(NativeSolverRegistration); + +#[derive(Debug)] +pub(crate) struct CompiledIlpPipeline { + path: Vec, + reducers: Vec, +} + +impl CompiledIlpPipeline { + pub(crate) fn path(&self) -> &[ExactProblemKey] { + &self.path + } + + pub(crate) fn path_labels(&self) -> Vec { + self.path.iter().map(ExactProblemKey::label).collect() + } + + #[cfg(feature = "ilp-solver")] + pub(crate) fn solve( + &self, + source: &dyn Any, + solver: &super::ILPSolver, + ) -> Result, PipelineExecutionError> { + if self.reducers.is_empty() { + return solver + .solve_dyn(source) + .ok_or(PipelineExecutionError::NoSolution); + } + + let mut reductions: Vec> = Vec::new(); + for reducer in &self.reducers { + let input = reductions + .last() + .map(|step| step.target_problem_any()) + .unwrap_or(source); + reductions.push(reducer(input)); + } + + let target = reductions + .last() + .expect("non-empty fixed pipeline must produce a target") + .target_problem_any(); + let solution = solver + .solve_dyn(target) + .ok_or(PipelineExecutionError::NoSolution)?; + Ok(reductions.iter().rev().fold(solution, |current, step| { + step.extract_solution_dyn(¤t) + })) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub(crate) enum PipelineExecutionError { + #[error("the registered ILP pipeline found no solution")] + NoSolution, +} + +#[derive(Clone, Copy)] +pub(crate) struct RegisteredSolverCapabilities<'a> { + pub(crate) native: Option<&'static NativeSolverRegistration>, + pub(crate) ilp: Option<&'a CompiledIlpPipeline>, +} + +impl std::fmt::Debug for RegisteredSolverCapabilities<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SolverCapabilities") + .field("native", &self.native.map(|entry| entry.implementation)) + .field("ilp", &self.ilp.map(CompiledIlpPipeline::path)) + .finish() + } +} + +/// Read-only metadata for a registered native solver. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct NativeSolverCapability { + pub implementation: &'static str, +} + +/// Read-only metadata for a registered fixed ILP pipeline. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct IlpSolverCapability { + path: Vec, +} + +impl IlpSolverCapability { + pub fn path(&self) -> &[ExactProblemKey] { + &self.path + } + + pub fn path_labels(&self) -> Vec { + self.path.iter().map(ExactProblemKey::label).collect() + } +} + +/// Read-only solver capabilities for one exact problem variant. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SolverCapabilities { + pub native: Option, + pub ilp: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct SolverCapabilityRegistry { + native: BTreeMap, + ilp: BTreeMap, +} + +impl SolverCapabilityRegistry { + pub(crate) fn lookup(&self, key: &ExactProblemKey) -> RegisteredSolverCapabilities<'_> { + RegisteredSolverCapabilities { + native: self.native.get(key).copied(), + ilp: self.ilp.get(key), + } + } + + #[cfg(test)] + pub(crate) fn native_entries( + &self, + ) -> impl Iterator + '_ { + self.native.iter().map(|(key, entry)| (key, *entry)) + } + + #[cfg(test)] + pub(crate) fn ilp_entries( + &self, + ) -> impl Iterator { + self.ilp.iter() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RegistryBuildError { + #[error("solver registration references unknown exact variant {0}")] + UnknownVariant(String), + #[error("duplicate native solver registration for {0}")] + DuplicateNative(String), + #[error("duplicate ILP pipeline registration for {0}")] + DuplicateIlp(String), + #[error("ILP pipeline must contain at least one node")] + EmptyPipeline, + #[error("ILP pipeline for {0} does not end at ILP or ILP")] + UnsupportedTarget(String), + #[error("ILP pipeline for {0} continues after reaching a supported ILP node")] + ContinuesAfterIlp(String), + #[error("ILP pipeline edge {source_label} -> {target_label} resolves to {matches} witness reductions")] + InvalidEdge { + source_label: String, + target_label: String, + matches: usize, + }, +} + +fn registered_variant_keys() -> BTreeSet { + inventory::iter::() + .map(|entry| ExactProblemKey::new(entry.name, entry.variant_map())) + .collect() +} + +fn edge_key(entry: &ReductionEntry, source: bool) -> ExactProblemKey { + let (name, variant) = if source { + (entry.source_name, entry.source_variant()) + } else { + (entry.target_name, entry.target_variant()) + }; + ExactProblemKey::new( + name, + variant + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) +} + +fn build_registry( + variants: &BTreeSet, + native_entries: impl IntoIterator, + pipeline_entries: impl IntoIterator, + reductions: &[&'static ReductionEntry], +) -> Result { + let mut registry = SolverCapabilityRegistry::default(); + + for native in native_entries { + let source = native.source_key(); + if !variants.contains(&source) { + return Err(RegistryBuildError::UnknownVariant(source.label())); + } + if registry.native.insert(source.clone(), native).is_some() { + return Err(RegistryBuildError::DuplicateNative(source.label())); + } + } + + for registration in pipeline_entries { + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let source = path + .first() + .cloned() + .ok_or(RegistryBuildError::EmptyPipeline)?; + + for step in &path { + if !variants.contains(step) { + return Err(RegistryBuildError::UnknownVariant(step.label())); + } + } + if !path.last().is_some_and(ExactProblemKey::is_supported_ilp) { + return Err(RegistryBuildError::UnsupportedTarget(source.label())); + } + if path[..path.len() - 1] + .iter() + .any(ExactProblemKey::is_supported_ilp) + { + return Err(RegistryBuildError::ContinuesAfterIlp(source.label())); + } + + let mut reducers = Vec::with_capacity(path.len().saturating_sub(1)); + for pair in path.windows(2) { + let matches = reductions + .iter() + .filter(|entry| { + entry.capabilities.witness + && entry.reduce_fn.is_some() + && edge_key(entry, true) == pair[0] + && edge_key(entry, false) == pair[1] + }) + .collect::>(); + if matches.len() != 1 { + return Err(RegistryBuildError::InvalidEdge { + source_label: pair[0].label(), + target_label: pair[1].label(), + matches: matches.len(), + }); + } + reducers.push(matches[0].reduce_fn.expect("filtered above")); + } + + if registry + .ilp + .insert(source.clone(), CompiledIlpPipeline { path, reducers }) + .is_some() + { + return Err(RegistryBuildError::DuplicateIlp(source.label())); + } + } + + Ok(registry) +} + +static REGISTRY: OnceLock> = OnceLock::new(); + +pub(crate) fn solver_capability_registry( +) -> Result<&'static SolverCapabilityRegistry, &'static RegistryBuildError> { + REGISTRY + .get_or_init(|| { + build_registry( + ®istered_variant_keys(), + inventory::iter::(), + inventory::iter::(), + &reduction_entries(), + ) + }) + .as_ref() +} + +/// Return read-only solver metadata for one exact problem variant. +pub fn solver_capabilities( + key: &ExactProblemKey, +) -> Result { + let registered = solver_capability_registry()?.lookup(key); + Ok(SolverCapabilities { + native: registered.native.map(|entry| NativeSolverCapability { + implementation: entry.implementation, + }), + ilp: registered.ilp.map(|pipeline| IlpSolverCapability { + path: pipeline.path.clone(), + }), + }) +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/registry.rs"] +mod tests; diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs new file mode 100644 index 000000000..929140c0e --- /dev/null +++ b/src/solvers/resolver.rs @@ -0,0 +1,154 @@ +//! Shared deterministic solver dispatch. + +use super::registry::{ + solver_capability_registry, CompiledIlpPipeline, ExactProblemKey, NativeSolverRegistration, + RegistryBuildError, +}; +use crate::registry::LoadedDynProblem; +use serde::Serialize; + +/// Public solver override. Omission is represented by [`SolverRequest::Default`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SolverRequest { + #[default] + Default, + Ilp, + BruteForce, +} + +/// Information about the backend execution that produced a solve result. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum SolverExecution { + Native { implementation: &'static str }, + Ilp { reduction_path: Vec }, + BruteForce, +} + +/// Type-erased result returned by deterministic solver dispatch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeterministicSolveResult { + pub solver: SolverExecution, + pub config: Option>, + pub evaluation: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum DeterministicSolveError { + #[error("solver capability registry is invalid: {0}")] + InvalidRegistry(&'static RegistryBuildError), + #[error("No ILP pipeline is registered for {0}")] + MissingIlpCapability(String), + #[error("{solver} found no solution for {problem}")] + NoSolution { + solver: &'static str, + problem: String, + }, +} + +fn problem_key(problem: &LoadedDynProblem) -> ExactProblemKey { + ExactProblemKey::new(problem.problem_name(), problem.variant_map()) +} + +fn solve_native( + problem: &LoadedDynProblem, + registration: &'static NativeSolverRegistration, +) -> Result { + let config = (registration.solve_fn)(problem.as_any()).ok_or_else(|| { + DeterministicSolveError::NoSolution { + solver: "native solver", + problem: problem_key(problem).label(), + } + })?; + let evaluation = problem.evaluate_dyn(&config); + Ok(DeterministicSolveResult { + solver: SolverExecution::Native { + implementation: registration.implementation, + }, + config: Some(config), + evaluation, + }) +} + +#[cfg(feature = "ilp-solver")] +fn solve_ilp( + problem: &LoadedDynProblem, + pipeline: &CompiledIlpPipeline, +) -> Result { + let config = pipeline + .solve(problem.as_any(), &super::ILPSolver::new()) + .map_err(|_| DeterministicSolveError::NoSolution { + solver: "ILP solver", + problem: problem_key(problem).label(), + })?; + let evaluation = problem.evaluate_dyn(&config); + Ok(DeterministicSolveResult { + solver: SolverExecution::Ilp { + reduction_path: pipeline.path_labels(), + }, + config: Some(config), + evaluation, + }) +} + +fn solve_brute_force(problem: &LoadedDynProblem) -> DeterministicSolveResult { + let evaluation = problem.solve_brute_force_value(); + let config = problem + .solve_brute_force_witness() + .map(|(config, _)| config); + DeterministicSolveResult { + solver: SolverExecution::BruteForce, + config, + evaluation, + } +} + +/// Solve a loaded problem using deterministic exact-variant dispatch. +/// +/// Default dispatch is native, then the registered fixed ILP pipeline, then +/// brute force. Once selected, backend failure is returned without fallback. +pub fn solve_deterministically( + problem: &LoadedDynProblem, + request: SolverRequest, +) -> Result { + let registry = + solver_capability_registry().map_err(DeterministicSolveError::InvalidRegistry)?; + let key = problem_key(problem); + let capabilities = registry.lookup(&key); + + match request { + SolverRequest::BruteForce => Ok(solve_brute_force(problem)), + SolverRequest::Ilp => { + let pipeline = capabilities + .ilp + .ok_or_else(|| DeterministicSolveError::MissingIlpCapability(key.label()))?; + #[cfg(feature = "ilp-solver")] + { + solve_ilp(problem, pipeline) + } + #[cfg(not(feature = "ilp-solver"))] + { + let _ = pipeline; + Err(DeterministicSolveError::MissingIlpCapability(key.label())) + } + } + SolverRequest::Default => { + if let Some(native) = capabilities.native { + return solve_native(problem, native); + } + if let Some(pipeline) = capabilities.ilp { + #[cfg(feature = "ilp-solver")] + { + return solve_ilp(problem, pipeline); + } + #[cfg(not(feature = "ilp-solver"))] + let _ = pipeline; + } + Ok(solve_brute_force(problem)) + } + } +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/resolver.rs"] +mod tests; diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 43dc121f4..57d696277 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -502,10 +502,8 @@ fn model_specs_are_self_consistent() { #[cfg(feature = "ilp-solver")] #[test] fn model_specs_are_optimal() { - use crate::registry::find_variant_entry; - use crate::solvers::ILPSolver; - - let ilp_solver = ILPSolver::new(); + use crate::registry::{find_variant_entry, load_dyn}; + use crate::solvers::{solve_deterministically, SolverRequest}; let specs = crate::models::graph::canonical_model_example_specs() .into_iter() @@ -520,13 +518,19 @@ fn model_specs_are_optimal() { // Try brute force first for small instances (fast, avoids expensive ILP chains) let dims = spec.instance.dims_dyn(); let log_space: f64 = dims.iter().map(|&d| (d as f64).log2()).sum(); + let solve_registered_ilp = || { + let loaded = load_dyn(name, &variant, spec.instance.serialize_json()).ok()?; + solve_deterministically(&loaded, SolverRequest::Ilp) + .ok()? + .config + }; let best_config = if log_space <= 20.0 { find_variant_entry(name, &variant) .and_then(|entry| (entry.solve_witness_fn)(spec.instance.as_any())) .map(|(config, _)| config) - .or_else(|| ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any())) + .or_else(solve_registered_ilp) } else { - ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any()) + solve_registered_ilp() }; if let Some(best_config) = best_config { diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index 45184be81..2f540040e 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -1,8 +1,6 @@ use crate::models::misc::TimetableDesign; use crate::solvers::BruteForce; use crate::traits::Problem; -#[cfg(feature = "ilp-solver")] -use std::collections::BTreeMap; fn timetable_design_flat_index( num_tasks: usize, @@ -130,20 +128,18 @@ fn test_timetable_design_bruteforce_solver_finds_solution() { assert!(problem.evaluate(&solution.unwrap())); } -#[cfg(feature = "ilp-solver")] #[test] -fn test_timetable_design_issue_example_is_solved_via_ilp_solver_dispatch() { +fn test_timetable_design_issue_example_is_solved_via_native_backend() { let problem = super::issue_example_problem(); - let solution = crate::solvers::ILPSolver::new() - .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) - .expect("expected ILP solver dispatch to find a satisfying timetable"); + let solution = problem + .solve_via_required_assignments() + .expect("expected native backend to find a satisfying timetable"); assert!(problem.evaluate(&solution)); } -#[cfg(feature = "ilp-solver")] #[test] -fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { +fn test_timetable_design_unsat_instance_returns_none_via_native_backend() { let problem = TimetableDesign::new( 1, 2, @@ -153,9 +149,7 @@ fn test_timetable_design_unsat_instance_returns_none_via_ilp_solver_dispatch() { vec![vec![1], vec![1]], ); - assert!(crate::solvers::ILPSolver::new() - .solve_via_reduction("TimetableDesign", &BTreeMap::new(), &problem) - .is_none()); + assert!(problem.solve_via_required_assignments().is_none()); } #[test] diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 310ab6fa2..28c6d6120 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -266,45 +266,30 @@ fn test_ilp_with_time_limit() { } #[test] -fn test_ilp_solve_via_reduction_success() { +fn test_registered_ilp_pipeline_success() { use crate::models::graph::MaximumIndependentSet; + use crate::registry::load_dyn; + use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; use crate::topology::SimpleGraph; use std::collections::BTreeMap; - let solver = ILPSolver::new(); let problem = MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1)]), vec![1i32; 3]); let variant = BTreeMap::from([ ("graph".to_string(), "SimpleGraph".to_string()), ("weight".to_string(), "i32".to_string()), ]); - let result = solver.try_solve_via_reduction("MaximumIndependentSet", &variant, &problem); - assert!(result.is_ok()); - let sol = result.unwrap(); - let eval = problem.evaluate(&sol); + let loaded = load_dyn( + "MaximumIndependentSet", + &variant, + serde_json::to_value(&problem).unwrap(), + ) + .unwrap(); + let result = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert!(matches!(result.solver, SolverExecution::Ilp { .. })); + let eval = problem.evaluate(result.config.as_ref().unwrap()); assert!(eval.is_valid()); } -#[test] -fn test_ilp_solve_via_reduction_no_path() { - use std::collections::BTreeMap; - - // Use a problem name that doesn't exist in the graph - let solver = ILPSolver::new(); - let ilp = ILP::::new( - 2, - vec![LinearConstraint::le(vec![(0, 1.0), (1, 1.0)], 1.0)], - vec![(0, 1.0)], - ObjectiveSense::Maximize, - ); - // solve_via_reduction on an ILP itself should succeed directly - let result = solver.try_solve_via_reduction( - "ILP", - &BTreeMap::from([("type".to_string(), "bool".to_string())]), - &ilp, - ); - assert!(result.is_ok()); -} - #[test] fn test_ilp_solve_dyn_bool() { let solver = ILPSolver::new(); @@ -338,53 +323,3 @@ fn test_ilp_solve_dyn_unknown_type_returns_none() { let result = solver.solve_dyn(¬_ilp as &dyn std::any::Any); assert!(result.is_none()); } - -#[test] -fn test_ilp_supports_direct_dyn() { - let solver = ILPSolver::new(); - let ilp_bool = ILP::::empty(); - let ilp_i32 = ILP::::new(1, vec![], vec![], ObjectiveSense::Maximize); - let not_ilp: i32 = 42; - - assert!(solver.supports_direct_dyn(&ilp_bool as &dyn std::any::Any)); - assert!(solver.supports_direct_dyn(&ilp_i32 as &dyn std::any::Any)); - assert!(!solver.supports_direct_dyn(¬_ilp as &dyn std::any::Any)); -} - -#[test] -fn test_solve_via_reduction_error_display() { - use crate::solvers::ilp::SolveViaReductionError; - - let err = SolveViaReductionError::WitnessPathRequired { - name: "Foo".to_string(), - }; - assert!(err.to_string().contains("witness-capable")); - assert!(err.to_string().contains("Foo")); - - let err = SolveViaReductionError::NoReductionPath { - name: "Bar".to_string(), - }; - assert!(err.to_string().contains("No reduction path")); - assert!(err.to_string().contains("Bar")); - - let err = SolveViaReductionError::NoSolution { - name: "Baz".to_string(), - }; - assert!(err.to_string().contains("no solution")); - assert!(err.to_string().contains("Baz")); - - // std::error::Error is implemented - let _: &dyn std::error::Error = &err; -} - -#[test] -fn test_solve_via_reduction_returns_none_for_no_path() { - let solver = ILPSolver::new(); - let not_ilp: i32 = 42; - let result = solver.solve_via_reduction( - "NonexistentProblem", - &std::collections::BTreeMap::new(), - ¬_ilp as &dyn std::any::Any, - ); - assert!(result.is_none()); -} diff --git a/src/unit_tests/solvers/customized/solver.rs b/src/unit_tests/solvers/native/solver.rs similarity index 76% rename from src/unit_tests/solvers/customized/solver.rs rename to src/unit_tests/solvers/native/solver.rs index 09127b0d5..dabfd8531 100644 --- a/src/unit_tests/solvers/customized/solver.rs +++ b/src/unit_tests/solvers/native/solver.rs @@ -1,9 +1,33 @@ use crate::config::DimsIterator; use crate::models::graph::{PartialFeedbackEdgeSet, RootedTreeArrangement}; -use crate::solvers::CustomizedSolver; +use crate::solvers::registry::solver_capability_registry; +use crate::solvers::ExactProblemKey; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; +struct NativeTestSolver; + +impl NativeTestSolver { + fn new() -> Self { + Self + } + + fn solve_dyn(&self, problem: &P) -> Option> { + let key = ExactProblemKey::new( + P::NAME, + P::variant() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + ); + solver_capability_registry() + .unwrap() + .lookup(&key) + .native + .and_then(|registration| (registration.solve_fn)(problem)) + } +} + fn all_simple_graphs(num_vertices: usize) -> impl Iterator { let candidate_edges: Vec<(usize, usize)> = (0..num_vertices) .flat_map(|u| ((u + 1)..num_vertices).map(move |v| (u, v))) @@ -37,22 +61,22 @@ fn exact_rooted_tree_arrangement_min_stretch(graph: &SimpleGraph) -> Option Vec<(&'static str, &'static str)> { + Vec::new() +} + +fn no_solution(_: &dyn std::any::Any) -> Option> { + None +} + +static NATIVE_A: NativeSolverRegistration = NativeSolverRegistration { + source_name: "Source", + source_variant_fn: source_variant, + implementation: "native-a", + solve_fn: no_solution, +}; +static NATIVE_B: NativeSolverRegistration = NativeSolverRegistration { + source_name: "Source", + source_variant_fn: source_variant, + implementation: "native-b", + solve_fn: no_solution, +}; + +#[test] +fn solver_capability_registry_constructs_without_graph_search() { + solver_capability_registry().expect("production solver registrations must be valid"); +} + +#[test] +fn exact_problem_key_has_canonical_label() { + let key = ExactProblemKey::new( + "MaximumIndependentSet", + BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]), + ); + assert_eq!(key.label(), "MaximumIndependentSet"); +} + +#[test] +fn solver_capability_registry_duplicate_ilp_registration_is_rejected_independent_of_order() { + let variants = BTreeSet::from([ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + )]); + for pipelines in [ + [&DIRECT_BOOL_A, &DIRECT_BOOL_B], + [&DIRECT_BOOL_B, &DIRECT_BOOL_A], + ] { + let error = build_registry(&variants, std::iter::empty(), pipelines, &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::DuplicateIlp(_))); + } +} + +#[test] +fn solver_capability_registry_duplicate_native_registration_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new("Source", BTreeMap::new())]); + let error = + build_registry(&variants, [&NATIVE_A, &NATIVE_B], std::iter::empty(), &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::DuplicateNative(_))); +} + +#[test] +fn solver_capability_registry_pipeline_with_missing_exact_edge_is_rejected() { + let variants = BTreeSet::from([ + ExactProblemKey::new("Source", BTreeMap::new()), + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + ), + ]); + let error = build_registry(&variants, std::iter::empty(), [&MISSING_EDGE], &[]).unwrap_err(); + assert!(matches!( + error, + RegistryBuildError::InvalidEdge { matches: 0, .. } + )); +} + +#[test] +fn solver_capability_registry_pipeline_must_stop_at_first_supported_ilp_node() { + let variants = BTreeSet::from([ + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + ), + ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "i32".to_string())]), + ), + ]); + let error = + build_registry(&variants, std::iter::empty(), [&CONTINUES_AFTER_ILP], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::ContinuesAfterIlp(_))); +} + +#[test] +fn solver_capability_registry_production_registry_has_expected_exact_capability_counts() { + let registry = solver_capability_registry().unwrap(); + assert_eq!(registry.native_entries().count(), 7); + #[cfg(feature = "ilp-solver")] + assert_eq!(registry.ilp_entries().count(), 151); +} + +#[test] +fn solver_capability_registry_does_not_leak_across_exact_variants() { + let registry = solver_capability_registry().unwrap(); + let key = ExactProblemKey::new( + "MinimumCardinalityKey", + BTreeMap::from([("unexpected".to_string(), "variant".to_string())]), + ); + let capabilities = registry.lookup(&key); + assert!(capabilities.native.is_none()); + assert!(capabilities.ilp.is_none()); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn solver_capability_registry_ignores_unrelated_reduction_edges() { + let source = ExactProblemKey::new( + "MaximumIndependentSet", + BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]), + ); + let registration = inventory::iter:: + .into_iter() + .find(|registration| { + registration.path.first().map(ExactProblemKey::from_static) == Some(source.clone()) + }) + .expect("production MIS pipeline must be registered"); + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let all_reductions = reduction_entries(); + let required_reductions = all_reductions + .iter() + .copied() + .filter(|entry| { + path.windows(2) + .any(|pair| edge_key(entry, true) == pair[0] && edge_key(entry, false) == pair[1]) + }) + .collect::>(); + let unrelated = all_reductions + .iter() + .copied() + .find(|entry| { + !required_reductions + .iter() + .any(|required| std::ptr::eq(*required, *entry)) + }) + .expect("catalog must contain an unrelated reduction edge"); + let mut with_unrelated = required_reductions.clone(); + with_unrelated.push(unrelated); + + let variants = registered_variant_keys(); + let minimal = build_registry( + &variants, + std::iter::empty(), + [registration], + &required_reductions, + ) + .unwrap(); + let expanded = build_registry( + &variants, + std::iter::empty(), + [registration], + &with_unrelated, + ) + .unwrap(); + let minimal_pipeline = minimal.lookup(&source).ilp.unwrap(); + let expanded_pipeline = expanded.lookup(&source).ilp.unwrap(); + + assert_eq!(minimal_pipeline.path(), expanded_pipeline.path()); + assert_eq!( + minimal_pipeline + .reducers + .iter() + .map(|reducer| *reducer as usize) + .collect::>(), + expanded_pipeline + .reducers + .iter() + .map(|reducer| *reducer as usize) + .collect::>() + ); +} diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs new file mode 100644 index 000000000..c91c39240 --- /dev/null +++ b/src/unit_tests/solvers/resolver.rs @@ -0,0 +1,193 @@ +#[cfg(feature = "ilp-solver")] +use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::registry::load_dyn; +use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; +use crate::traits::Problem; +use std::collections::BTreeMap; + +#[test] +fn deterministic_solver_dispatch_native_registration_wins_default_dispatch() { + use crate::models::set::MinimumCardinalityKey; + + let problem = MinimumCardinalityKey::new(3, vec![(vec![0], vec![1, 2])]); + let loaded = crate::registry::load_dyn( + MinimumCardinalityKey::NAME, + &BTreeMap::new(), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!( + result.solver, + SolverExecution::Native { + implementation: "fd-minimum-cardinality-key" + } + ); +} + +#[test] +fn deterministic_solver_dispatch_unregistered_ilp_override_is_a_capability_error_without_fallback() +{ + use crate::models::graph::MaxCut; + use crate::topology::SimpleGraph; + + // MaxCut has a discoverable graph route toward ILP, but that route is + // partial for valid negative-weight instances and is intentionally not a + // registered solver pipeline. + let problem = MaxCut::new(SimpleGraph::new(2, vec![(0, 1)]), vec![1i32]); + let loaded = crate::registry::load_dyn( + MaxCut::::NAME, + &BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let default = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!(default.solver, SolverExecution::BruteForce); + let error = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::MissingIlpCapability(_) + )); +} + +#[test] +fn deterministic_solver_dispatch_native_failure_does_not_fall_back() { + use crate::models::misc::AdditionalKey; + + // {0} is the only candidate key and it is already known, so the registered + // native solver has no witness. Brute force can still report the aggregate + // infeasibility result, which lets this test distinguish fallback from error. + let problem = AdditionalKey::new(3, vec![(vec![0], vec![1, 2])], vec![0, 1, 2], vec![vec![0]]); + let loaded = load_dyn( + AdditionalKey::NAME, + &BTreeMap::new(), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::NoSolution { + solver: "native solver", + .. + } + )); + let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); + assert_eq!(brute_force.solver, SolverExecution::BruteForce); + assert!(brute_force.config.is_none()); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_direct_ilp_uses_registered_one_node_pipeline() { + let problem = ILP::::new(0, vec![], vec![], ObjectiveSense::Minimize); + let loaded = load_dyn( + ILP::::NAME, + &BTreeMap::from([("variable".to_string(), "bool".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let result = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert_eq!( + result.solver, + SolverExecution::Ilp { + reduction_path: vec!["ILP".to_string()] + } + ); + assert_eq!(result.config, Some(vec![])); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { + use crate::models::graph::MaximumIndependentSet; + use crate::topology::SimpleGraph; + + let problem = MaximumIndependentSet::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![crate::types::One; 3], + ); + let variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "One".to_string()), + ]); + let loaded = load_dyn( + MaximumIndependentSet::::NAME, + &variant, + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let first = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + let second = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert_eq!(first, second); + let SolverExecution::Ilp { reduction_path } = first.solver else { + panic!("expected ILP execution metadata"); + }; + assert_eq!( + reduction_path, + vec![ + "MaximumIndependentSet", + "MaximumIndependentSet", + "MaximumSetPacking", + "ILP", + ] + ); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_native_default_allows_explicit_ilp_override() { + use crate::models::graph::RootedTreeArrangement; + use crate::topology::SimpleGraph; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3); + let loaded = load_dyn( + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let default = solve_deterministically(&loaded, SolverRequest::Default).unwrap(); + assert!(matches!(default.solver, SolverExecution::Native { .. })); + + let explicit_ilp = solve_deterministically(&loaded, SolverRequest::Ilp).unwrap(); + assert!(matches!(explicit_ilp.solver, SolverExecution::Ilp { .. })); + assert_eq!(default.evaluation, explicit_ilp.evaluation); +} + +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_repeats_each_available_solver_class() { + use crate::models::graph::RootedTreeArrangement; + use crate::topology::SimpleGraph; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3); + let loaded = load_dyn( + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let mut evaluations = Vec::new(); + for request in [ + SolverRequest::Default, + SolverRequest::Ilp, + SolverRequest::BruteForce, + ] { + let first = solve_deterministically(&loaded, request).unwrap(); + let second = solve_deterministically(&loaded, request).unwrap(); + assert_eq!(first, second, "{request:?} changed its witness"); + evaluations.push(first.evaluation); + } + assert!(evaluations.windows(2).all(|pair| pair[0] == pair[1])); +} From ef4a1898eed5dee2c3f86c9d45ca664b61875fdd Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 21 Jul 2026 13:18:43 +0800 Subject: [PATCH 22/31] Simplify solver dispatch and expand coverage --- problemreductions-cli/src/commands/inspect.rs | 54 ++----- problemreductions-cli/src/commands/solve.rs | 28 +--- problemreductions-cli/src/dispatch.rs | 144 ++++++++++++++++- problemreductions-cli/src/mcp/tools.rs | 66 +------- src/solvers/brute_force.rs | 11 +- src/solvers/native/solver.rs | 153 ++++++------------ src/solvers/registry.rs | 31 ++-- src/solvers/resolver.rs | 40 ++--- src/unit_tests/solvers/brute_force.rs | 38 +++++ src/unit_tests/solvers/registry.rs | 128 +++++++++++++++ src/unit_tests/solvers/resolver.rs | 58 ++++++- 11 files changed, 486 insertions(+), 265 deletions(-) diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs index a88a9522d..4d190c6cb 100644 --- a/problemreductions-cli/src/commands/inspect.rs +++ b/problemreductions-cli/src/commands/inspect.rs @@ -1,8 +1,9 @@ -use crate::dispatch::{load_problem, read_input, ProblemJson, ReductionBundle}; +use crate::dispatch::{ + load_problem, read_input, solver_capabilities_view, ProblemJson, ReductionBundle, +}; use crate::output::OutputConfig; use anyhow::Result; use problemreductions::rules::ReductionGraph; -use problemreductions::solvers::{solver_capabilities, ExactProblemKey}; use std::path::Path; pub fn inspect(input: &Path, out: &OutputConfig) -> Result<()> { @@ -41,46 +42,19 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { } text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn())); - let key = ExactProblemKey::new(name, variant.clone()); - let capabilities = solver_capabilities(&key) - .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}"))?; - let native = capabilities.native.as_ref().map(|entry| { - serde_json::json!({ - "implementation": entry.implementation, - }) - }); - let ilp = capabilities.ilp.as_ref().map(|pipeline| { - serde_json::json!({ - "reduction_path": pipeline.path_labels(), - }) - }); - let default_solver = if capabilities.native.is_some() { - "native" - } else if capabilities.ilp.is_some() { - "ilp" - } else { - "brute-force" - }; - let mut solvers = Vec::new(); - if capabilities.native.is_some() { - solvers.push("native"); - } - if capabilities.ilp.is_some() { - solvers.push("ilp"); - } - solvers.push("brute-force"); - text.push_str(&format!("Default solver: {default_solver}\n")); - text.push_str(&format!("Solvers: {}\n", solvers.join(", "))); - if let Some(native) = capabilities.native.as_ref() { + let solver_view = solver_capabilities_view(&problem)?; + text.push_str(&format!("Default solver: {}\n", solver_view.default_solver)); + text.push_str(&format!("Solvers: {}\n", solver_view.solvers.join(", "))); + if let Some(native) = solver_view.capabilities.native.as_ref() { text.push_str(&format!( "Native implementation: {}\n", native.implementation )); } - if let Some(ilp) = capabilities.ilp.as_ref() { + if let Some(ilp) = solver_view.capabilities.ilp.as_ref() { text.push_str(&format!( "ILP pipeline: {}\n", - ilp.path_labels().join(" -> ") + ilp.reduction_path.join(" -> ") )); } @@ -97,13 +71,9 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> { "variant": variant, "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), - "solvers": solvers, - "default_solver": default_solver, - "solver_capabilities": { - "native": native, - "ilp": ilp, - "brute_force": true, - }, + "solvers": solver_view.solvers, + "default_solver": solver_view.default_solver, + "solver_capabilities": solver_view.capabilities, "reduces_to": targets, }); diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 411ad22fa..3b6ba801e 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -1,4 +1,7 @@ -use crate::dispatch::{load_problem, read_input, BundleReplay, ProblemJson, ReductionBundle}; +use crate::dispatch::{ + load_problem, read_input, solve_result_json, solver_request, BundleReplay, ProblemJson, + ReductionBundle, +}; use crate::output::OutputConfig; use anyhow::{Context, Result}; use problemreductions::solvers::{DeterministicSolveResult, SolverExecution, SolverRequest}; @@ -52,18 +55,6 @@ fn solve_result_text(problem: &str, result: &DeterministicSolveResult) -> String text } -fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> serde_json::Value { - let mut json = serde_json::json!({ - "problem": problem, - "solver": &result.solver, - "evaluation": result.evaluation, - }); - if let Some(config) = &result.config { - json["solution"] = serde_json::json!(config); - } - json -} - fn plain_problem_output( problem: &str, result: &DeterministicSolveResult, @@ -74,17 +65,6 @@ fn plain_problem_output( ) } -fn solver_request(solver_name: Option<&str>) -> Result { - match solver_name { - None => Ok(SolverRequest::Default), - Some("ilp") => Ok(SolverRequest::Ilp), - Some("brute-force") => Ok(SolverRequest::BruteForce), - Some(other) => { - anyhow::bail!("Unknown solver: {other}. Available solver overrides: brute-force, ilp") - } - } -} - pub fn solve( input: &Path, solver_name: Option<&str>, diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 4d2ac34ab..5bd39ed75 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -2,7 +2,8 @@ use anyhow::{Context, Result}; use problemreductions::registry::{DynProblem, LoadedDynProblem}; use problemreductions::rules::ReductionGraph; use problemreductions::solvers::{ - solve_deterministically, DeterministicSolveResult, SolverRequest, + solve_deterministically, solver_capabilities, DeterministicSolveResult, ExactProblemKey, + SolverRequest, }; use serde_json::Value; use std::any::Any; @@ -46,6 +47,90 @@ impl LoadedProblem { } } +#[derive(Clone, Debug, serde::Serialize)] +pub struct NativeSolverCapabilityView { + pub implementation: &'static str, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct IlpSolverCapabilityView { + pub reduction_path: Vec, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct SolverCapabilityDetailsView { + pub native: Option, + pub ilp: Option, + pub brute_force: bool, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct SolverCapabilitiesView { + pub solvers: Vec<&'static str>, + pub default_solver: &'static str, + pub capabilities: SolverCapabilityDetailsView, +} + +pub fn solver_capabilities_view(problem: &LoadedProblem) -> Result { + let key = ExactProblemKey::new(problem.problem_name(), problem.variant_map()); + let registered = solver_capabilities(&key) + .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}"))?; + let native = registered.native.map(|entry| NativeSolverCapabilityView { + implementation: entry.implementation, + }); + let ilp = registered.ilp.map(|pipeline| IlpSolverCapabilityView { + reduction_path: pipeline.path_labels(), + }); + let default_solver = if native.is_some() { + "native" + } else if ilp.is_some() { + "ilp" + } else { + "brute-force" + }; + let mut solvers = Vec::with_capacity(3); + if native.is_some() { + solvers.push("native"); + } + if ilp.is_some() { + solvers.push("ilp"); + } + solvers.push("brute-force"); + + Ok(SolverCapabilitiesView { + solvers, + default_solver, + capabilities: SolverCapabilityDetailsView { + native, + ilp, + brute_force: true, + }, + }) +} + +pub fn solver_request(solver_name: Option<&str>) -> Result { + match solver_name { + None => Ok(SolverRequest::Default), + Some("ilp") => Ok(SolverRequest::Ilp), + Some("brute-force") => Ok(SolverRequest::BruteForce), + Some(other) => { + anyhow::bail!("Unknown solver: {other}. Available solver overrides: brute-force, ilp") + } + } +} + +pub fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> serde_json::Value { + let mut json = serde_json::json!({ + "problem": problem, + "solver": &result.solver, + "evaluation": result.evaluation, + }); + if let Some(config) = &result.config { + json["solution"] = serde_json::json!(config); + } + json +} + /// A validated reduction bundle ready to replay: /// source, target, and the reconstructed reduction chain. Construct via /// [`BundleReplay::prepare`]. All three CLI/MCP bundle workflows @@ -362,4 +447,61 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn solver_request_accepts_only_documented_overrides() { + assert_eq!(solver_request(None).unwrap(), SolverRequest::Default); + assert_eq!(solver_request(Some("ilp")).unwrap(), SolverRequest::Ilp); + assert_eq!( + solver_request(Some("brute-force")).unwrap(), + SolverRequest::BruteForce + ); + for rejected in ["auto", "customized", "native", "implementation-id"] { + let error = solver_request(Some(rejected)).unwrap_err(); + assert!(error.to_string().contains(rejected), "{error}"); + } + } + + #[test] + fn solve_result_json_preserves_structured_solver_contract() { + let result = DeterministicSolveResult { + solver: problemreductions::solvers::SolverExecution::Ilp { + reduction_path: vec!["Source".to_string(), "ILP".to_string()], + }, + config: Some(vec![1, 0]), + evaluation: "Max(1)".to_string(), + }; + let json = solve_result_json("Source", &result); + + assert_eq!(json["problem"], "Source"); + assert_eq!(json["solver"]["kind"], "ilp"); + assert_eq!( + json["solver"]["reduction_path"], + serde_json::json!(["Source", "ILP"]) + ); + assert_eq!(json["solution"], serde_json::json!([1, 0])); + assert!(json.get("reduced_to").is_none()); + } + + #[test] + #[cfg(any(feature = "highs", feature = "cplex", feature = "lp-solvers"))] + fn solver_capabilities_view_centralizes_default_and_available_order() { + use problemreductions::models::graph::RootedTreeArrangement; + use problemreductions::Problem; + + let problem = RootedTreeArrangement::new(SimpleGraph::new(2, vec![(0, 1)]), 1); + let loaded = load_problem( + RootedTreeArrangement::::NAME, + &BTreeMap::from([("graph".to_string(), "SimpleGraph".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + let view = solver_capabilities_view(&loaded).unwrap(); + + assert_eq!(view.default_solver, "native"); + assert_eq!(view.solvers, ["native", "ilp", "brute-force"]); + assert!(view.capabilities.native.is_some()); + assert!(view.capabilities.ilp.is_some()); + assert!(view.capabilities.brute_force); + } } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index e50498fd3..bd6fbceec 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -10,9 +10,7 @@ use problemreductions::registry::collect_schemas; use problemreductions::rules::{ CustomCost, MinimizeSteps, ReductionGraph, ReductionMode, TraversalFlow, }; -use problemreductions::solvers::{ - solver_capabilities, DeterministicSolveResult, ExactProblemKey, SolverRequest, -}; +use problemreductions::solvers::SolverRequest; use problemreductions::topology::{ Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph, }; @@ -24,8 +22,8 @@ use serde::Serialize; use std::collections::BTreeMap; use crate::dispatch::{ - load_problem, serialize_any_problem, BundleReplay, PathStep, ProblemJson, ProblemJsonOutput, - ReductionBundle, + load_problem, serialize_any_problem, solve_result_json, solver_capabilities_view, + solver_request, BundleReplay, PathStep, ProblemJson, ProblemJsonOutput, ReductionBundle, }; use crate::problem_name::{aliases_for, resolve_problem_ref, unknown_problem_error}; @@ -755,32 +753,7 @@ impl McpServer { let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); targets.sort(); targets.dedup(); - let key = ExactProblemKey::new(name, variant.clone()); - let capabilities = solver_capabilities(&key) - .map_err(|error| anyhow::anyhow!("solver capability registry is invalid: {error}"))?; - let native = capabilities - .native - .as_ref() - .map(|entry| serde_json::json!({"implementation": entry.implementation})); - let ilp = capabilities - .ilp - .as_ref() - .map(|pipeline| serde_json::json!({"reduction_path": pipeline.path_labels()})); - let default_solver = if capabilities.native.is_some() { - "native" - } else if capabilities.ilp.is_some() { - "ilp" - } else { - "brute-force" - }; - let mut solvers = Vec::new(); - if capabilities.native.is_some() { - solvers.push("native"); - } - if capabilities.ilp.is_some() { - solvers.push("ilp"); - } - solvers.push("brute-force"); + let solver_view = solver_capabilities_view(&problem)?; let result = serde_json::json!({ "kind": "problem", @@ -788,13 +761,9 @@ impl McpServer { "variant": variant, "size_fields": size_fields, "num_variables": problem.num_variables_dyn(), - "solvers": solvers, - "default_solver": default_solver, - "solver_capabilities": { - "native": native, - "ilp": ilp, - "brute_force": true, - }, + "solvers": solver_view.solvers, + "default_solver": solver_view.default_solver, + "solver_capabilities": solver_view.capabilities, "reduces_to": targets, }); Ok(serde_json::to_string_pretty(&result)?) @@ -900,14 +869,7 @@ impl McpServer { solver: Option<&str>, timeout: Option, ) -> anyhow::Result { - let request = match solver { - None => SolverRequest::Default, - Some("ilp") => SolverRequest::Ilp, - Some("brute-force") => SolverRequest::BruteForce, - Some(other) => anyhow::bail!( - "Unknown solver: {other}. Available solver overrides: brute-force, ilp" - ), - }; + let request = solver_request(solver)?; let json: serde_json::Value = serde_json::from_str(problem_json)?; let timeout_secs = timeout.unwrap_or(0); @@ -1176,18 +1138,6 @@ fn ser(problem: T) -> anyhow::Result { util::ser(problem) } -fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> serde_json::Value { - let mut json = serde_json::json!({ - "problem": problem, - "solver": &result.solver, - "evaluation": result.evaluation, - }); - if let Some(config) = &result.config { - json["solution"] = serde_json::json!(config); - } - json -} - fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { util::variant_map(pairs) } diff --git a/src/solvers/brute_force.rs b/src/solvers/brute_force.rs index caf8ca817..9fca85076 100644 --- a/src/solvers/brute_force.rs +++ b/src/solvers/brute_force.rs @@ -25,7 +25,16 @@ impl BruteForce { P: Problem, P::Value: Aggregate, { - self.find_all_witnesses(problem).into_iter().next() + let total = self.solve(problem); + + if !P::Value::supports_witnesses() { + return None; + } + + DimsIterator::new(problem.dims()).find(|config| { + let value = problem.evaluate(config); + P::Value::contributes_to_witnesses(&value, &total) + }) } /// Find all witness configurations for witness-supporting aggregates. diff --git a/src/solvers/native/solver.rs b/src/solvers/native/solver.rs index c187df8fc..de5dc46a0 100644 --- a/src/solvers/native/solver.rs +++ b/src/solvers/native/solver.rs @@ -12,114 +12,55 @@ use crate::topology::SimpleGraph; use crate::traits::Problem; use std::collections::HashSet; -fn no_variant() -> Vec<(&'static str, &'static str)> { - Vec::new() -} - -fn simple_graph_variant() -> Vec<(&'static str, &'static str)> { - vec![("graph", "SimpleGraph")] -} - -fn downcast_solve( - any: &dyn std::any::Any, - solve: fn(&P) -> Option>, -) -> Option> { - let problem = any - .downcast_ref::

() - .expect("native solver registration received the wrong concrete type"); - solve(problem) -} - -fn solve_minimum_cardinality_key_dyn(any: &dyn std::any::Any) -> Option> { - downcast_solve(any, solve_minimum_cardinality_key) -} - -fn solve_additional_key_dyn(any: &dyn std::any::Any) -> Option> { - downcast_solve(any, solve_additional_key) -} - -fn solve_prime_attribute_name_dyn(any: &dyn std::any::Any) -> Option> { - downcast_solve(any, solve_prime_attribute_name) -} - -fn solve_bcnf_violation_dyn(any: &dyn std::any::Any) -> Option> { - downcast_solve(any, solve_bcnf_violation) -} - -fn solve_partial_feedback_edge_set_dyn(any: &dyn std::any::Any) -> Option> { - downcast_solve(any, super::partial_feedback_edge_set::find_witness) -} - -fn solve_rooted_tree_arrangement_dyn(any: &dyn std::any::Any) -> Option> { - downcast_solve(any, super::rooted_tree_arrangement::find_witness) -} - -fn solve_timetable_design_dyn(any: &dyn std::any::Any) -> Option> { - downcast_solve(any, TimetableDesign::solve_via_required_assignments) -} - -inventory::submit! { - NativeSolverRegistration { - source_name: MinimumCardinalityKey::NAME, - source_variant_fn: no_variant, - implementation: "fd-minimum-cardinality-key", - solve_fn: solve_minimum_cardinality_key_dyn, - } -} - -inventory::submit! { - NativeSolverRegistration { - source_name: AdditionalKey::NAME, - source_variant_fn: no_variant, - implementation: "fd-additional-key", - solve_fn: solve_additional_key_dyn, - } -} - -inventory::submit! { - NativeSolverRegistration { - source_name: PrimeAttributeName::NAME, - source_variant_fn: no_variant, - implementation: "fd-prime-attribute-name", - solve_fn: solve_prime_attribute_name_dyn, - } -} - -inventory::submit! { - NativeSolverRegistration { - source_name: BoyceCoddNormalFormViolation::NAME, - source_variant_fn: no_variant, - implementation: "fd-bcnf-violation", - solve_fn: solve_bcnf_violation_dyn, - } -} - -inventory::submit! { - NativeSolverRegistration { - source_name: PartialFeedbackEdgeSet::::NAME, - source_variant_fn: simple_graph_variant, - implementation: "partial-feedback-edge-set", - solve_fn: solve_partial_feedback_edge_set_dyn, - } -} - -inventory::submit! { - NativeSolverRegistration { - source_name: RootedTreeArrangement::::NAME, - source_variant_fn: simple_graph_variant, - implementation: "rooted-tree-arrangement", - solve_fn: solve_rooted_tree_arrangement_dyn, - } +macro_rules! register_native_solver { + ($problem:ty, $implementation:literal, $solve:path) => { + inventory::submit! { + NativeSolverRegistration { + source_name: <$problem as Problem>::NAME, + source_variant_fn: <$problem as Problem>::variant, + implementation: $implementation, + solve_fn: |any| { + let problem = any.downcast_ref::<$problem>().expect( + "native solver registration received the wrong concrete type", + ); + $solve(problem) + }, + } + } + }; } -inventory::submit! { - NativeSolverRegistration { - source_name: TimetableDesign::NAME, - source_variant_fn: no_variant, - implementation: "timetable-required-assignments", - solve_fn: solve_timetable_design_dyn, - } -} +register_native_solver!( + MinimumCardinalityKey, + "fd-minimum-cardinality-key", + solve_minimum_cardinality_key +); +register_native_solver!(AdditionalKey, "fd-additional-key", solve_additional_key); +register_native_solver!( + PrimeAttributeName, + "fd-prime-attribute-name", + solve_prime_attribute_name +); +register_native_solver!( + BoyceCoddNormalFormViolation, + "fd-bcnf-violation", + solve_bcnf_violation +); +register_native_solver!( + PartialFeedbackEdgeSet, + "partial-feedback-edge-set", + super::partial_feedback_edge_set::find_witness +); +register_native_solver!( + RootedTreeArrangement, + "rooted-tree-arrangement", + super::rooted_tree_arrangement::find_witness +); +register_native_solver!( + TimetableDesign, + "timetable-required-assignments", + TimetableDesign::solve_via_required_assignments +); /// Solve MinimumCardinalityKey: find a minimal key with smallest cardinality. /// diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index 5511d2fc1..28b7e9b8b 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -277,6 +277,18 @@ fn build_registry( reductions: &[&'static ReductionEntry], ) -> Result { let mut registry = SolverCapabilityRegistry::default(); + let mut reduction_index = + BTreeMap::<(ExactProblemKey, ExactProblemKey), Vec<&'static ReductionEntry>>::new(); + for entry in reductions + .iter() + .copied() + .filter(|entry| entry.capabilities.witness && entry.reduce_fn.is_some()) + { + reduction_index + .entry((edge_key(entry, true), edge_key(entry, false))) + .or_default() + .push(entry); + } for native in native_entries { let source = native.source_key(); @@ -316,15 +328,10 @@ fn build_registry( let mut reducers = Vec::with_capacity(path.len().saturating_sub(1)); for pair in path.windows(2) { - let matches = reductions - .iter() - .filter(|entry| { - entry.capabilities.witness - && entry.reduce_fn.is_some() - && edge_key(entry, true) == pair[0] - && edge_key(entry, false) == pair[1] - }) - .collect::>(); + let matches = reduction_index + .get(&(pair[0].clone(), pair[1].clone())) + .map(Vec::as_slice) + .unwrap_or_default(); if matches.len() != 1 { return Err(RegistryBuildError::InvalidEdge { source_label: pair[0].label(), @@ -332,7 +339,11 @@ fn build_registry( matches: matches.len(), }); } - reducers.push(matches[0].reduce_fn.expect("filtered above")); + reducers.push( + matches[0] + .reduce_fn + .expect("indexed only entries with reduce_fn"), + ); } if registry diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs index 929140c0e..dde6d79f5 100644 --- a/src/solvers/resolver.rs +++ b/src/solvers/resolver.rs @@ -39,11 +39,10 @@ pub enum DeterministicSolveError { InvalidRegistry(&'static RegistryBuildError), #[error("No ILP pipeline is registered for {0}")] MissingIlpCapability(String), - #[error("{solver} found no solution for {problem}")] - NoSolution { - solver: &'static str, - problem: String, - }, + #[error("native solver found no solution for {problem}")] + NativeNoSolution { problem: String }, + #[error("ILP solver found no solution for {problem}")] + IlpNoSolution { problem: String }, } fn problem_key(problem: &LoadedDynProblem) -> ExactProblemKey { @@ -55,8 +54,7 @@ fn solve_native( registration: &'static NativeSolverRegistration, ) -> Result { let config = (registration.solve_fn)(problem.as_any()).ok_or_else(|| { - DeterministicSolveError::NoSolution { - solver: "native solver", + DeterministicSolveError::NativeNoSolution { problem: problem_key(problem).label(), } })?; @@ -77,8 +75,7 @@ fn solve_ilp( ) -> Result { let config = pipeline .solve(problem.as_any(), &super::ILPSolver::new()) - .map_err(|_| DeterministicSolveError::NoSolution { - solver: "ILP solver", + .map_err(|_| DeterministicSolveError::IlpNoSolution { problem: problem_key(problem).label(), })?; let evaluation = problem.evaluate_dyn(&config); @@ -92,14 +89,17 @@ fn solve_ilp( } fn solve_brute_force(problem: &LoadedDynProblem) -> DeterministicSolveResult { - let evaluation = problem.solve_brute_force_value(); - let config = problem - .solve_brute_force_witness() - .map(|(config, _)| config); - DeterministicSolveResult { - solver: SolverExecution::BruteForce, - config, - evaluation, + match problem.solve_brute_force_witness() { + Some((config, evaluation)) => DeterministicSolveResult { + solver: SolverExecution::BruteForce, + config: Some(config), + evaluation, + }, + None => DeterministicSolveResult { + solver: SolverExecution::BruteForce, + config: None, + evaluation: problem.solve_brute_force_value(), + }, } } @@ -111,13 +111,17 @@ pub fn solve_deterministically( problem: &LoadedDynProblem, request: SolverRequest, ) -> Result { + if request == SolverRequest::BruteForce { + return Ok(solve_brute_force(problem)); + } + let registry = solver_capability_registry().map_err(DeterministicSolveError::InvalidRegistry)?; let key = problem_key(problem); let capabilities = registry.lookup(&key); match request { - SolverRequest::BruteForce => Ok(solve_brute_force(problem)), + SolverRequest::BruteForce => unreachable!("handled before registry initialization"), SolverRequest::Ilp => { let pipeline = capabilities .ilp diff --git a/src/unit_tests/solvers/brute_force.rs b/src/unit_tests/solvers/brute_force.rs index 75d31d717..725f494d7 100644 --- a/src/unit_tests/solvers/brute_force.rs +++ b/src/unit_tests/solvers/brute_force.rs @@ -2,6 +2,8 @@ use super::*; use crate::solvers::Solver; use crate::traits::Problem; use crate::types::{Max, Min, Or, Sum}; +use std::cell::Cell; +use std::rc::Rc; #[derive(Clone)] struct MaxSumProblem { @@ -87,6 +89,29 @@ struct SumProblem { weights: Vec, } +#[derive(Clone)] +struct CountingSatProblem { + evaluations: Rc>, +} + +impl Problem for CountingSatProblem { + const NAME: &'static str = "CountingSatProblem"; + type Value = Or; + + fn dims(&self) -> Vec { + vec![2, 2] + } + + fn evaluate(&self, config: &[usize]) -> Self::Value { + self.evaluations.set(self.evaluations.get() + 1); + Or(config == [0, 0]) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + vec![] + } +} + impl Problem for SumProblem { const NAME: &'static str = "SumProblem"; type Value = Sum; @@ -162,6 +187,19 @@ fn test_solver_find_witness_for_satisfaction_problem() { assert_eq!(problem.evaluate(&witness.unwrap()), Or(true)); } +#[test] +fn test_solver_find_witness_stops_after_first_optimal_configuration() { + let evaluations = Rc::new(Cell::new(0)); + let problem = CountingSatProblem { + evaluations: Rc::clone(&evaluations), + }; + + assert_eq!(BruteForce::new().find_witness(&problem), Some(vec![0, 0])); + // Four evaluations compute the aggregate; the witness pass stops at the + // first configuration instead of collecting every optimal witness. + assert_eq!(evaluations.get(), 5); +} + #[test] fn test_solver_find_witness_returns_none_for_sum_problem() { let problem = SumProblem { diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 1b0f936d9..909be9442 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -41,6 +41,13 @@ static CONTINUES_AFTER_ILP: IlpPipelineRegistration = IlpPipelineRegistration { }, ], }; +static EMPTY_PIPELINE: IlpPipelineRegistration = IlpPipelineRegistration { path: &[] }; +static UNSUPPORTED_TARGET: IlpPipelineRegistration = IlpPipelineRegistration { + path: &[StaticProblemStep { + name: "Source", + variant: NO_VARIANT, + }], +}; fn source_variant() -> Vec<(&'static str, &'static str)> { Vec::new() @@ -103,6 +110,37 @@ fn solver_capability_registry_duplicate_native_registration_is_rejected() { assert!(matches!(error, RegistryBuildError::DuplicateNative(_))); } +#[test] +fn solver_capability_registry_unknown_native_variant_is_rejected() { + let error = build_registry(&BTreeSet::new(), [&NATIVE_A], std::iter::empty(), &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnknownVariant(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_unknown_pipeline_variant_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new( + "ILP", + BTreeMap::from([("variable".to_string(), "bool".to_string())]), + )]); + let error = build_registry(&variants, std::iter::empty(), [&MISSING_EDGE], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnknownVariant(label) if label == "Source")); +} + +#[test] +fn solver_capability_registry_empty_pipeline_is_rejected() { + let error = + build_registry(&BTreeSet::new(), std::iter::empty(), [&EMPTY_PIPELINE], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::EmptyPipeline)); +} + +#[test] +fn solver_capability_registry_unsupported_pipeline_target_is_rejected() { + let variants = BTreeSet::from([ExactProblemKey::new("Source", BTreeMap::new())]); + let error = + build_registry(&variants, std::iter::empty(), [&UNSUPPORTED_TARGET], &[]).unwrap_err(); + assert!(matches!(error, RegistryBuildError::UnsupportedTarget(label) if label == "Source")); +} + #[test] fn solver_capability_registry_pipeline_with_missing_exact_edge_is_rejected() { let variants = BTreeSet::from([ @@ -144,6 +182,61 @@ fn solver_capability_registry_production_registry_has_expected_exact_capability_ assert_eq!(registry.ilp_entries().count(), 151); } +#[test] +#[cfg(feature = "ilp-solver")] +fn solver_capability_registry_exposes_representative_capability_classes() { + let key = |name: &str, variant: &[(&str, &str)]| { + ExactProblemKey::new( + name, + variant + .iter() + .map(|&(key, value)| (key.to_string(), value.to_string())) + .collect(), + ) + }; + + let native_only = solver_capabilities(&key("TimetableDesign", &[])).unwrap(); + assert_eq!( + native_only.native.unwrap().implementation, + "timetable-required-assignments" + ); + assert!(native_only.ilp.is_none()); + + let direct_ilp = solver_capabilities(&key( + "MaximumClique", + &[("graph", "SimpleGraph"), ("weight", "i32")], + )) + .unwrap(); + assert!(direct_ilp.native.is_none()); + assert_eq!( + direct_ilp.ilp.unwrap().path_labels(), + ["MaximumClique", "ILP"] + ); + + let multihop_ilp = solver_capabilities(&key( + "MaximumIndependentSet", + &[("graph", "SimpleGraph"), ("weight", "One")], + )) + .unwrap(); + assert!(multihop_ilp.ilp.unwrap().path_labels().len() > 2); + + let both = + solver_capabilities(&key("RootedTreeArrangement", &[("graph", "SimpleGraph")])).unwrap(); + assert!(both.native.is_some()); + assert!(both.ilp.is_some()); + + let brute_force_only = solver_capabilities(&key( + "MaxCut", + &[("graph", "SimpleGraph"), ("weight", "i32")], + )) + .unwrap(); + assert!(brute_force_only.native.is_none()); + assert!(brute_force_only.ilp.is_none()); + + let ilp_itself = solver_capabilities(&key("ILP", &[("variable", "bool")])).unwrap(); + assert_eq!(ilp_itself.ilp.unwrap().path_labels(), ["ILP"]); +} + #[test] fn solver_capability_registry_does_not_leak_across_exact_variants() { let registry = solver_capability_registry().unwrap(); @@ -230,3 +323,38 @@ fn solver_capability_registry_ignores_unrelated_reduction_edges() { .collect::>() ); } + +#[test] +#[cfg(feature = "ilp-solver")] +fn solver_capability_registry_ambiguous_exact_edge_is_rejected() { + let registration = inventory::iter:: + .into_iter() + .find(|registration| registration.path.len() == 2) + .expect("production catalog must contain a direct ILP pipeline"); + let path = registration + .path + .iter() + .map(ExactProblemKey::from_static) + .collect::>(); + let reduction = reduction_entries() + .into_iter() + .find(|entry| { + entry.capabilities.witness + && entry.reduce_fn.is_some() + && edge_key(entry, true) == path[0] + && edge_key(entry, false) == path[1] + }) + .expect("direct pipeline must have one witness reduction"); + let error = build_registry( + ®istered_variant_keys(), + std::iter::empty(), + [registration], + &[reduction, reduction], + ) + .unwrap_err(); + + assert!(matches!( + error, + RegistryBuildError::InvalidEdge { matches: 2, .. } + )); +} diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index c91c39240..a0a648171 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -1,5 +1,5 @@ #[cfg(feature = "ilp-solver")] -use crate::models::algebraic::{ObjectiveSense, ILP}; +use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::registry::load_dyn; use crate::solvers::{solve_deterministically, SolverExecution, SolverRequest}; use crate::traits::Problem; @@ -73,10 +73,7 @@ fn deterministic_solver_dispatch_native_failure_does_not_fall_back() { let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); assert!(matches!( error, - crate::solvers::DeterministicSolveError::NoSolution { - solver: "native solver", - .. - } + crate::solvers::DeterministicSolveError::NativeNoSolution { .. } )); let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); assert_eq!(brute_force.solver, SolverExecution::BruteForce); @@ -104,6 +101,57 @@ fn deterministic_solver_dispatch_direct_ilp_uses_registered_one_node_pipeline() assert_eq!(result.config, Some(vec![])); } +#[test] +#[cfg(feature = "ilp-solver")] +fn deterministic_solver_dispatch_ilp_failure_does_not_fall_back() { + let problem = ILP::::new( + 0, + vec![LinearConstraint::le(vec![], -1.0)], + vec![], + ObjectiveSense::Minimize, + ); + let loaded = load_dyn( + ILP::::NAME, + &BTreeMap::from([("variable".to_string(), "bool".to_string())]), + serde_json::to_value(problem).unwrap(), + ) + .unwrap(); + + let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); + assert!(matches!( + error, + crate::solvers::DeterministicSolveError::IlpNoSolution { .. } + )); + let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); + assert_eq!(brute_force.solver, SolverExecution::BruteForce); + assert!(brute_force.config.is_none()); +} + +#[test] +fn deterministic_solver_execution_has_stable_tagged_json_contract() { + assert_eq!( + serde_json::to_value(SolverExecution::Native { + implementation: "native-id" + }) + .unwrap(), + serde_json::json!({"kind": "native", "implementation": "native-id"}) + ); + assert_eq!( + serde_json::to_value(SolverExecution::Ilp { + reduction_path: vec!["Source".to_string(), "ILP".to_string()] + }) + .unwrap(), + serde_json::json!({ + "kind": "ilp", + "reduction_path": ["Source", "ILP"] + }) + ); + assert_eq!( + serde_json::to_value(SolverExecution::BruteForce).unwrap(), + serde_json::json!({"kind": "brute-force"}) + ); +} + #[test] #[cfg(feature = "ilp-solver")] fn deterministic_solver_dispatch_fixed_multihop_pipeline_is_repeatable() { From ce27fc72e8c3e17d8e38f410d539a324dcd773b6 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Tue, 21 Jul 2026 15:06:22 +0800 Subject: [PATCH 23/31] Distinguish ILP solve failures --- docs/src/design.md | 2 +- docs/src/getting-started.md | 9 +- src/solvers/ilp/mod.rs | 2 +- src/solvers/ilp/solver.rs | 90 +++++++++++++++---- src/solvers/mod.rs | 2 +- src/solvers/registry.rs | 17 +--- src/solvers/resolver.rs | 17 ++-- src/unit_tests/rules/acyclicpartition_ilp.rs | 2 +- .../balancedcompletebipartitesubgraph_ilp.rs | 2 +- src/unit_tests/rules/binpacking_ilp.rs | 2 +- .../rules/bottlenecktravelingsalesman_ilp.rs | 2 +- .../boundedcomponentspanningforest_ilp.rs | 2 +- src/unit_tests/rules/clustering_ilp.rs | 2 +- src/unit_tests/rules/coloring_ilp.rs | 6 +- ...onsistencyofdatabasefrequencytables_ilp.rs | 4 +- .../rules/directedhamiltonianpath_ilp.rs | 2 +- .../directedtwocommodityintegralflow_ilp.rs | 4 +- src/unit_tests/rules/eulerianpath_ilp.rs | 2 +- src/unit_tests/rules/factoring_ilp.rs | 2 +- .../rules/feasibleregisterassignment_ilp.rs | 2 +- .../rules/flowshopscheduling_ilp.rs | 2 +- src/unit_tests/rules/graphpartitioning_ilp.rs | 7 +- src/unit_tests/rules/hamiltonianpath_ilp.rs | 2 +- .../rules/integralflowbundles_ilp.rs | 2 +- ...bility_directedtwocommodityintegralflow.rs | 2 +- ...tisfiability_feasibleregisterassignment.rs | 4 +- .../ksatisfiability_preemptivescheduling.rs | 2 +- .../rules/ksatisfiability_timetabledesign.rs | 6 +- src/unit_tests/rules/maximummatching_ilp.rs | 2 +- src/unit_tests/rules/maximumsetpacking_ilp.rs | 2 +- .../rules/minimumedgecostflow_ilp.rs | 2 +- .../rules/minimumfaultdetectiontestset_ilp.rs | 2 +- .../rules/minimummultiwaycut_ilp.rs | 2 +- .../rules/minimumsetcovering_ilp.rs | 2 +- .../rules/minimumweightdecoding_ilp.rs | 2 +- .../rules/monochromatictriangle_ilp.rs | 2 +- src/unit_tests/rules/naesatisfiability_ilp.rs | 2 +- .../numericalmatchingwithtargetsums_ilp.rs | 2 +- .../precedenceconstrainedscheduling_ilp.rs | 2 +- .../rules/preemptivescheduling_ilp.rs | 12 ++- .../rules/registersufficiency_ilp.rs | 2 +- .../resourceconstrainedscheduling_ilp.rs | 2 +- .../rules/rootedtreestorageassignment_ilp.rs | 9 +- src/unit_tests/rules/sat_coloring.rs | 2 +- .../schedulingwithindividualdeadlines_ilp.rs | 2 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 2 +- ...quencingtominimizeweightedtardiness_ilp.rs | 2 +- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 6 +- .../rules/sequencingwithinintervals_ilp.rs | 2 +- ...uencingwithreleasetimesanddeadlines_ilp.rs | 2 +- src/unit_tests/rules/setsplitting_ilp.rs | 2 +- .../shortestweightconstrainedpath_ilp.rs | 4 +- src/unit_tests/rules/steinertree_ilp.rs | 2 +- .../rules/stringtostringcorrection_ilp.rs | 2 +- .../strongconnectivityaugmentation_ilp.rs | 2 +- .../rules/subgraphisomorphism_ilp.rs | 2 +- .../rules/threedimensionalmatching_ilp.rs | 4 +- src/unit_tests/rules/timetabledesign_ilp.rs | 2 +- src/unit_tests/rules/travelingsalesman_ilp.rs | 4 +- .../rules/undirectedflowlowerbounds_ilp.rs | 2 +- .../undirectedtwocommodityintegralflow_ilp.rs | 2 +- src/unit_tests/solvers/ilp/solver.rs | 40 ++++++--- src/unit_tests/solvers/resolver.rs | 5 +- .../unitdiskmapping_algorithms/common.rs | 8 +- .../unitdiskmapping_algorithms/weighted.rs | 2 +- .../suites/register_assignment_reductions.rs | 2 +- 66 files changed, 219 insertions(+), 131 deletions(-) diff --git a/docs/src/design.md b/docs/src/design.md index 7f709edfc..1fec44b46 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -321,7 +321,7 @@ pub trait Solver { | Solver | Description | |--------|-------------| | **BruteForce** | Enumerates all configurations. `solve()` works for any aggregate problem; `find_witness()`, `find_all_witnesses()`, and `solve_with_witnesses()` are available when `P::Value` supports witnesses. Used for testing and verification. | -| **ILPSolver** | Enabled by default. Solves ILP instances directly with HiGHS via `good_lp`. Also provides `solve_reduced()` for witness-capable problems that implement `ReduceTo>`. | +| **ILPSolver** | Enabled by default. Solves `ILP` and `ILP` instances directly with HiGHS via `good_lp`. Also provides `solve_reduced::()` for witness-capable problems that implement `ReduceTo>`. | ## JSON Serialization diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 5afc10916..1116df9b1 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -100,10 +100,17 @@ For convenience, `ILPSolver::solve_reduced` combines reduce + solve + extract in a single call: ```rust,ignore -let solution = ILPSolver::new().solve_reduced(&problem).unwrap(); +let solution = ILPSolver::new() + .solve_reduced::(&problem) + .unwrap(); assert!(problem.evaluate(&solution).is_valid()); ``` +The ILP domain is explicit because a source type may provide more than one +direct ILP reduction. Both `bool` and `i32` are supported. `solve` and +`solve_reduced` return `ILPSolveError`, which distinguishes infeasibility, +timeout, unboundedness, unsupported dynamic input, and backend failure. + ### Example 2: Reduction path search — integer factoring to spin glass Real-world problems often require **chaining** multiple reductions. Here we factor the integer 6 by reducing `Factoring` through the reduction graph to `SpinGlass`, through automatic reduction path search. ([full source](https://github.com/CodingThrust/problem-reductions/blob/main/examples/chained_reduction_factoring_to_spinglass.rs)) diff --git a/src/solvers/ilp/mod.rs b/src/solvers/ilp/mod.rs index f23f70ff2..c061a84a7 100644 --- a/src/solvers/ilp/mod.rs +++ b/src/solvers/ilp/mod.rs @@ -23,4 +23,4 @@ mod solver; -pub use solver::ILPSolver; +pub use solver::{ILPSolveError, ILPSolver}; diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index de052587f..81eacc124 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -8,7 +8,38 @@ use good_lp::default_solver; use good_lp::highs; #[cfg(feature = "ilp-highs")] use good_lp::solvers::highs::HighsParallelType; -use good_lp::{variable, ProblemVariables, Solution, SolverModel, Variable}; +use good_lp::{ + variable, ProblemVariables, ResolutionError, Solution, SolutionStatus, SolverModel, Variable, +}; + +/// A failure to produce a proven-optimal ILP solution. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ILPSolveError { + /// The constraints have no feasible assignment. + #[error("the ILP is infeasible")] + Infeasible, + /// The objective is unbounded. + #[error("the ILP objective is unbounded")] + Unbounded, + /// The configured time limit was reached before optimality was proven. + #[error("the ILP solver reached its time limit before proving optimality")] + Timeout, + /// The selected backend failed for another reason. + #[error("the ILP backend failed: {0}")] + BackendFailure(String), + /// Type-erased dispatch received a value other than a supported ILP variant. + #[error("the ILP backend supports only ILP and ILP")] + UnsupportedProblemType, +} + +fn classify_backend_error(error: ResolutionError, time_limit: Option) -> ILPSolveError { + match error { + ResolutionError::Infeasible => ILPSolveError::Infeasible, + ResolutionError::Unbounded => ILPSolveError::Unbounded, + ResolutionError::Other("NoSolutionFound") if time_limit.is_some() => ILPSolveError::Timeout, + other => ILPSolveError::BackendFailure(other.to_string()), + } +} /// An ILP solver using the HiGHS backend. /// @@ -29,9 +60,9 @@ use good_lp::{variable, ProblemVariables, Solution, SolverModel, Variable}; /// ); /// /// let solver = ILPSolver::new(); -/// if let Some(solution) = solver.solve(&ilp) { -/// println!("Solution: {:?}", solution); -/// } +/// let solution = solver.solve(&ilp)?; +/// println!("Solution: {:?}", solution); +/// # Ok::<(), problemreductions::solvers::ILPSolveError>(()) /// ``` #[derive(Debug, Clone, Default)] pub struct ILPSolver { @@ -54,13 +85,17 @@ impl ILPSolver { /// Solve an ILP problem directly. /// - /// Returns `None` if the problem is infeasible or the solver fails. + /// Returns a classified error when the problem is infeasible, the time + /// limit is reached, or the backend fails. /// The returned solution is a configuration vector where each element /// is the variable value (config index = value). - pub fn solve(&self, problem: &ILP) -> Option> { + pub fn solve(&self, problem: &ILP) -> Result, ILPSolveError> { let n = problem.num_vars; if n == 0 { - return problem.is_feasible(&[]).then_some(vec![]); + return problem + .is_feasible(&[]) + .then_some(vec![]) + .ok_or(ILPSolveError::Infeasible); } // Derive tighter per-variable upper bounds from single-variable ≤ constraints. @@ -145,7 +180,23 @@ impl ILPSolver { } // Solve - let solution = model.solve().ok()?; + #[cfg(feature = "ilp-highs")] + let effective_time_limit = self.time_limit; + #[cfg(not(feature = "ilp-highs"))] + let effective_time_limit = None; + let solution = model + .solve() + .map_err(|error| classify_backend_error(error, effective_time_limit))?; + + match solution.status() { + SolutionStatus::Optimal => {} + SolutionStatus::TimeLimit => return Err(ILPSolveError::Timeout), + SolutionStatus::GapLimit => { + return Err(ILPSolveError::BackendFailure( + "the backend stopped at its gap limit before proving optimality".to_string(), + )); + } + } // Extract solution: config index = value (no lower bound offset) let result: Vec = vars @@ -156,12 +207,12 @@ impl ILPSolver { }) .collect(); - Some(result) + Ok(result) } - /// Solve any problem that reduces to `ILP`. + /// Solve any problem that reduces directly to `ILP`. /// - /// This method first reduces the problem to a binary ILP, solves the ILP, + /// This method first reduces the problem to the selected ILP domain, solves the ILP, /// and then extracts the solution back to the original problem space. /// /// # Example @@ -179,28 +230,29 @@ impl ILPSolver { /// /// // Solve using ILP solver /// let solver = ILPSolver::new(); - /// if let Some(solution) = solver.solve_reduced(&problem) { - /// println!("Solution: {:?}", solution); - /// } + /// let solution = solver.solve_reduced::(&problem)?; + /// println!("Solution: {:?}", solution); + /// # Ok::<(), problemreductions::solvers::ILPSolveError>(()) /// ``` - pub fn solve_reduced

(&self, problem: &P) -> Option> + pub fn solve_reduced(&self, problem: &P) -> Result, ILPSolveError> where - P: ReduceTo>, + V: VariableDomain, + P: ReduceTo>, { let reduction = problem.reduce_to(); let ilp_solution = self.solve(reduction.target_problem())?; - Some(reduction.extract_solution(&ilp_solution)) + Ok(reduction.extract_solution(&ilp_solution)) } /// Solve a type-erased supported ILP variant directly. - pub(crate) fn solve_dyn(&self, any: &dyn std::any::Any) -> Option> { + pub(crate) fn solve_dyn(&self, any: &dyn std::any::Any) -> Result, ILPSolveError> { if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } if let Some(ilp) = any.downcast_ref::>() { return self.solve(ilp); } - None + Err(ILPSolveError::UnsupportedProblemType) } } diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index 6d39ae8fd..a91b8fd94 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -22,7 +22,7 @@ pub use resolver::{ }; #[cfg(feature = "ilp-solver")] -pub use ilp::ILPSolver; +pub use ilp::{ILPSolveError, ILPSolver}; use crate::traits::Problem; diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index 28b7e9b8b..e229700b9 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -2,6 +2,7 @@ use crate::registry::VariantEntry; use crate::rules::registry::{reduction_entries, ReduceFn, ReductionEntry}; +#[cfg(feature = "ilp-solver")] use crate::rules::DynReductionResult; use serde::Serialize; use std::any::Any; @@ -119,11 +120,9 @@ impl CompiledIlpPipeline { &self, source: &dyn Any, solver: &super::ILPSolver, - ) -> Result, PipelineExecutionError> { + ) -> Result, super::ILPSolveError> { if self.reducers.is_empty() { - return solver - .solve_dyn(source) - .ok_or(PipelineExecutionError::NoSolution); + return solver.solve_dyn(source); } let mut reductions: Vec> = Vec::new(); @@ -139,21 +138,13 @@ impl CompiledIlpPipeline { .last() .expect("non-empty fixed pipeline must produce a target") .target_problem_any(); - let solution = solver - .solve_dyn(target) - .ok_or(PipelineExecutionError::NoSolution)?; + let solution = solver.solve_dyn(target)?; Ok(reductions.iter().rev().fold(solution, |current, step| { step.extract_solution_dyn(¤t) })) } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] -pub(crate) enum PipelineExecutionError { - #[error("the registered ILP pipeline found no solution")] - NoSolution, -} - #[derive(Clone, Copy)] pub(crate) struct RegisteredSolverCapabilities<'a> { pub(crate) native: Option<&'static NativeSolverRegistration>, diff --git a/src/solvers/resolver.rs b/src/solvers/resolver.rs index dde6d79f5..340d9b6e9 100644 --- a/src/solvers/resolver.rs +++ b/src/solvers/resolver.rs @@ -1,8 +1,9 @@ //! Shared deterministic solver dispatch. +#[cfg(feature = "ilp-solver")] +use super::registry::CompiledIlpPipeline; use super::registry::{ - solver_capability_registry, CompiledIlpPipeline, ExactProblemKey, NativeSolverRegistration, - RegistryBuildError, + solver_capability_registry, ExactProblemKey, NativeSolverRegistration, RegistryBuildError, }; use crate::registry::LoadedDynProblem; use serde::Serialize; @@ -41,8 +42,13 @@ pub enum DeterministicSolveError { MissingIlpCapability(String), #[error("native solver found no solution for {problem}")] NativeNoSolution { problem: String }, - #[error("ILP solver found no solution for {problem}")] - IlpNoSolution { problem: String }, + #[cfg(feature = "ilp-solver")] + #[error("ILP solver failed for {problem}: {source}")] + IlpSolve { + problem: String, + #[source] + source: super::ILPSolveError, + }, } fn problem_key(problem: &LoadedDynProblem) -> ExactProblemKey { @@ -75,8 +81,9 @@ fn solve_ilp( ) -> Result { let config = pipeline .solve(problem.as_any(), &super::ILPSolver::new()) - .map_err(|_| DeterministicSolveError::IlpNoSolution { + .map_err(|source| DeterministicSolveError::IlpSolve { problem: problem_key(problem).label(), + source, })?; let evaluation = problem.evaluate_dyn(&config); Ok(DeterministicSolveResult { diff --git a/src/unit_tests/rules/acyclicpartition_ilp.rs b/src/unit_tests/rules/acyclicpartition_ilp.rs index 97efc979f..de050bc5f 100644 --- a/src/unit_tests/rules/acyclicpartition_ilp.rs +++ b/src/unit_tests/rules/acyclicpartition_ilp.rs @@ -76,7 +76,7 @@ fn test_infeasible_instance() { let reduction: ReductionAcyclicPartitionToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs index 9c4cc1109..ffb36daf3 100644 --- a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -46,7 +46,7 @@ fn test_infeasible_instance() { let reduction: ReductionBCBSToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = crate::solvers::ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/binpacking_ilp.rs b/src/unit_tests/rules/binpacking_ilp.rs index 772eb4601..0573c82d9 100644 --- a/src/unit_tests/rules/binpacking_ilp.rs +++ b/src/unit_tests/rules/binpacking_ilp.rs @@ -135,7 +135,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs index 70452a9e1..03aee897a 100644 --- a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs @@ -92,7 +92,7 @@ fn test_no_hamiltonian_cycle_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Path graph should have no Hamiltonian cycle" ); } diff --git a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs index bd97a4a96..19f94f6bf 100644 --- a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs @@ -81,7 +81,7 @@ fn test_infeasible_instance() { let reduction: ReductionBCSFToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/clustering_ilp.rs b/src/unit_tests/rules/clustering_ilp.rs index a35273e37..2da5f263f 100644 --- a/src/unit_tests/rules/clustering_ilp.rs +++ b/src/unit_tests/rules/clustering_ilp.rs @@ -74,5 +74,5 @@ fn test_clustering_to_ilp_infeasible_instance_is_infeasible() { let problem = infeasible_instance(); let reduction: ReductionClusteringToILP = ReduceTo::>::reduce_to(&problem); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_none()); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); } diff --git a/src/unit_tests/rules/coloring_ilp.rs b/src/unit_tests/rules/coloring_ilp.rs index f436f39b5..41eab4e0a 100644 --- a/src/unit_tests/rules/coloring_ilp.rs +++ b/src/unit_tests/rules/coloring_ilp.rs @@ -113,7 +113,7 @@ fn test_ilp_infeasible_triangle_2_colors() { // ILP should be infeasible let result = ilp_solver.solve(ilp); assert!( - result.is_none(), + result.is_err(), "Triangle with 2 colors should be infeasible" ); } @@ -202,7 +202,7 @@ fn test_complete_graph_k4_with_3_colors_infeasible() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_none(), "K4 with 3 colors should be infeasible"); + assert!(result.is_err(), "K4 with 3 colors should be infeasible"); } #[test] @@ -234,7 +234,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution)); diff --git a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs index 1cb1d59ad..157a03fa6 100644 --- a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -65,7 +65,7 @@ fn test_cdft_to_ilp_unsat_instance_is_infeasible() { let problem = small_no_instance(); let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem); let solver = ILPSolver::new(); - assert!(solver.solve(reduction.target_problem()).is_none()); + assert!(solver.solve(reduction.target_problem()).is_err()); } #[test] @@ -73,7 +73,7 @@ fn test_cdft_to_ilp_solve_reduced() { let problem = small_yes_instance(); let solver = ILPSolver::new(); let solution = solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should find a satisfying assignment"); assert!(problem.evaluate(&solution)); } diff --git a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs index e13a85326..ac027fb8c 100644 --- a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs @@ -89,7 +89,7 @@ fn test_directedhamiltonianpath_to_ilp_no_path() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Graph with no Hamiltonian path should be infeasible" ); } diff --git a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs index 42f09bd3a..ec3d8e3eb 100644 --- a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs @@ -94,7 +94,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible flow instance should produce infeasible ILP" ); } @@ -106,7 +106,7 @@ fn test_directedtwocommodityintegralflow_to_ilp_disallows_using_other_commodity_ let reduction: ReductionD2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "commodity 1 must conserve flow at commodity 2's source in the ILP reduction" ); } diff --git a/src/unit_tests/rules/eulerianpath_ilp.rs b/src/unit_tests/rules/eulerianpath_ilp.rs index ce690f067..1c8b3fd57 100644 --- a/src/unit_tests/rules/eulerianpath_ilp.rs +++ b/src/unit_tests/rules/eulerianpath_ilp.rs @@ -88,7 +88,7 @@ fn test_eulerianpath_to_ilp_infeasible_no_instance() { // The ILP must report infeasibility for a NO instance. let solution = ILPSolver::new().solve(reduction.target_problem()); assert!( - solution.is_none(), + solution.is_err(), "ILP must be infeasible for a degree-unbalanced NO instance, got {:?}", solution ); diff --git a/src/unit_tests/rules/factoring_ilp.rs b/src/unit_tests/rules/factoring_ilp.rs index 85bc86003..f33a717ec 100644 --- a/src/unit_tests/rules/factoring_ilp.rs +++ b/src/unit_tests/rules/factoring_ilp.rs @@ -161,7 +161,7 @@ fn test_infeasible_target_too_large() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(ilp); - assert!(result.is_none(), "Should be infeasible"); + assert!(result.is_err(), "Should be infeasible"); } #[test] diff --git a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs index b4c0c36ef..db3a43c5e 100644 --- a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs +++ b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs @@ -41,7 +41,7 @@ fn test_feasible_register_assignment_to_ilp_infeasible() { let reduction = ReduceTo::>::reduce_to(&source); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "register-conflict source instance should reduce to an infeasible ILP" ); } diff --git a/src/unit_tests/rules/flowshopscheduling_ilp.rs b/src/unit_tests/rules/flowshopscheduling_ilp.rs index 23195381c..15dd3b795 100644 --- a/src/unit_tests/rules/flowshopscheduling_ilp.rs +++ b/src/unit_tests/rules/flowshopscheduling_ilp.rs @@ -33,7 +33,7 @@ fn test_flowshopscheduling_to_ilp_infeasible() { let problem = FlowShopScheduling::new(2, vec![vec![5, 5], vec![5, 5], vec![5, 5]], 6); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible FSS should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/graphpartitioning_ilp.rs b/src/unit_tests/rules/graphpartitioning_ilp.rs index bb0ec4e4e..cf27d091d 100644 --- a/src/unit_tests/rules/graphpartitioning_ilp.rs +++ b/src/unit_tests/rules/graphpartitioning_ilp.rs @@ -104,7 +104,10 @@ fn test_odd_vertices_reduce_to_infeasible_ilp() { assert_eq!(ilp.constraints[0].rhs, 1.5); let solver = ILPSolver::new(); - assert_eq!(solver.solve(ilp), None); + assert_eq!( + solver.solve(ilp), + Err(crate::solvers::ILPSolveError::Infeasible) + ); } #[test] @@ -125,7 +128,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert_eq!(problem.evaluate(&solution), Min(Some(3))); diff --git a/src/unit_tests/rules/hamiltonianpath_ilp.rs b/src/unit_tests/rules/hamiltonianpath_ilp.rs index ce36c3422..03fd75d18 100644 --- a/src/unit_tests/rules/hamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/hamiltonianpath_ilp.rs @@ -77,7 +77,7 @@ fn test_hamiltonianpath_to_ilp_no_path() { let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Disconnected graph should have no Hamiltonian path" ); } diff --git a/src/unit_tests/rules/integralflowbundles_ilp.rs b/src/unit_tests/rules/integralflowbundles_ilp.rs index 6a3268ebf..12dde8a1a 100644 --- a/src/unit_tests/rules/integralflowbundles_ilp.rs +++ b/src/unit_tests/rules/integralflowbundles_ilp.rs @@ -94,7 +94,7 @@ fn test_integral_flow_bundles_to_ilp_extract_solution_is_identity() { fn test_integral_flow_bundles_to_ilp_unsat_instance_is_infeasible() { let problem = no_instance(); let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); - assert!(ILPSolver::new().solve(reduction.target_problem()).is_none()); + assert!(ILPSolver::new().solve(reduction.target_problem()).is_err()); } #[test] diff --git a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs index 6ed29abea..a03765d13 100644 --- a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -47,7 +47,7 @@ fn solve_target_via_ilp( problem: &crate::models::graph::DirectedTwoCommodityIntegralFlow, ) -> Option> { let reduction = ReduceTo::>::reduce_to(problem); - let ilp_solution = ILPSolver::new().solve(reduction.target_problem())?; + let ilp_solution = ILPSolver::new().solve(reduction.target_problem()).ok()?; let extracted = reduction.extract_solution(&ilp_solution); problem.evaluate(&extracted).0.then_some(extracted) } diff --git a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs index 457ef61de..d792c61e0 100644 --- a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs @@ -102,9 +102,7 @@ fn test_ksatisfiability_to_feasible_register_assignment_unsatisfiable_instance() let fra_to_ilp = ReduceTo::>::reduce_to(reduction.target_problem()); assert!( - ILPSolver::new() - .solve(fra_to_ilp.target_problem()) - .is_none(), + ILPSolver::new().solve(fra_to_ilp.target_problem()).is_err(), "an unsatisfiable source formula should yield an infeasible FRA instance" ); } diff --git a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs index 7f92fcbaa..b27b46739 100644 --- a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs @@ -34,7 +34,7 @@ fn solve_threshold_schedule_via_ilp( target.precedences().to_vec(), ); let pcs_to_ilp = ReduceTo::>::reduce_to(&pcs); - let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem())?; + let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem()).ok()?; let slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution); let mut config = vec![0usize; target.num_tasks() * target.d_max()]; diff --git a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs index 76363ba44..83fb3e168 100644 --- a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs +++ b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs @@ -78,7 +78,7 @@ fn test_ksatisfiability_to_timetabledesign_closed_loop() { let reduction = ReduceTo::::reduce_to(&source); let target_solution = ILPSolver::new() - .solve_reduced(reduction.target_problem()) + .solve_reduced::(reduction.target_problem()) .expect("satisfiable source instance should produce a feasible timetable"); assert!(reduction.target_problem().evaluate(&target_solution).0); @@ -95,8 +95,8 @@ fn test_ksatisfiability_to_timetabledesign_unsatisfiable() { assert!( ILPSolver::new() - .solve_reduced(reduction.target_problem()) - .is_none(), + .solve_reduced::(reduction.target_problem()) + .is_err(), "unsatisfiable 3SAT instance should produce an infeasible timetable" ); } diff --git a/src/unit_tests/rules/maximummatching_ilp.rs b/src/unit_tests/rules/maximummatching_ilp.rs index 02c9c6061..4ca49d5b0 100644 --- a/src/unit_tests/rules/maximummatching_ilp.rs +++ b/src/unit_tests/rules/maximummatching_ilp.rs @@ -242,7 +242,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index 54daaed04..bffe90ca4 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -121,7 +121,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimumedgecostflow_ilp.rs b/src/unit_tests/rules/minimumedgecostflow_ilp.rs index 080748317..03d7ad096 100644 --- a/src/unit_tests/rules/minimumedgecostflow_ilp.rs +++ b/src/unit_tests/rules/minimumedgecostflow_ilp.rs @@ -104,7 +104,7 @@ fn test_minimumedgecostflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMECFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs index a831575ce..f5f3d06a8 100644 --- a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs +++ b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs @@ -80,7 +80,7 @@ fn test_reduction_is_infeasible_when_an_internal_vertex_has_no_covering_pair() { assert_eq!(problem.evaluate(&[0]), Min(None)); assert_eq!(problem.evaluate(&[1]), Min(None)); - assert!(ILPSolver::new().solve(ilp).is_none()); + assert!(ILPSolver::new().solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/minimummultiwaycut_ilp.rs b/src/unit_tests/rules/minimummultiwaycut_ilp.rs index 99260a2ab..b5a6e5046 100644 --- a/src/unit_tests/rules/minimummultiwaycut_ilp.rs +++ b/src/unit_tests/rules/minimummultiwaycut_ilp.rs @@ -131,7 +131,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimumsetcovering_ilp.rs b/src/unit_tests/rules/minimumsetcovering_ilp.rs index cd16428a2..4e154c1a6 100644 --- a/src/unit_tests/rules/minimumsetcovering_ilp.rs +++ b/src/unit_tests/rules/minimumsetcovering_ilp.rs @@ -184,7 +184,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); assert!(problem.evaluate(&solution).is_valid()); diff --git a/src/unit_tests/rules/minimumweightdecoding_ilp.rs b/src/unit_tests/rules/minimumweightdecoding_ilp.rs index 5f589bf46..3f5dd0c8e 100644 --- a/src/unit_tests/rules/minimumweightdecoding_ilp.rs +++ b/src/unit_tests/rules/minimumweightdecoding_ilp.rs @@ -91,7 +91,7 @@ fn test_minimumweightdecoding_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionMinimumWeightDecodingToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/monochromatictriangle_ilp.rs b/src/unit_tests/rules/monochromatictriangle_ilp.rs index f55c96ee4..7e7cc9119 100644 --- a/src/unit_tests/rules/monochromatictriangle_ilp.rs +++ b/src/unit_tests/rules/monochromatictriangle_ilp.rs @@ -64,7 +64,7 @@ fn test_monochromatic_triangle_to_ilp_infeasible_k6() { let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "K6 should be infeasible by R(3,3)=6" ); } diff --git a/src/unit_tests/rules/naesatisfiability_ilp.rs b/src/unit_tests/rules/naesatisfiability_ilp.rs index bd2efef04..d4e7504ae 100644 --- a/src/unit_tests/rules/naesatisfiability_ilp.rs +++ b/src/unit_tests/rules/naesatisfiability_ilp.rs @@ -74,7 +74,7 @@ fn test_naesatisfiability_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); // The ILP should be infeasible: x1 ≥ 1 (at least one true) AND x1 ≤ 0 (at least one false) assert!( - ilp_solver.solve(ilp).is_none(), + ilp_solver.solve(ilp).is_err(), "ILP should be infeasible for unsatisfiable NAE-SAT" ); } diff --git a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs index b6a1dd3ee..f0318d543 100644 --- a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs @@ -60,7 +60,7 @@ fn test_numericalmatchingwithtargetsums_to_ilp_unsatisfiable() { let reduction = ReduceTo::>::reduce_to(&problem); let result = ILPSolver::new().solve(reduction.target_problem()); assert!( - result.is_none(), + result.is_err(), "Unsatisfiable instance should have no ILP solution" ); } diff --git a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs index 4f4e3a363..b921910b7 100644 --- a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs @@ -57,7 +57,7 @@ fn test_precedenceconstrainedscheduling_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionPCSToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible scheduling instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/preemptivescheduling_ilp.rs b/src/unit_tests/rules/preemptivescheduling_ilp.rs index 95a0258a5..210b8aa3b 100644 --- a/src/unit_tests/rules/preemptivescheduling_ilp.rs +++ b/src/unit_tests/rules/preemptivescheduling_ilp.rs @@ -55,6 +55,16 @@ fn test_preemptivescheduling_to_ilp_closed_loop() { ); } +#[test] +fn test_solve_reduced_supports_direct_ilp_i32_reductions() { + let problem = small_instance(); + let solution = ILPSolver::new() + .solve_reduced::(&problem) + .expect("direct ILP reduction should be solvable"); + + assert!(problem.evaluate(&solution).0.is_some()); +} + #[test] fn test_preemptivescheduling_to_ilp_medium_closed_loop() { let p = medium_instance(); @@ -87,7 +97,7 @@ fn test_preemptivescheduling_to_ilp_infeasible() { let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); let sol = ILPSolver::new().solve(reduction.target_problem()); // 1 processor, t0 at slot 0, t1 at slot 1 → always feasible - assert!(sol.is_some(), "should be feasible"); + assert!(sol.is_ok(), "should be feasible"); } // ─── extract_solution ────────────────────────────────────────────────────── diff --git a/src/unit_tests/rules/registersufficiency_ilp.rs b/src/unit_tests/rules/registersufficiency_ilp.rs index 8b5e9ec77..504f86727 100644 --- a/src/unit_tests/rules/registersufficiency_ilp.rs +++ b/src/unit_tests/rules/registersufficiency_ilp.rs @@ -64,7 +64,7 @@ fn test_register_sufficiency_to_ilp_infeasible() { let reduction = ReduceTo::>::reduce_to(&source); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "register-sufficiency instance with bound one should be infeasible" ); } diff --git a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs index f29497710..8acd6b484 100644 --- a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs @@ -52,7 +52,7 @@ fn test_resourceconstrainedscheduling_to_ilp_infeasible() { ResourceConstrainedScheduling::new(1, vec![5], vec![vec![6], vec![6], vec![6]], 1); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible RCS should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs index d21d180aa..93a6f1958 100644 --- a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs +++ b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs @@ -34,13 +34,13 @@ fn test_rootedtreestorageassignment_to_ilp_bf_vs_ilp() { let ilp_result = ilp_solver.solve(reduction.target_problem()); match ilp_result { - Some(ilp_solution) => { + Ok(ilp_solution) => { let extracted = reduction.extract_solution(&ilp_solution); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.0, "ILP solution should be feasible"); assert!(bf_value.0, "BF should also find feasible solution"); } - None => { + Err(_) => { assert!(!bf_value.0, "both should agree on infeasibility"); } } @@ -63,10 +63,7 @@ fn test_rootedtreestorageassignment_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); let ilp_result = ilp_solver.solve(reduction.target_problem()); assert!(bf_witness.is_none(), "source should be infeasible"); - assert!( - ilp_result.is_none(), - "reduced ILP should also be infeasible" - ); + assert!(ilp_result.is_err(), "reduced ILP should also be infeasible"); } #[test] diff --git a/src/unit_tests/rules/sat_coloring.rs b/src/unit_tests/rules/sat_coloring.rs index 929bb7651..a193f02bb 100644 --- a/src/unit_tests/rules/sat_coloring.rs +++ b/src/unit_tests/rules/sat_coloring.rs @@ -321,7 +321,7 @@ fn test_jl_parity_sat_to_coloring() { let ilp_solver = crate::solvers::ILPSolver::new(); let target = result.target_problem(); let target_sol = ilp_solver - .solve_reduced(target) + .solve_reduced::(target) .expect("ILP should find a coloring"); let extracted = result.extract_solution(&target_sol); let best_source: HashSet> = BruteForce::new() diff --git a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs index 569da137e..72c20ef12 100644 --- a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs @@ -57,7 +57,7 @@ fn test_schedulingwithindividualdeadlines_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIDToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should yield infeasible ILP" ); } diff --git a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index 5f599cb07..1bd5baa1b 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -98,7 +98,7 @@ fn test_cyclic_precedence_instance_is_infeasible() { let ilp = reduction.target_problem(); assert!( - ILPSolver::new().solve(ilp).is_none(), + ILPSolver::new().solve(ilp).is_err(), "cyclic precedences should make the ILP infeasible" ); } diff --git a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs index 1d68ac783..f09f97d6b 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -43,7 +43,7 @@ fn test_sequencingtominimizeweightedtardiness_to_ilp_infeasible() { SequencingToMinimizeWeightedTardiness::new(vec![10, 10], vec![1, 1], vec![1, 1], 0); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible STMWT should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 23f97a1a8..8e6541bea 100644 --- a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -53,7 +53,7 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_infeasible() { SequencingWithDeadlinesAndSetUpTimes::new(vec![2, 2], vec![1, 1], vec![0, 0], vec![0]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } @@ -90,13 +90,13 @@ fn test_sequencingwithdeadlinesandsetuptimes_to_ilp_bf_vs_ilp_small() { let reduction = ReduceTo::>::reduce_to(&problem); let ilp_result = ILPSolver::new().solve(reduction.target_problem()); - let ilp_feasible = ilp_result.is_some(); + let ilp_feasible = ilp_result.is_ok(); assert_eq!( bf_feasible, ilp_feasible, "BF and ILP should agree on feasibility" ); - if let Some(ilp_solution) = ilp_result { + if let Ok(ilp_solution) = ilp_result { let extracted = reduction.extract_solution(&ilp_solution); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs index fa04ef222..32c0b2082 100644 --- a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs +++ b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs @@ -69,7 +69,7 @@ fn test_sequencingwithinintervals_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionSWIToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance (forced overlap) should yield infeasible ILP" ); } diff --git a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 4ed4daca9..6da8a68e4 100644 --- a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -42,7 +42,7 @@ fn test_sequencingwithreleasetimesanddeadlines_to_ilp_infeasible() { let problem = SequencingWithReleaseTimesAndDeadlines::new(vec![2, 2], vec![0, 0], vec![2, 2]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible SWRTD should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/setsplitting_ilp.rs b/src/unit_tests/rules/setsplitting_ilp.rs index b15762c82..d30286c0e 100644 --- a/src/unit_tests/rules/setsplitting_ilp.rs +++ b/src/unit_tests/rules/setsplitting_ilp.rs @@ -64,7 +64,7 @@ fn test_setsplitting_to_ilp_infeasible() { let ilp_solver = ILPSolver::new(); assert!( - ilp_solver.solve(ilp).is_none(), + ilp_solver.solve(ilp).is_err(), "ILP should be infeasible for unsplittable instance" ); } diff --git a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs index 6584fb275..26343fc58 100644 --- a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs @@ -52,13 +52,13 @@ fn test_shortestweightconstrainedpath_to_ilp_bf_vs_ilp() { let ilp_result = ilp_solver.solve(reduction.target_problem()); match ilp_result { - Some(ilp_solution) => { + Ok(ilp_solution) => { let extracted = reduction.extract_solution(&ilp_solution); let ilp_value = problem.evaluate(&extracted); // Both should agree on the optimal length assert_eq!(ilp_value, bf_value); } - None => { + Err(_) => { // ILP found no feasible solution; brute force should agree assert_eq!(bf_value, Min(None)); } diff --git a/src/unit_tests/rules/steinertree_ilp.rs b/src/unit_tests/rules/steinertree_ilp.rs index 7925f7ed4..4f3dbfb98 100644 --- a/src/unit_tests/rules/steinertree_ilp.rs +++ b/src/unit_tests/rules/steinertree_ilp.rs @@ -75,7 +75,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { fn test_solve_reduced_uses_new_rule() { let problem = canonical_instance(); let solution = ILPSolver::new() - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should find the Steiner tree via ILP"); assert_eq!(problem.evaluate(&solution), Min(Some(6))); } diff --git a/src/unit_tests/rules/stringtostringcorrection_ilp.rs b/src/unit_tests/rules/stringtostringcorrection_ilp.rs index 3b1983b03..d9d6dbea6 100644 --- a/src/unit_tests/rules/stringtostringcorrection_ilp.rs +++ b/src/unit_tests/rules/stringtostringcorrection_ilp.rs @@ -62,7 +62,7 @@ fn test_stringtostringcorrection_to_ilp_infeasible() { let reduction: ReductionSTSCToILP = ReduceTo::>::reduce_to(&problem); let ilp_solver = ILPSolver::new(); assert!( - ilp_solver.solve(reduction.target_problem()).is_none(), + ilp_solver.solve(reduction.target_problem()).is_err(), "reduced ILP should also be infeasible" ); } diff --git a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs index 924fcc0ec..8e963a52a 100644 --- a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs @@ -90,7 +90,7 @@ fn test_infeasible_budget() { let reduction: ReductionSCAToILP = ReduceTo::>::reduce_to(&source); let ilp = reduction.target_problem(); let solver = ILPSolver::new(); - assert!(solver.solve(ilp).is_none()); + assert!(solver.solve(ilp).is_err()); } #[test] diff --git a/src/unit_tests/rules/subgraphisomorphism_ilp.rs b/src/unit_tests/rules/subgraphisomorphism_ilp.rs index a662292f0..026c5e79a 100644 --- a/src/unit_tests/rules/subgraphisomorphism_ilp.rs +++ b/src/unit_tests/rules/subgraphisomorphism_ilp.rs @@ -78,7 +78,7 @@ fn test_subgraphisomorphism_to_ilp_infeasible() { let reduction: ReductionSubIsoToILP = ReduceTo::>::reduce_to(&problem); let ilp_solver = ILPSolver::new(); let result = ilp_solver.solve(reduction.target_problem()); - assert!(result.is_none(), "K3 in path should be infeasible"); + assert!(result.is_err(), "K3 in path should be infeasible"); } #[test] diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index 0873a4b65..6ae678b8c 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -115,7 +115,7 @@ fn test_threedimensionalmatching_to_ilp_infeasible_instance() { "source instance should be infeasible" ); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "reduced ILP should be infeasible" ); } @@ -138,7 +138,7 @@ fn test_threedimensionalmatching_to_ilp_direct_path_beats_indirect_chain() { assert_eq!(problem.evaluate(&direct_source), Or(true)); assert!( - solver.solve(indirect.target_problem()).is_some(), + solver.solve(indirect.target_problem()).is_ok(), "indirect ILP should agree on feasibility" ); assert!(direct.target_problem().num_vars < indirect.target_problem().num_vars); diff --git a/src/unit_tests/rules/timetabledesign_ilp.rs b/src/unit_tests/rules/timetabledesign_ilp.rs index f4cdd9522..556bcee1e 100644 --- a/src/unit_tests/rules/timetabledesign_ilp.rs +++ b/src/unit_tests/rules/timetabledesign_ilp.rs @@ -55,7 +55,7 @@ fn test_timetabledesign_to_ilp_infeasible() { let problem = TimetableDesign::new(1, 1, 1, vec![vec![true]], vec![vec![true]], vec![vec![2]]); let reduction = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible TD should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/travelingsalesman_ilp.rs b/src/unit_tests/rules/travelingsalesman_ilp.rs index cb0040030..03c472daf 100644 --- a/src/unit_tests/rules/travelingsalesman_ilp.rs +++ b/src/unit_tests/rules/travelingsalesman_ilp.rs @@ -104,7 +104,7 @@ fn test_no_hamiltonian_cycle_infeasible() { let result = ilp_solver.solve(ilp); assert!( - result.is_none(), + result.is_err(), "Path graph should have no Hamiltonian cycle (infeasible ILP)" ); } @@ -136,7 +136,7 @@ fn test_solve_reduced() { let ilp_solver = ILPSolver::new(); let solution = ilp_solver - .solve_reduced(&problem) + .solve_reduced::(&problem) .expect("solve_reduced should work"); let metric = problem.evaluate(&solution); diff --git a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs index 2969919dd..a8391993c 100644 --- a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs @@ -73,7 +73,7 @@ fn test_undirectedflowlowerbounds_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionUFLBToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible instance should produce infeasible ILP" ); } diff --git a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs index 398f8c485..f6f91eec9 100644 --- a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -99,7 +99,7 @@ fn test_undirectedtwocommodityintegralflow_to_ilp_infeasible() { let problem = infeasible_instance(); let reduction: ReductionU2CIFToILP = ReduceTo::>::reduce_to(&problem); assert!( - ILPSolver::new().solve(reduction.target_problem()).is_none(), + ILPSolver::new().solve(reduction.target_problem()).is_err(), "infeasible flow instance should yield infeasible ILP" ); } diff --git a/src/unit_tests/solvers/ilp/solver.rs b/src/unit_tests/solvers/ilp/solver.rs index 28c6d6120..d83350d7d 100644 --- a/src/unit_tests/solvers/ilp/solver.rs +++ b/src/unit_tests/solvers/ilp/solver.rs @@ -16,7 +16,7 @@ fn test_ilp_solver_basic_maximize() { let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); let sol = solution.unwrap(); // Solution should be valid @@ -40,7 +40,7 @@ fn test_ilp_solver_basic_minimize() { let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); let sol = solution.unwrap(); // Solution should be valid @@ -86,11 +86,11 @@ fn test_ilp_empty_problem() { let ilp = ILP::::empty(); let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert_eq!(solution, Some(vec![])); + assert_eq!(solution, Ok(vec![])); } #[test] -fn test_ilp_empty_problem_with_infeasible_constraint_returns_none() { +fn test_ilp_empty_problem_with_infeasible_constraint_returns_infeasible() { let ilp = ILP::::new( 0, vec![LinearConstraint::le(vec![], -1.0)], @@ -99,7 +99,27 @@ fn test_ilp_empty_problem_with_infeasible_constraint_returns_none() { ); let solver = ILPSolver::new(); let solution = solver.solve(&ilp); - assert_eq!(solution, None); + assert_eq!(solution, Err(ILPSolveError::Infeasible)); +} + +#[test] +fn test_backend_errors_are_classified_without_losing_the_cause() { + assert_eq!( + classify_backend_error(ResolutionError::Infeasible, None), + ILPSolveError::Infeasible + ); + assert_eq!( + classify_backend_error(ResolutionError::Unbounded, None), + ILPSolveError::Unbounded + ); + assert_eq!( + classify_backend_error(ResolutionError::Other("NoSolutionFound"), Some(0.1)), + ILPSolveError::Timeout + ); + assert!(matches!( + classify_backend_error(ResolutionError::Other("SolveError"), None), + ILPSolveError::BackendFailure(message) if message.contains("SolveError") + )); } #[test] @@ -262,7 +282,7 @@ fn test_ilp_with_time_limit() { ); let solution = solver.solve(&ilp); - assert!(solution.is_some()); + assert!(solution.is_ok()); } #[test] @@ -300,7 +320,7 @@ fn test_ilp_solve_dyn_bool() { ObjectiveSense::Maximize, ); let result = solver.solve_dyn(&ilp as &dyn std::any::Any); - assert!(result.is_some()); + assert!(result.is_ok()); } #[test] @@ -313,13 +333,13 @@ fn test_ilp_solve_dyn_i32() { ObjectiveSense::Maximize, ); let result = solver.solve_dyn(&ilp as &dyn std::any::Any); - assert!(result.is_some()); + assert!(result.is_ok()); } #[test] -fn test_ilp_solve_dyn_unknown_type_returns_none() { +fn test_ilp_solve_dyn_unknown_type_returns_unsupported_problem_type() { let solver = ILPSolver::new(); let not_ilp: i32 = 42; let result = solver.solve_dyn(¬_ilp as &dyn std::any::Any); - assert!(result.is_none()); + assert_eq!(result, Err(ILPSolveError::UnsupportedProblemType)); } diff --git a/src/unit_tests/solvers/resolver.rs b/src/unit_tests/solvers/resolver.rs index a0a648171..ce39fcd83 100644 --- a/src/unit_tests/solvers/resolver.rs +++ b/src/unit_tests/solvers/resolver.rs @@ -120,7 +120,10 @@ fn deterministic_solver_dispatch_ilp_failure_does_not_fall_back() { let error = solve_deterministically(&loaded, SolverRequest::Default).unwrap_err(); assert!(matches!( error, - crate::solvers::DeterministicSolveError::IlpNoSolution { .. } + crate::solvers::DeterministicSolveError::IlpSolve { + source: crate::solvers::ILPSolveError::Infeasible, + .. + } )); let brute_force = solve_deterministically(&loaded, SolverRequest::BruteForce).unwrap(); assert_eq!(brute_force.solver, SolverExecution::BruteForce); diff --git a/src/unit_tests/unitdiskmapping_algorithms/common.rs b/src/unit_tests/unitdiskmapping_algorithms/common.rs index 2a6cd4f5a..ca3c6c5d7 100644 --- a/src/unit_tests/unitdiskmapping_algorithms/common.rs +++ b/src/unit_tests/unitdiskmapping_algorithms/common.rs @@ -40,7 +40,7 @@ pub fn solve_mis(num_vertices: usize, edges: &[(usize, usize)]) -> usize { let weights = vec![1; num_vertices]; let ilp = build_mis_ilp(num_vertices, edges, &weights); let solver = ILPSolver::new(); - if let Some(solution) = solver.solve(&ilp) { + if let Ok(solution) = solver.solve(&ilp) { solution.iter().filter(|&&x| x > 0).count() } else { 0 @@ -52,7 +52,7 @@ pub fn solve_mis_config(num_vertices: usize, edges: &[(usize, usize)]) -> Vec 0 { 1 } else { 0 }) @@ -88,7 +88,7 @@ pub fn solve_weighted_grid_mis(result: &MappingResult) -> usize { pub fn solve_weighted_mis(num_vertices: usize, edges: &[(usize, usize)], weights: &[i32]) -> i32 { let ilp = build_mis_ilp(num_vertices, edges, weights); let solver = ILPSolver::new(); - if let Some(solution) = solver.solve(&ilp) { + if let Ok(solution) = solver.solve(&ilp) { solution .iter() .zip(weights.iter()) @@ -109,7 +109,7 @@ pub fn solve_weighted_mis_config( let ilp = build_mis_ilp(num_vertices, edges, weights); let solver = ILPSolver::new(); - if let Some(solution) = solver.solve(&ilp) { + if let Ok(solution) = solver.solve(&ilp) { solution .iter() .map(|&x| if x > 0 { 1 } else { 0 }) diff --git a/src/unit_tests/unitdiskmapping_algorithms/weighted.rs b/src/unit_tests/unitdiskmapping_algorithms/weighted.rs index 62ec282d4..d1ee880e6 100644 --- a/src/unit_tests/unitdiskmapping_algorithms/weighted.rs +++ b/src/unit_tests/unitdiskmapping_algorithms/weighted.rs @@ -715,7 +715,7 @@ fn test_weighted_map_config_back_standard_graphs() { let grid_config: Vec = solver .solve(&ilp) .map(|sol| sol.iter().map(|&x| if x > 0 { 1 } else { 0 }).collect()) - .unwrap_or_else(|| vec![0; num_grid]); + .unwrap_or_else(|_| vec![0; num_grid]); // Use triangular-specific trace_centers (not the KSG version) // Build position to node index map diff --git a/tests/suites/register_assignment_reductions.rs b/tests/suites/register_assignment_reductions.rs index a124edb00..cecb8bdfb 100644 --- a/tests/suites/register_assignment_reductions.rs +++ b/tests/suites/register_assignment_reductions.rs @@ -106,7 +106,7 @@ fn test_unsatisfiable_ksat_stays_infeasible_through_fra_to_ilp() { assert!( ILPSolver::new() .solve(fra_chain.target_problem::>()) - .is_none(), + .is_err(), "unsatisfiable source instance should yield an infeasible ILP" ); } From 7cf57c648dac9d628211c345ece7962141e6defa Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 30 Jul 2026 18:29:15 +0800 Subject: [PATCH 24/31] Clean up regression test naming and fixtures --- problemreductions-cli/src/commands/graph.rs | 173 ++++-------------- problemreductions-cli/src/mcp/tests.rs | 2 +- problemreductions-cli/src/mcp/tools.rs | 10 +- problemreductions-cli/tests/cli_tests.rs | 10 +- .../fixtures/issue_1069_ksat_qubo_all.txt | 36 ---- src/growth.rs | 7 +- src/rules/pareto.rs | 4 +- src/unit_tests/big_o.rs | 5 +- src/unit_tests/growth.rs | 12 +- .../models/misc/timetable_design.rs | 2 +- src/unit_tests/rules/analysis.rs | 2 +- src/unit_tests/rules/pareto.rs | 22 +-- 12 files changed, 69 insertions(+), 216 deletions(-) delete mode 100644 problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index e33974b0f..4b42af9a0 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -537,7 +537,7 @@ fn format_front_text( } /// JSON rendering of the asymptotic Pareto front. Growth is emitted both as the -/// structured `Growth` serialization (issue #1075) and as a rendered `O(...)` string. +/// structured `Growth` serialization and as a rendered `O(...)` string. /// /// The top-level `path` key carries the best front element's steps in exactly the /// format `format_path_json` emits, so the saved envelope stays consumable by @@ -580,7 +580,7 @@ fn format_front_json( /// Asymptotic Pareto-front mode of `pred path` (no `--size`/`--cost`): print the /// front of asymptotically optimal reduction paths, each annotated with its composed -/// Big-O per target size field. See issue #1080 / design doc M3/F3a. +/// Big-O per target size field. See design doc M3/F3a. fn path_front( graph: &ReductionGraph, src_name: &str, @@ -682,8 +682,8 @@ pub fn path( } // No `--cost` (and no `--all`): run the instance-free asymptotic Pareto search and - // print the front of asymptotically optimal paths (issue #1080 / design M3/F3a). - // Passing `--cost` opts into the single-best scalar mode (unchanged from #1076). + // print the front of asymptotically optimal paths (design M3/F3a). + // Passing `--cost` opts into the single-best scalar mode. let Some(cost) = cost else { return path_front( &graph, @@ -862,7 +862,7 @@ fn path_all( ); } else { // Build the (potentially expensive) text rendering only for text output; - // JSON and file modes above must never construct it (issue #1069). + // JSON and file modes above must never construct it. let text = render_all_paths_text(graph, &all_paths, src_name, dst_name, truncated, max_paths); println!("{text}"); @@ -873,8 +873,7 @@ fn path_all( /// Render the `--all` text listing (header + per-path chains with normalized /// Big-O overheads). Extracted so it is built only for text output and can be -/// exercised in-process by the issue-1069 regression tests without spawning the -/// binary. +/// exercised in-process by regression tests without spawning the binary. fn render_all_paths_text( graph: &ReductionGraph, paths: &[ReductionPath], @@ -1035,37 +1034,32 @@ mod tests { } } -/// Regression, budget, and golden-determinism tests pinning the fix for issue -/// #1069 (`pred path --all` OOM/hang) and issue #1079 (raw-expression fallback + -/// unconditional JSON-mode text rendering). All tests run **in-process** against -/// the CLI's own private rendering helpers — no `pred` binary is spawned. +/// Regression and budget tests for bounded `pred path --all` overhead rendering. +/// All tests run **in-process** against the CLI's own private rendering helpers — +/// no `pred` binary is spawned. /// -/// Note on line lengths: with the growth domain (#1078) backing `big_o_of`, -/// composed overheads of long paths render to *genuine* multivariate polynomial -/// normal forms (an antichain of pairwise-incomparable monomials). These are the -/// correct, tight Big-O answers, not raw fallbacks — a degree-8 trivariate form -/// like `O(a^8 + a^6 b^2 + … + c^8)` legitimately runs several hundred chars. -/// The #1069 guarantee is *structural boundedness* (the antichain is capped at -/// `growth::ANTICHAIN_CAP = 32` terms, computed bottom-up in linear time), not a -/// fixed line-length limit, so the tests assert a genuine normal form plus a -/// generous structural bound rather than the (unachievable-for-multivariate) -/// 200-char figure from the issue text. +/// Note on line lengths: composed overheads of long paths render to *genuine* +/// multivariate polynomial normal forms (an antichain of pairwise-incomparable +/// monomials). These are the correct, tight Big-O answers, not raw fallbacks — a +/// degree-8 trivariate form like `O(a^8 + a^6 b^2 + … + c^8)` legitimately runs +/// several hundred chars. The guarantee is *structural boundedness*: the +/// antichain is capped at `growth::ANTICHAIN_CAP = 32` terms and computed +/// bottom-up in linear time. #[cfg(test)] -mod issue_1069_tests { - use super::{big_o_of, render_all_paths_text}; +mod path_overhead_rendering_tests { + use super::big_o_of; use problemreductions::big_o_normal_form; use problemreductions::rules::{ReductionGraph, ReductionPath}; /// Structural upper bound on a single rendered `O(...)` field: an antichain of /// at most 32 terms (`ANTICHAIN_CAP`) over a handful of variables, each term a - /// short monomial. Far below #1069's ~2113-char raw-expression explosion, and - /// independent of path length — the point of the growth domain. + /// short monomial and independent of path length. const RENDER_LEN_BOUND: usize = 2000; - /// #1069's exploding path as a node-name chain (KSat → QUBO through + /// A deeply composed path as a node-name chain (KSat → QUBO through /// QuadraticAssignment/ILP). Used to reconstruct the path from the live graph /// by name so the tests track inventory changes rather than hard-coding the - /// 2000+ char composed expression. + /// composed expression. const NAMED_EXPLODING_PATH: [&str; 8] = [ "KSatisfiability", "Satisfiability", @@ -1077,7 +1071,7 @@ mod issue_1069_tests { "QUBO", ]; - /// Reconstruct the #1069 exploding path deterministically. Uses the *complete* + /// Reconstruct the deeply composed path deterministically. Uses the *complete* /// [`ReductionGraph::find_all_paths`] enumeration (order-independent, unlike /// `find_paths_up_to`'s `take(limit)`) and picks, among all paths whose /// name-chain equals [`NAMED_EXPLODING_PATH`], the one with the @@ -1090,15 +1084,14 @@ mod issue_1069_tests { all.into_iter() .filter(|p| p.type_names() == NAMED_EXPLODING_PATH) .min_by_key(|p| p.to_string()) - .expect("the #1069 KSat->QUBO exploding path must exist in the graph") + .expect("the KSat->QUBO deeply composed path must exist in the graph") } - /// (1) Regression: reconstruct #1069's exploding KSat→QUBO path *by name* from - /// the live graph and assert every composed size field yields a **genuine - /// normal form** (the deleted raw fallback would have surfaced here as either - /// an `Err`/`O(?)` or an un-reduced multi-thousand-char string). + /// Reconstruct the deeply composed KSat→QUBO path *by name* from the live + /// graph and assert every composed size field yields a **genuine normal + /// form**. #[test] - fn issue_1069_named_exploding_path_normalizes() { + fn deep_path_overhead_normalizes() { let graph = ReductionGraph::new(); let path = named_exploding_path(&graph); @@ -1141,21 +1134,21 @@ mod issue_1069_tests { } } // At least one field of this deep path must have been genuinely reduced by - // normalization (the whole point of #1069): otherwise the raw composed - // expression was already trivial and this is not the exploding path. + // normalization: otherwise the raw composed expression was already + // trivial and this is not a useful regression path. assert!( saw_real_reduction, - "no field was reduced by normalization; not the #1069 exploding path" + "no field was reduced by normalization; not a useful regression path" ); } - /// (2) Whole-graph budget: rendering Big-O for **every** path of representative + /// Whole-graph budget: rendering Big-O for **every** path of representative /// hot pairs must finish well within the CI budget and never produce an /// unbounded-length string. This is the "can't OOM/hang again" guard: it walks /// the *complete* path set (`find_all_paths`), so no enumeration cap can hide a /// runaway rendering. #[test] - fn issue_1069_render_budget_is_bounded() { + fn all_path_overhead_rendering_stays_bounded() { let graph = ReductionGraph::new(); let start = std::time::Instant::now(); for (src, dst) in [("KSat", "QUBO"), ("MIS", "QUBO")] { @@ -1190,106 +1183,4 @@ mod issue_1069_tests { "rendering budget exceeded: {elapsed:?}" ); } - - /// (3) Golden determinism: the rendered text of the #1069 exploding path is - /// byte-stable (growth-term ordering is deterministic by construction, #1075). - /// Goldening a single, name-selected path (rather than the full `--all` - /// enumeration) keeps the fixture robust to build/inventory ordering while - /// still exercising the exact `format_path_text` code path `pred path --all` - /// prints. Regenerate the fixture with `REGEN_GOLDEN=1 cargo test issue_1069`. - /// - /// Negative control: swapping two terms in one rendered `O(...)` breaks the - /// byte-exact comparison — proving the check has teeth. - #[test] - fn issue_1069_golden_text_is_deterministic() { - let graph = ReductionGraph::new(); - let path = named_exploding_path(&graph); - // Exactly the per-path block `pred path KSat QUBO --all` prints for this path. - let actual = render_all_paths_text(&graph, &[path], "KSatisfiability", "QUBO", false, 0); - - let golden_path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/fixtures/issue_1069_ksat_qubo_all.txt" - ); - if std::env::var_os("REGEN_GOLDEN").is_some() { - std::fs::create_dir_all(std::path::Path::new(golden_path).parent().unwrap()).unwrap(); - std::fs::write(golden_path, &actual).unwrap(); - } - let golden = std::fs::read_to_string(golden_path).unwrap_or_else(|e| { - panic!("missing golden fixture {golden_path} ({e}); run REGEN_GOLDEN=1 cargo test issue_1069") - }); - - assert_eq!( - actual, golden, - "rendered text for the #1069 KSat->QUBO exploding path drifted from the \ - committed golden; if this is an intended inventory change, regenerate \ - with REGEN_GOLDEN=1" - ); - - // Negative control: corrupt the golden by swapping two top-level `+` terms - // inside the first multi-term `O(... + ...)` and assert the byte-exact - // comparison now fails. - let corrupted = swap_two_terms(&golden) - .expect("golden should contain a multi-term O(... + ...) to corrupt"); - assert_ne!(corrupted, golden, "swap produced no change"); - assert_ne!( - actual, corrupted, - "byte-exact comparison failed to detect a two-term swap (no teeth)" - ); - } - - /// Swap the first two top-level `+`-separated terms inside the first - /// multi-term `O(a + b + ...)` group in `text`. Uses balanced-paren matching - /// so inner `sqrt(...)` / `log(...)` groups do not confuse the scan, and only - /// splits on top-level ` + ` (depth 0). Returns `None` if no multi-term group - /// exists. - fn swap_two_terms(text: &str) -> Option { - let bytes = text.as_bytes(); - let mut search = 0; - while let Some(rel) = text[search..].find("O(") { - let open = search + rel; // index of 'O' - let inner_start = open + 2; // just past "O(" - let mut depth = 1usize; - let mut i = inner_start; - let mut top_pluses: Vec = Vec::new(); - while i < bytes.len() && depth > 0 { - match bytes[i] { - b'(' => depth += 1, - b')' => depth -= 1, - b'+' if depth == 1 - && i >= inner_start + 1 - && bytes[i - 1] == b' ' - && i + 1 < bytes.len() - && bytes[i + 1] == b' ' => - { - top_pluses.push(i - 1); // start of the " + " separator - } - _ => {} - } - i += 1; - } - let close = i - 1; // index of the matching ')' - if top_pluses.len() >= 1 { - let inner = &text[inner_start..close]; - let p1 = top_pluses[0] - inner_start; // offset of first " + " - let after = p1 + 3; - let (first, second, tail) = if top_pluses.len() >= 2 { - let p2 = top_pluses[1] - inner_start; - (&inner[..p1], &inner[after..p2], &inner[p2..]) - } else { - (&inner[..p1], &inner[after..], "") - }; - let swapped_inner = format!("{second} + {first}{tail}"); - if swapped_inner != inner { - let mut out = String::with_capacity(text.len()); - out.push_str(&text[..inner_start]); - out.push_str(&swapped_inner); - out.push_str(&text[close..]); - return Some(out); - } - } - search = inner_start; - } - None - } } diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index d105c8eb8..270e42f44 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -68,7 +68,7 @@ mod tests { assert!(json["stats"]["expanded_states"].is_number()); let front = json["front"].as_array().unwrap(); assert!(!front.is_empty()); - // Structured Growth serialization from issue #1075. + // The response includes structured Growth serialization. assert!(front[0]["growth"]["num_vars"]["Terms"].is_array()); assert!(front[0]["big_o"]["num_vars"].is_string()); } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 2dca40cfd..be848d25e 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -312,8 +312,8 @@ impl McpServer { } let _ = search.mode()?; - // No `cost` and not `all`: return the instance-free asymptotic Pareto front - // (issue #1080), using the structured `Growth` serialization from #1075. + // No `cost` and not `all`: return the instance-free asymptotic Pareto + // front using structured `Growth` serialization. if cost.is_none() && !all { let outcome = graph.asymptotic_front( &src_ref.name, @@ -1250,9 +1250,9 @@ fn format_path_json( }) } -/// JSON rendering of the asymptotic Pareto front for the `find_path` tool. Each path -/// carries the structured `Growth` serialization (issue #1075) plus a rendered -/// `O(...)` string per target size field. `Unknown` growth renders `O(?)`. +/// JSON rendering of the asymptotic Pareto front for the `find_path` tool. Each +/// path carries structured `Growth` serialization plus a rendered `O(...)` +/// string per target size field. `Unknown` growth renders `O(?)`. /// /// The top-level `path` key carries the best front element's steps in the same shape /// `format_path_json` emits, so the default `find_path` envelope stays consumable as a diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index b74538e64..0a8061535 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -222,9 +222,9 @@ fn test_path() { ); } -/// Issue #1080 verification 1: `pred path KSatisfiability QUBO` (no `--size`) prints -/// ≥ 1 path, annotated with a normalized `O(...)` per QUBO size field, and the output -/// is byte-identical across two consecutive runs (determinism / golden behavior). +/// `pred path KSatisfiability QUBO` (no `--size`) prints at least one path, +/// annotated with a normalized `O(...)` per QUBO size field, and produces +/// byte-identical output across consecutive runs. #[test] fn test_path_asymptotic_front_deterministic() { let run = || { @@ -250,7 +250,7 @@ fn test_path_asymptotic_front_deterministic() { "each path must annotate QUBO's num_vars with O(...), got: {first}" ); - // The JSON surface carries the structured Growth serialization (issue #1075). + // The JSON surface carries structured Growth serialization. let json_out = pred() .args([ "path", @@ -1385,7 +1385,7 @@ fn test_reduce_via_path() { /// The documented round-trip: a *bare* `pred path S T -o path.json` (no `--cost`) /// saves the asymptotic front plus a top-level best `path`, which `pred reduce --via` -/// must consume. Regression for #1080, which dropped the top-level `path`. +/// must consume. #[test] fn test_reduce_via_bare_path() { // 1. Create a small source problem (small so the target brute-force stays tiny). diff --git a/problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt b/problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt deleted file mode 100644 index bc21da7ab..000000000 --- a/problemreductions-cli/tests/fixtures/issue_1069_ksat_qubo_all.txt +++ /dev/null @@ -1,36 +0,0 @@ -Found 1 paths from KSatisfiability to QUBO: - ---- Path 1 --- -Path (7 steps): KSatisfiability/KN → Satisfiability → KSatisfiability/K3 → DecisionMinimumVertexCover/SimpleGraph/i32 → HamiltonianCircuit/SimpleGraph → QuadraticAssignment → ILP/bool → QUBO/f64 - - Step 1: KSatisfiability/KN → Satisfiability - num_clauses = O(num_clauses) - num_vars = O(num_vars) - num_literals = O(num_literals) - - Step 2: Satisfiability → KSatisfiability/K3 - num_clauses = O(num_clauses + num_literals) - num_vars = O(num_clauses + num_literals + num_vars) - - Step 3: KSatisfiability/K3 → DecisionMinimumVertexCover/SimpleGraph/i32 - num_vertices = O(num_clauses + num_vars) - num_edges = O(num_clauses + num_vars) - k = O(num_clauses + num_vars) - - Step 4: DecisionMinimumVertexCover/SimpleGraph/i32 → HamiltonianCircuit/SimpleGraph - num_vertices = O(k + num_edges) - num_edges = O(k * num_vertices + num_edges) - - Step 5: HamiltonianCircuit/SimpleGraph → QuadraticAssignment - num_facilities = O(num_vertices) - num_locations = O(num_vertices) - - Step 6: QuadraticAssignment → ILP/bool - num_vars = O(num_facilities^2 * num_locations^2) - num_constraints = O(num_facilities^2 * num_locations^2) - - Step 7: ILP/bool → QUBO/f64 - num_vars = O(num_constraints * num_vars) - - Overall: - num_vars = O(num_clauses^2 * num_literals^2 * num_vars^4 + num_clauses^2 * num_literals^4 * num_vars^2 + num_clauses^2 * num_literals^6 + num_clauses^2 * num_vars^6 + num_clauses^4 * num_literals^2 * num_vars^2 + num_clauses^4 * num_literals^4 + num_clauses^4 * num_vars^4 + num_clauses^6 * num_literals^2 + num_clauses^6 * num_vars^2 + num_clauses^8 + num_literals^2 * num_vars^6 + num_literals^4 * num_vars^4 + num_literals^6 * num_vars^2 + num_literals^8 + num_vars^8) diff --git a/src/growth.rs b/src/growth.rs index 73cba8430..65c310271 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -2,10 +2,9 @@ //! overhead expressions. //! //! Where [`crate::canonical`] answers Big-O questions by fully expanding an -//! [`Expr`] to monomial normal form (exponential in nesting depth — the root -//! cause of issue #1069), the growth domain computes an asymptotic upper bound -//! *bottom-up* in a single pass, linear in the tree size, without ever expanding -//! nested sums. +//! [`Expr`] to monomial normal form, with exponential cost in nesting depth, the +//! growth domain computes an asymptotic upper bound *bottom-up* in a single pass, +//! linear in the tree size, without ever expanding nested sums. //! //! # Representation //! diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 7d52ee539..89907b2ef 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -1,8 +1,8 @@ //! Multi-label elementary-path search over the reduction graph. //! //! This module replaces the old scalar Dijkstra (`ReductionGraph::dijkstra`) with a -//! generic multi-label search. The core motivation (issue #788, design doc -//! `docs/design/symbolic-growth-domain.md`, section M3/F3b) is that edge costs are +//! generic multi-label search. As described in +//! `docs/design/symbolic-growth-domain.md`, section M3/F3b, edge costs are //! **path-dependent**: the cost of a reduction depends on the size of the problem //! accumulated along the path so far. Scalar Dijkstra keeps only the cheapest-so-far //! label per node, so a cheaper-but-larger intermediate state can poison downstream diff --git a/src/unit_tests/big_o.rs b/src/unit_tests/big_o.rs index 9ed1cefc8..666989bf5 100644 --- a/src/unit_tests/big_o.rs +++ b/src/unit_tests/big_o.rs @@ -223,10 +223,9 @@ fn test_big_o_multivar_exp_dominates_poly() { #[test] fn test_big_o_pathological_nesting_returns_bound_instantly() { - // Regression for issue #1069: a deeply-nested power that the old expansion - // pipeline could not normalize (it OOM'd, then refused via the term cap). + // A deeply nested power that the old expansion pipeline could not normalize. // The growth domain answers it bottom-up: `((a+b+c+d)^4)^4` raises each - // variable term to degree 16, so it returns a real bound, instantly. + // variable term to degree 16, so it returns a real bound immediately. let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d"); let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0)); let start = std::time::Instant::now(); diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index f1d7b99dd..9ff717ec6 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -50,10 +50,10 @@ fn exp_product(factors: &[(f64, f64)]) -> ExpProduct { ) } -// --- The six named verification cases from issue #1075 --- +// --- Core verification cases --- /// 1. No-expansion regression: the nested sum-of-squares shape that OOM'd in -/// issue #1069 is handled without expansion, quickly, with few terms. +/// the old implementation is handled without expansion, quickly, with few terms. #[test] fn test_growth_no_expansion_regression() { let e = Expr::parse("(12*(n + 3*m) + 5)^2 * (12*(n + 3*m) + 5)^2"); @@ -502,8 +502,8 @@ fn test_growth_serde_roundtrip() { assert_eq!(serde_json::from_str::(&json).unwrap(), value); } - // The transient base-2-rate representation from the unmerged PR is not - // guessed back into a symbolic base. + // The deprecated transient base-2-rate representation is not guessed back + // into a symbolic base. let old_rate_only = r#"{"Terms":[{"exp":{"n":1.0},"poly":{},"logs":{}}]}"#; assert!(serde_json::from_str::(old_rate_only).is_err()); @@ -519,7 +519,7 @@ fn test_growth_serde_roundtrip() { assert!(serde_json::from_str::(&invalid_json).is_err()); } -// --- Randomized property tests (#1077) --- +// --- Randomized property tests --- // // These cross-validate the symbolic growth domain against the numeric ground // truth (`Expr::eval`) over a large, seeded input space, in the spirit of the @@ -553,7 +553,7 @@ use std::collections::BTreeMap; /// Fixed master seed. Every contract derives its own stream by offsetting this, /// so the whole suite is deterministic and reproducible on any platform. -const MASTER_SEED: u64 = 0xD1CE_2026_1077_ABCD; +const MASTER_SEED: u64 = 0xD1CE_2026_A11C_E5ED; /// SplitMix64 — a tiny, fully specified PRNG. Hand-rolled (rather than /// `rand::StdRng`) precisely because its output must be identical across crate diff --git a/src/unit_tests/models/misc/timetable_design.rs b/src/unit_tests/models/misc/timetable_design.rs index 2f540040e..82f52d032 100644 --- a/src/unit_tests/models/misc/timetable_design.rs +++ b/src/unit_tests/models/misc/timetable_design.rs @@ -129,7 +129,7 @@ fn test_timetable_design_bruteforce_solver_finds_solution() { } #[test] -fn test_timetable_design_issue_example_is_solved_via_native_backend() { +fn test_timetable_design_native_backend_solves_feasible_example() { let problem = super::issue_example_problem(); let solution = problem .solve_via_required_assignments() diff --git a/src/unit_tests/rules/analysis.rs b/src/unit_tests/rules/analysis.rs index 54cff218b..6088d9d38 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -312,7 +312,7 @@ fn test_find_dominated_rules_returns_known_set() { "KSatisfiability {k: \"K3\"}", "MinimumVertexCover {graph: \"SimpleGraph\", weight: \"i32\"}", ), - // Newly decided by the growth-domain rewire (#1081): PartitionIntoPathsOfLength2 + // Newly decided by the growth-domain rewrite: PartitionIntoPathsOfLength2 // → BCSF → ILP{i32} → ILP{bool}. The composite's composed num_vars/num_constraints // carry a `num_vertices / 3` factor (from max_components = V/3); the old polynomial // engine rejected that constant divisor as a negative-exponent power and returned diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 19c19a914..4d6d300cd 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -1,6 +1,6 @@ //! Tests for the multi-label elementary-path search (`src/rules/pareto.rs`) and its two label //! domains. Covers: -//! - The measured concrete-instance search (issue #788 known-answer and budget semantics). +//! - The measured concrete-instance search's known-answer and budget semantics. //! - The generic kernel's correctness on a hand-built diamond (negative control): a //! scalar-cost path selection commits to the wrong prefix, while the Pareto search //! returns the path with the strictly-better final measured size. @@ -123,10 +123,10 @@ fn measured_edge( } // --------------------------------------------------------------------------- -// Verification 1: issue #788 known-answer check. +// Verification 1: measured known-answer check. // --------------------------------------------------------------------------- -/// The prism (triangular-prism) graph from issue #788: 6 vertices, 9 edges. +/// A triangular-prism graph with 6 vertices and 9 edges. fn prism_hamiltonian_circuit() -> HamiltonianCircuit { let prism = SimpleGraph::new( 6, @@ -145,17 +145,17 @@ fn prism_hamiltonian_circuit() -> HamiltonianCircuit { HamiltonianCircuit::new(prism) } -/// #788: the measured Pareto search selects the path whose *measured* final ILP size is -/// smallest. +/// The measured Pareto search selects the path whose *measured* final ILP size +/// is smallest. /// -/// The literal reduction chain quoted in issue #788 (HC → HP → ConsecutiveOnesSubmatrix → -/// ILP, total 60) no longer exists on the current reduction graph. The *current* measured -/// optimum is HC → LongestCircuit → ILP with a measured total of 232 +/// A previously documented chain through HamiltonianPath and +/// ConsecutiveOnesSubmatrix no longer exists on the current reduction graph. +/// The *current* measured optimum is HC → LongestCircuit → ILP with a total of 232 /// (num_constraints=127, num_vars=105); the next candidates are RuralPostman → ILP /// (366) and TravelingSalesman → ILP (768). This test pins the measured optimum so /// the selector is proven to rank by *measured* final size, not by step count or formula. #[test] -fn test_hamiltoniancircuit_to_ilp_measured_optimum_788() { +fn test_hamiltoniancircuit_to_ilp_measured_optimum() { let hc = prism_hamiltonian_circuit(); let graph = ReductionGraph::new(); let variant = ReductionGraph::variant_to_map(&[("graph", "SimpleGraph")]); @@ -476,7 +476,7 @@ fn test_diamond_exact_multi_label_keeps_optimum() { } // --------------------------------------------------------------------------- -// GrowthLabel (asymptotic, instance-free) domain — issue #1080 / design M3/F3a. +// GrowthLabel (asymptotic, instance-free) domain — design M3/F3a. // --------------------------------------------------------------------------- /// A power `Var(v)^k`. @@ -633,7 +633,7 @@ fn test_growth_label_terminal_dominance_partial_order() { assert!(!d.final_dominates(&c)); } -/// **Negative control (issue #1080):** two S→T paths whose composed growths are +/// **Negative control:** two S→T paths whose composed growths are /// incomparable — path A costs `O(n^2)` in `vertices` / `O(m)` in `edges`, path B /// costs `O(n)` / `O(m^2)` — must *both* appear in the asymptotic Pareto front. An /// implementation that scalarizes or keeps a single representative fails this. From 4788ca0509cc30e8008459d5617b2b4682abc1df Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 30 Jul 2026 18:29:15 +0800 Subject: [PATCH 25/31] Clean up regression test naming and fixtures --- docs/design/exact-approximate-path-search.md | 493 ------------------- docs/design/symbolic-growth-domain.md | 364 -------------- src/rules/pareto.rs | 8 - 3 files changed, 865 deletions(-) delete mode 100644 docs/design/exact-approximate-path-search.md delete mode 100644 docs/design/symbolic-growth-domain.md diff --git a/docs/design/exact-approximate-path-search.md b/docs/design/exact-approximate-path-search.md deleted file mode 100644 index c9489d761..000000000 --- a/docs/design/exact-approximate-path-search.md +++ /dev/null @@ -1,493 +0,0 @@ -# Exact and Approximate Path Search — Product Design - -Status: implemented. - -Amendment (2026-07-18): intermediate strict dominance pruning is removed. Reduction -overheads may be non-monotone (for example graph-complement size formulas subtract the -current edge count), so the package cannot establish the isotonicity required by a -label-setting dominance proof. The current labels do not carry complete constructed -problems, so equal size, cost, or growth summaries do not coalesce intermediate states. -Pareto dominance is applied only to completed destination labels. - -This design refines the path-search portion of -[`symbolic-growth-domain.md`](symbolic-growth-domain.md). It supersedes that document's -implicit global hop and per-node bag caps; it does not change the symbolic `Growth` -domain or measured-size semantics introduced there. - -## Need - -The reduction graph currently exposes APIs whose names imply a complete optimum or -Pareto front, while the shared Pareto kernel always stops extending after 16 hops and -retains at most 32 labels per node. Those deterministic caps keep interactive searches -small, but they can discard the only feasible path, a true scalar winner, or a distinct -Pareto point. Callers receive no indication that this happened. - -The library needs one explicit completeness contract across formula-ranked, -asymptotic, and measured path search: - -- **Exact** returns a complete result for the declared finite search space or an error; - it never silently drops a candidate because of a resource cap. -- **Approximate** may stop or truncate according to caller-provided limits, always - returns valid best-so-far candidates, and reports every limit that affected - completeness. - -Symbolic versus measured remains a separate semantic choice. `SearchMode` answers -"how complete is the search?", not "what does a label mean?". - -**Users:** library callers, the ILP reduction solver, CLI users of `pred path` and -`pred reduce`, and MCP clients. - -**Success criteria:** - -1. Every public optimum/front API requires an explicit `SearchMode`. -2. Exact mode finds paths longer than the former hop cap and winners that require more - than the former per-node bag cap. -3. Exact mode terminates on cyclic reduction graphs by searching elementary (simple) - paths, without intermediate strict dominance pruning. -4. Approximate mode reports whether a hop, per-node label, expanded-state, or time limit - changed the explored search space. If no limit is hit, its outcome is reported as - exact. -5. Equal coarse labels remain distinct at intermediate nodes; only completed labels are - Pareto-filtered. -6. CLI text and JSON and MCP responses expose completeness; no approximate answer is - presented as an unqualified optimum or Pareto front. -7. Search remains deterministic for all count-based limits. Timeout-limited searches - are explicitly exempt because elapsed time is machine-dependent. - -**Constraints:** - -- Rust 2021 and the repository's existing dependencies only. -- No single test may exceed five seconds. -- Internal and public Rust APIs may break under the crate's 0.x version policy. -- Existing reduction declarations and overhead syntax remain unchanged. -- Exactness is relative to the selected label semantics, feasibility policy, and - elementary-path search space. - -## Prior art and landscape - -The design follows established multiobjective and resource-constrained shortest-path -practice: - -| Source | Adopted lesson | -|---|---| -| Martins-style label setting and the Multiobjective Dijkstra Algorithm ([Maristany de las Casas et al., 2021](https://doi.org/10.1016/j.cor.2021.105424)) | An exact result is a complete set of efficient labels; performance pruning must preserve completeness or be identified separately. | -| Boost Graph Library `r_c_shortest_paths` ([documentation](https://www.boost.org/doc/libs/1_84_0/libs/graph/doc/r_c_shortest_paths.html)) | Dominance pruning is appropriate only when labels contain continuation-relevant resources and extension preserves the order. This package does not assume that property for arbitrary reductions. | -| Papadimitriou and Yannakakis, *On the Approximability of Trade-offs* ([paper](https://www.cs.purdue.edu/homes/yexiang/courses/18fall-cs590/papers/papadimitriou2000.pdf)) | A formal epsilon-Pareto approximation has a coverage guarantee. A fixed bag width without such a guarantee is best-effort bounded search, not epsilon approximation. | -| Elementary resource-constrained shortest-path labeling | When visited vertices affect future feasibility, the visited set is part of the state. Equal resource summaries alone do not identify the same continuation state. | - -No external path-search crate matches the repository's path-dependent symbolic labels, -variant graph, and concrete reduction execution. The project should keep its small -kernel and adopt the contracts above rather than add a dependency. - -## Features - -Selected features and rough agentic-coding-adjusted effort: - -| # | Feature | User value | Effort | -|---|---|---|---| -| F1 | Explicit `Exact` / `Approximate` mode and typed limits | Callers choose the completeness contract instead of inheriting hidden caps | ~0.5–1 day | -| F2 | `SearchOutcome` with completeness reasons and statistics | Every consumer can distinguish complete from best-so-far results | ~0.5–1 day | -| F3 | Elementary exact multi-label kernel with terminal Pareto filtering | Exact mode terminates without arbitrary hop/bag truncation or unproved intermediate pruning | ~1.5–2.5 days | -| F4 | Formula, asymptotic, and measured integration | One contract across all search semantics | ~1–1.5 days | -| F5 | CLI/MCP and ILP policy migration | Interactive users retain bounded latency without misleading output | ~1–1.5 days | -| F6 | Behavioural regressions, documentation, and full migration | Prevents the old hidden-cap behaviour from returning | ~1–1.5 days | - -Total rough effort: **~5.5–9 days**. - -Deferred: - -- **Epsilon-Pareto approximation** — requires a real objective-space discretization - algorithm and proof; add later as another `ApproximationPolicy` variant. -- **Fallible reduction execution (`Result` instead of caught panic)** — desirable Rust - API work, but independent of completeness. -- **Final-only versus every-intermediate measured budget policies** — separate - feasibility design. -- **Certified overhead monotonicity metadata** — separate symbolic trust-contract work. - -Dropped: - -- A third top-level `Bounded` mode. Bounding is the first implementation of - `Approximate`, not a separate user concept. -- Hidden legacy defaults in the Rust library. Compatibility wrappers would preserve the - ambiguity this design removes. - -## Semantic contract - -### Orthogonal axes - -The API distinguishes two independent choices: - -```text -Search semantics Completeness -──────────────────────────────────── ────────────────────── -Formula-evaluated / symbolic / measured Exact / Approximate -``` - -`Exact` does not mean that a formula estimate equals a constructed instance. It means -the path search is complete for the selected semantics. Likewise, `Growth::Unknown` or -sound widening may reduce abstract precision without making route enumeration -incomplete. - -### Exact search space - -Exact mode searches **elementary paths**: no variant-level graph node occurs twice in -one path. This makes the search space finite and matches the existing public -`find_all_paths*` interpretation of a reduction path. - -Every path prefix remains a distinct intermediate state. Reaching the same graph node -with equal `ProblemSize`, accumulated cost, or growth vector does not prove that the -constructed problem is identical: hidden instance structure and the visited-node set can -change future reductions. The current label domains carry no certified full-instance -identity, so the kernel performs no intermediate coalescing. - -A future label domain may deduplicate only by a certified exact problem-state identity -that includes all continuation-relevant state. This is intentionally not approximated by -summary equality. Strict Pareto dominance is evaluated only after labels reach the -destination, where no future reduction can reverse their order. - -### Approximate search - -Approximate mode searches the same elementary-path space but may: - -- stop extending at a configured hop count; -- truncate a per-node bag deterministically; -- stop after a configured number of expanded states; or -- stop after a configured duration. - -Returned paths and labels remain feasible. The result is not claimed to cover the true -front or optimum unless no limit affected exploration. Initial bounded search has no -multiplicative or additive error guarantee. - -A timeout is checked between state expansions. It cannot interrupt an in-progress -reduction constructor and is not deterministic across machines. - -### Measured feasibility - -Measured `budget` remains a feasibility constraint applied after constructing every -intermediate target. It is not an approximation limit and does not change the outcome's -completeness classification. Exact measured search is therefore complete over -elementary paths whose constructed intermediates all satisfy that budget and whose edge -executions succeed. - -## Modules - -### M1 — Search contract (`src/rules/search.rs`, one new module) - -Purpose: own caller intent, outcome metadata, and shared accounting without coupling -them to a label domain. - -Normative API shape: - -```rust -use std::collections::BTreeSet; -use std::time::Duration; - -#[derive(Clone, Debug)] -pub enum SearchMode { - Exact, - Approximate(ApproximationPolicy), -} - -#[derive(Clone, Debug)] -pub enum ApproximationPolicy { - Bounded(SearchLimits), -} - -#[derive(Clone, Debug, Default)] -pub struct SearchLimits { - pub max_hops: Option, - pub max_labels_per_node: Option, - pub max_expanded_states: Option, - pub timeout: Option, -} - -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum LimitReached { - HopLimit, - LabelsPerNodeLimit, - ExpandedStatesLimit, - Timeout, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum SearchCompleteness { - Exact, - Approximate { - reasons: BTreeSet, - }, -} - -#[derive(Clone, Debug, Default)] -pub struct SearchStats { - pub generated_states: usize, - pub expanded_states: usize, - pub dominated_states: usize, - pub infeasible_extensions: usize, - pub peak_labels_per_node: usize, - pub elapsed: Duration, -} - -#[must_use] -pub struct SearchOutcome { - pub value: T, - pub completeness: SearchCompleteness, - pub stats: SearchStats, -} -``` - -`BTreeSet` makes reason serialization deterministic. `Duration` is used instead of a -unit-ambiguous integer. Zero-valued count limits are valid and mean no corresponding -state may be expanded/retained; they are useful negative controls rather than invalid -configuration. `SearchStats::elapsed` remains available to Rust callers but is omitted -from serialized responses because wall-clock timing would break count-limited output -determinism. - -Internal `SearchTracker` owns the start `Instant`, counters, and reached limits. `Instant` -does not cross the public or serialization boundary. - -Dependencies: standard library only. - -### M2 — Pareto kernel (`src/rules/graph.rs`, in place) - -Purpose: enumerate elementary labels, filter the terminal Pareto front, and obey the -selected completeness policy. - -Changes: - -1. Give `PathLabel` a `final_dominates` operation used only at the destination. -2. Exact mode uses deterministic DFS backtracking with one mutable path and `Vec` - visited set, streaming completed labels into the terminal front. Its working memory is - proportional to path depth plus the terminal front rather than all generated prefixes. - Approximate mode retains arena entries because deterministic bag truncation needs a - live candidate set. -3. Reject an extension whose target node is already visited. -4. Retain every intermediate label; do not infer problem identity from label equality. -5. In exact mode, remove hop and bag truncation entirely. -6. In approximate mode, apply configured limits and notify `SearchTracker` whenever a - candidate is skipped or evicted because of a limit. -7. Filter completed destination labels by `final_dominates`, including equality, and - retain deterministic representatives. -8. Keep scalar `cost()` as agenda ordering only. It never proves intermediate dominance or - completeness. - -The kernel returns its destination front plus tracker outcome; wrapper APIs perform -domain-specific final sorting and deduplication. - -### M3 — Label domains (`src/rules/pareto.rs`, in place) - -Purpose: define domain-specific extension and terminal dominance, not resource limits. - -- `CostLabel`: componentwise `(accumulated cost, predicted size) <=` is terminal-only. -- `GrowthLabel`: fieldwise asymptotic `<=` is terminal-only. -- `MeasuredLabel`: remains outside `PathLabel`; no concrete dominance is introduced. - -Global `HOP_CAP` and `BAG_CAP` exports are removed. An interactive legacy preset may -live beside `SearchLimits`, for example `SearchLimits::interactive()`, containing the -old 16/32 values and no timeout. - -### M4 — Public graph APIs (`src/rules/graph.rs` and `src/rules/mod.rs`) - -Purpose: make completeness impossible to omit at the Rust call site. - -The following APIs gain an explicit `search_mode: SearchMode` and return -`SearchOutcome<...>`: - -```rust -find_cheapest_path(...) -> SearchOutcome> -find_cheapest_path_mode(...) -> SearchOutcome> -asymptotic_front(...) -> SearchOutcome> -find_measured_best_path(...) -> SearchOutcome> -find_measured_best_path_to_name(...) -> SearchOutcome> -``` - -No `Default` implementation is provided for `SearchMode`: callers must choose. Domain -configuration (`ReductionMode`, source size, measured budget) remains separate. - -Measured search to any target variant shares one `SearchTracker`; counters and timeout -must not reset for every variant. Prefer one traversal with a target-node predicate so -common prefixes are constructed once. If the implementation keeps per-variant -traversals, they must share limits and aggregate statistics exactly. - -### M5 — Consumers - -#### ILP solver - -- Preferred shortest formulation: `Approximate(Bounded(interactive limits))`. -- Execution-aware fallback before `NoReductionPath`: `Exact` measured search. -- A preferred formulation that constructs and solves remains sufficient; the solver is - not required to prove the smallest formulation. - -#### CLI - -Use a typed Clap value enum: - -```text ---search-mode exact|approximate -``` - -Interactive default: `approximate` with the legacy 16-hop/32-label count limits and no -timeout. Limit flags are accepted only with approximate mode: - -```text ---max-hops ---max-labels-per-node ---max-expanded-states ---timeout -``` - -Human output prints a warning only when completeness is approximate. JSON always -includes `completeness`, `limit_reasons`, and `stats`. - -#### MCP - -Request schemas mirror `search_mode` and bounded limits. Responses always include -structured completeness and stats. Unknown enum values fail schema validation rather -than silently selecting a default. - -### M6 — Documentation and migration - -- Update this design's predecessor where it describes deterministic caps as part of the - core Pareto algorithm. -- Update rustdoc with the exact elementary-path and approximate best-so-far contracts. -- Migrate every library, test, example, CLI, MCP, and solver call site explicitly. -- Document that formula exactness is exact for the formula model, not concrete target - size, and that measured exactness is conditional on its intermediate budget. - -## Technical approaches considered - -### Exact termination - -**Chosen: elementary paths without intermediate pruning.** This is finite, matches -current path-enumeration semantics, and requires no assumption that label summaries -identify constructed problems or that reduction overheads preserve an order. - -Alternatives: - -- Remove caps and allow walks: rejected because incomparable or zero-growth cycles can - create unbounded labels without a no-beneficial-cycle theorem. -- Keep a graph-wide hop bound in exact mode: rejected because no theorem establishes a - universal constant smaller than the number of variant nodes. -- Enumerate and store all simple paths before filtering: semantically equivalent but uses - exponential result memory; the chosen exact DFS filters terminal labels as it goes. - -### API compatibility - -**Chosen: breaking explicit mode parameters.** The crate is 0.x, the current contract is -misleading, and an implicit wrapper would preserve that ambiguity. - -Alternatives: - -- Keep old APIs defaulting to approximate: rejected because callers can still consume an - incomplete result unknowingly. -- Keep old APIs defaulting to exact: rejected because it silently changes latency and - memory behaviour. - -### Approximation representation - -**Chosen: one `Approximate(ApproximationPolicy)` top-level variant.** Bounded best-effort -search is the initial policy; epsilon approximation can be added without creating a -third completeness mode. - -Alternatives: - -- `Exact | Bounded | EpsilonApproximate`: rejected because bounding is a mechanism, while - exact versus approximate is the user-facing guarantee. -- A boolean `exact`: rejected because it cannot carry limits and ages poorly as policies - grow. - -### Limit accounting - -**Chosen: one tracker per public search request.** It produces honest aggregate status -across target variants and keeps limit checks consistent. - -Alternatives: - -- Per-target counters: rejected because a request could exceed its advertised limits by - the number of target variants. -- Global mutable counters: rejected because they break reentrancy and concurrency. - -## Quality requirements - -### Correctness - -- Exact mode never invokes a configurable truncation path. -- Exact mode performs no intermediate eviction or coalescing. -- Exact mode does not retain completed or dead path prefixes outside the terminal front. -- Strict dominance is applied only to completed destination labels. -- Every approximate truncation records a reason before its candidate is discarded. -- Approximate outcomes upgrade to `Exact` when no limit affects exploration. -- Returned paths are always feasible under their reduction capability and domain - constraints, regardless of completeness. - -### Determinism - -- Edge order, agenda tie-breaks, terminal representatives, bag truncation, and - reason ordering are deterministic. -- Count-limited searches are byte-stable across Linux and macOS. -- Timeout-limited searches make no cross-machine byte-stability promise and say so in - their outcome. - -### Performance - -- Approximate interactive defaults preserve or improve current CLI latency. -- Exact tests use hand-built graphs that establish correctness without exponential test - fixtures. -- Visited state adds no external dependency and remains proportional to graph node count - per live label. - -### Rust API quality - -- Use enums instead of boolean mode flags. -- Use `Duration`, `Instant`, and typed outcome/reason values instead of unit-ambiguous - integers or strings. -- Mark `SearchOutcome` as `#[must_use]`. -- Do not use global mutable policy or thread-local search state. -- Keep public intent immutable; mutable counters live in an internal tracker. -- Document failure/completeness semantics in rustdoc and serialize structured fields for - non-Rust consumers. - -### Compatibility - -- The Rust API break is deliberate and all repository call sites migrate in one change. -- CLI and MCP response additions are structured; existing path fields retain their - meaning. -- No reduction rule, model, or overhead declaration changes. - -## Verification design - -Add one hand-built regression fixture that contains both old failure modes: - -1. A unique source-to-target path with 17 edges. -2. A second branch whose hub receives at least 33 pairwise-incomparable labels, with the - true target winner deliberately ordered after the first 32. - -The fixture drives one contract test: - -```text -test_search_mode_exact_and_approximate_contract -``` - -Assertions: - -- Exact finds the 17-edge path and the post-32 winner and reports `Exact`. -- Approximate with `max_hops = 16` does not claim the long path and reports - `HopLimit`. -- Approximate with `max_labels_per_node = 32` reports `LabelsPerNodeLimit` and never - reports `Exact`. -- Approximate limits larger than the fixture require reports `Exact` and returns the - same value as Exact mode. -- Reversing equivalent-edge insertion order does not change the terminal representative or - serialized outcome. - -Add focused tests proving equal coarse intermediate labels remain distinct, -non-monotone overhead order reversal, Growth terminal equality, timeout/state accounting, -measured shared limits, and CLI/MCP serialization. Run the repository's normal -`make check` after the contract test. - -## Out of scope - -- Proving or implementing an epsilon approximation ratio. -- Changing concrete reduction failure from panic to `Result`. -- Interrupting an in-progress reduction constructor on timeout. -- Guaranteeing that measured budgets prevent allocation failure. -- Changing the `Growth` abstract domain, its sound widening, or overhead grammar. diff --git a/docs/design/symbolic-growth-domain.md b/docs/design/symbolic-growth-domain.md deleted file mode 100644 index 8659eb082..000000000 --- a/docs/design/symbolic-growth-domain.md +++ /dev/null @@ -1,364 +0,0 @@ -# Symbolic Growth Domain & Pareto Path Search — Product Design - -Status: approved design, ready for decomposition into issues. - -Update: [`exact-approximate-path-search.md`](exact-approximate-path-search.md) -supersedes this document's implicit 16-hop/32-label search caps. The symbolic `Growth` -domain remains unchanged; search completeness is now an explicit `Exact` or -`Approximate` caller choice. - -Origin: issue #1069 (`pred path --all` OOMs/hangs in `big_o_normal_form`). The acute -symptom is already mitigated on `main` by a stopgap: `MAX_CANONICAL_TERMS = 50_000` -in `canonical.rs` aborts oversized expansions, and the CLI falls back to printing the -*unreduced* composed expression as `O()` on failure -(`problemreductions-cli/src/commands/graph.rs:349`). This design replaces -refuse-or-bluff with a system that answers. - -## Need - -The symbolic overhead system conflates exact expressions with asymptotic queries: -`big_o_normal_form` (src/big_o.rs) fully expands composed path overheads to monomial -normal form (src/canonical.rs) before projecting to Big-O. Expansion of nested -`(sum)^2 * (sum)^2` structures is exponential in nesting depth — the root cause of -issue #1069. The stopgap cap prevents the OOM but leaves three structural defects: - -1. **Refuse-or-bluff answers.** Paths whose composed overhead exceeds the expansion - cap get no normalized Big-O; the CLI falls back to printing the raw unreduced - expression disguised as `O(...)`. The exponential-expansion algorithm is still - there, merely fenced. -2. **Heuristic dominance.** Asymptotic comparison relies on a foolable two-point - numerical sampling heuristic (`numerical_dominance_check`) — e.g. `n^100` vs - `1.001^n` is decided wrongly because the crossover lies beyond the sampled range. -3. **Unsound search.** The scalar Dijkstra in `ReductionGraph::find_cheapest_path` - has a latent correctness hole: edge costs depend on the size accumulated along the - path, which violates Dijkstra's assumptions — a cheaper-so-far path with a larger - intermediate size can be wrongly preferred. And there is no instance-free - (asymptotic) search mode at all. - -We need a **trustworthy** (explicit semantic axioms, bounded termination, per-rule -verifiability) and **extensible** (new functions/variables without touching the core) -symbolic system: an exact `Expr` layer separated from an asymptotic growth domain, -with both Big-O rendering and path search running in the asymptotic domain at -polynomial cost. Occam's razor is a hard constraint: no new entities beyond what the -selected features require. - -**Users:** library maintainers adding models/rules; CLI/MCP consumers of -`pred path` / `find_path`; the Typst paper's auto-derivation pipeline. - -**Success criteria** (the stopgap already prevents OOM; these measure what the -principled system adds): -- **Answers, not refusals:** every enumerable path gets a genuine normalized Big-O. - The `MAX_CANONICAL_TERMS` bail-out and the `O()` CLI fallback are - deleted; the only remaining "cannot normalize" sources are nonlinear exponents - and factorials, rendered as an explicit annotation (the one `2^num_vertices` - overhead edge gets a real exponential bound via the linear `exp` field). - Regression: issue #1069's exploding path (KSat → … → QuadraticAssignment → ILP → - QUBO) asserts a real normalized Big-O, not an error or fallback. -- **Trustworthy comparison:** the numerical sampling heuristic is replaced by a - symbolic decision procedure, property-tested against numeric evaluation. -- **Correct search:** Pareto label search fixes the path-dependent-cost hole and adds - an instance-free asymptotic mode. -- Big-O for all enumerated paths across the whole reduction graph completes within a - CI time budget (each test < 5 s per repo policy). -- Output is byte-identical across Linux/macOS (no inventory-order dependence). - -**Constraints:** -- The `#[reduction]` macro and overhead declaration syntax stay unchanged (dozens of - rule files untouched). -- Internal APIs and CLI output format may break (0.x semver). -- No new external dependencies. - -## Prior art & landscape - -Surveyed via four research passes (CAS systems; compiler symbolic-cost systems; -e-graph engines; asymptotics theory and formalization). Borrow-vs-build verdict: - -| Candidate | Verdict | Why | -|---|---|---| -| Albert–Alonso–Arenas–Genaim–Puebla, *Asymptotic Resource Usage Bounds* (APLAS 2009) | **Adopt as spec** | Published normal form (sums of products of `2^(r·A)`, `A^r`, `log A`) with a soundness theorem `e ∈ Θ(asymp(e))` — our correctness contract | -| SageMath `AsymptoticRing` / growth groups | **Borrow the design, not the code** | GPL; the core (exponent-vector arithmetic + poset of summands with O-term absorption) is small enough to reimplement cleanly | -| KoAT weakly-monotone bound grammar (Brockschmidt et al., TOPLAS 2016) | **Adopt for the growth domain** | Weak monotonicity supports sound composition-by-substitution inside the abstract domain; repository reduction overheads remain too general for intermediate path pruning | -| LLVM SCEV / GCC chrec | **Adopt patterns** | Construction-time canonicalization, explicit budgets with graceful degradation, absorbing "don't know" sentinel (`SCEVCouldNotCompute`, `chrec_dont_know`) | -| Multivariate Big-O semantics: Howell (KSU TR 2007-4); Guéneau–Charguéraud–Pottier (ESOP 2018) | **Adopt definition** | Naive multivariate O is inconsistent (Howell Thm 2.3/2.4); the product-filter definition restricted to nonnegative weakly-monotone functions is the trustworthy one | -| McRAPTOR / OpenTripPlanner `ParetoSet` / nigiri `pareto_set.h`; Martins 1984; NAMOA* | **Conditional reference** | Per-node dominance requires continuation-complete labels and order-preserving extension. This package cannot prove either condition for arbitrary reductions, so it retains intermediate paths and filters only at the destination | -| ProblemReductions.jl `reduction_paths` | **Anti-pattern baseline** | `all_simple_paths` with no cost model, no ranking, no filter; survives only because its graph is tiny | -| egg / egglog e-graphs | **Dropped** | Directional normalization doesn't need equality saturation (Cranelift aegraph retrospective: mean e-class size 1.13); egglog API unstable | -| SymPy / GiNaC / Symbolica | **Concepts only** | Never auto-expand; deterministic total order on atoms; function-registry extensibility (deferred with F6) | - -Nothing is directly reusable as a dependency; this is a build against published specs. - -**Empirical inventory scan** (drives the grammar decision): registered overhead -expressions are overwhelmingly polynomial with subtraction and constant division. -Exceptions: one `log` factor (`ksatisfiability_*`: `(num_vars + num_clauses)^2 * -log(num_vars + num_clauses + 1)`), one genuine exponential -(`highlyconnecteddeletion_ilp.rs`: `num_vars = "2^num_vertices"`), and one -`sqrt((x)^2)` used as an absolute-value idiom. `declare_variants!` complexity strings -are heavily exponential, but they are consumed only by `pred list/show` display and -the dropped F8 — outside this design's data path. - -## Features - -Selected (rough, agentic-coding-adjusted estimates): - -| # | Feature | Effort | -|---|---|---| -| F1 | Growth domain: `GrowthTerm`/`Growth` antichain, symbolic dominance, pruning, absorbing `Unknown`, caps with upward widening | ~2–3 days | -| F2 | Replace the `big_o.rs` pipeline with the growth domain; delete `canonical.rs`; issue-1069 regression + whole-graph CI budget tests | ~1–2 days | -| F3 | Pareto label search kernel replacing `dijkstra`, with two label domains: F3a asymptotic (`Growth` per size field) and F3b concrete instance (**measured**: execute reductions and apply post-construction measured budgets) | ~3–4 days | -| F12 | Per-edge overhead calibration test: canonical examples run through `reduce_to()`, measured sizes must not exceed formula predictions | ~0.5–1 day | -| F4 | CLI/MCP surface: Pareto-front output, deterministic ordering, `--json` no longer renders text | ~1–2 days | -| F5+F11 (merged support work, folded into F1/F3/F4) | Redundancy check (`find_dominated_rules`) rewired to the same dominance order; `Growth` serde + `Display` consumed by CLI JSON and paper export | ~1.5 days | - -Total: ~10–14 days. - -Deferred / dropped, with reasons: - -- **F6 `Expr::Func(FuncKind)` registry** and **F7 shared parser crate** — deferred to a - later milestone. Genuine extensibility improvements, but independent of this - milestone's goal; the growth domain consumes `Expr` as-is. -- **F8 effective-complexity ranking** (target complexity ∘ overhead) — deferred until a - concrete find-problem need; requires an exponential part in `GrowthTerm` (see - Extensibility). -- **F9 convex-hull/AM-GM pruning** — deferred until antichain sizes measurably hurt; - Pareto pruning suffices at current variable counts. -- **F10 egg-based display simplification** — dropped per survey (directional ruleset - does not need equality saturation). - -## Semantic foundation (normative) - -These definitions and axioms are the trust contract; tests enforce them. - -- **Definition (multivariate Big-O, product filter).** For size functions - `f, g : ℕ_{≥2}^k → ℝ_{≥0}`: `g ∈ O(f)` iff `∃ c > 0, N` such that - `g(x) ≤ c·f(x)` whenever **all** variables `x_i ≥ N`. (Howell's `O_∀`; - Guéneau et al.'s product filter.) -- **Domain axioms.** Every expression admitted to the growth domain is nonnegative - and weakly monotone (nondecreasing in each variable) on `vars ≥ 2`. Under these - axioms Howell's inconsistencies vanish and `f + g ≍ max(f, g)` up to a constant - factor, which licenses `add = antichain union + prune`. -- **Widening rules (always upward, i.e. toward a valid upper bound):** - - Subtraction: `a − b ⇝ a + b` (sound since `b ≥ 0`; also covers the - `sqrt((a−b)^2)` absolute-value idiom because `|a−b| ≤ a+b`). - - Constant division and all multiplicative constants: dropped on entry. - - Exponentials with **linear** exponents (`c^x`, `c^(r·x)`, `exp(x)`) are - first-class (see M1's `exp` field). Nonlinear exponents (`2^(n*k)`, - `2^sqrt(n)`, double exponentials), `factorial(·)`, and negative exponents: - `Growth::Unknown` (absorbing). -- **Forbidden moves (documented + tested):** never specialize a variable to a - constant inside an O-fact; never rescale coefficients of exponents - (`2^(2n) ∉ O(2^n)` — exp rates compare coefficientwise, exactly). -- **Search boundary:** growth-domain monotonicity does not license intermediate path - pruning. Repository overheads may contain subtraction and labels omit constructed - instance structure. M3 therefore uses growth order only on completed paths. - -## Modules - -Only one new file. Everything else is in-place replacement; net LOC is expected -near zero or negative (`canonical.rs`, 431 lines, is deleted). - -### M1 — `src/growth.rs` (the one new entity) - -```rust -/// One growth monomial, e.g. 2^(3k)·n^2·m·log(n) → -/// { exp: {k:3.0}, poly: {n:2.0, m:1.0}, logs: {n:1} }. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GrowthTerm { - exp: BTreeMap<&'static str, f64>, // variable → rate, base normalized to 2 - // (3^n → {n: log2(3)}); linear forms only - poly: BTreeMap<&'static str, f64>, // variable → degree (0.5 covers sqrt) - logs: BTreeMap<&'static str, u32>, // variable → log power -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub enum Growth { - /// Antichain of pairwise-incomparable dominant terms, sorted by a - /// deterministic total order (for stable output/serialization). - Terms(Vec), - /// Absorbing sentinel: exp/factorial/negative exponents, or cap overflow - /// that even widening cannot represent. Absorbs through all operations. - Unknown, -} -``` - -Operations (each prunes back to an antichain immediately): - -- `Growth::from_expr(&Expr) -> Growth` — single bottom-up pass, linear in tree size. - `Var → {poly:{v:1}}`; `Const → O(1)` (empty term); `Add → union + prune`; - `Mul → pairwise map-merge + prune`; `Pow(base, const k ≥ 0) →` compute base's - antichain, then pairwise products (never expands the underlying sums); - `Log(a) → log(dominant(a))` using `log(n^a·m^b) ≍ log n + log m`; - `Sqrt = Pow 0.5`; everything else → `Unknown`. -- `dominates(&GrowthTerm, &GrowthTerm) -> bool` — per variable, lexicographic on - (exp rate, poly degree, log power); dominated iff ≤ on every variable and < on at - least one. This decides e.g. `1.001^n ≻ n^100` correctly, which the sampling - heuristic gets wrong. - Purely symbolic; replaces `numerical_dominance_check`. -- Caps: antichain length cap (default 32). On overflow, **widen upward** to the - single term taking the componentwise max of all exponents (a valid upper bound), - never truncate by order. -- Axiom guards: `debug_assert!` nonnegativity/monotonicity preconditions at entry. - -Deps: read-only on `expr.rs`. Serde derive here is the whole of former F11. - -### M2 — `big_o.rs` pipeline replacement - -`big_o_normal_form(&Expr) -> Result` keeps its -signature: internally `Growth::from_expr` → render `Growth` back to a display `Expr` -(`Unknown` maps to the existing `Unsupported` error). CLI callers (`big_o_of`, -`overhead_to_json`, `format_path_text`) are untouched. `compose_path_overhead` -continues to produce the compact nested `Expr` (≤ ~2 KB in the worst observed case); -`from_expr` walks it in microseconds — **no caching, no registry changes**. -`canonical.rs` and the `asymptotic_normal_form` compatibility wrapper are deleted -along with their unit tests (internal API breakage is in-scope). - -`pred-sym` (the standalone symbolic CLI, used by the find-problem skills for -`big-o` and `eval`) follows suit: the `canon` subcommand is deleted (no live -consumers), and `compare` narrows its semantics to Big-O equivalence via the growth -domain. `big-o` keeps working on the skills' effective-complexity inputs -(`1.5^n * n^2`) thanks to the linear `exp` field; nonlinear-exponent inputs report -`Unknown` and the skills fall back to `pred-sym eval`. - -Alternatives considered: capped expansion (rejected: keeps the exponential algorithm -and reintroduces order-dependent truncation); per-edge growth caching in -`ReductionEntry` with per-path folding (rejected for now: YAGNI at current graph -size; revisit if profiling ever shows `from_expr` on composed paths as hot). - -### M3 — Multi-label elementary-path kernel (`src/rules/graph.rs`, in-place) - -Replace `dijkstra` with one generic multi-label elementary-path search -plus a minimal trait: - -```rust -pub trait PathLabel: Clone { - fn extend(&self, edge: &ReductionEdge) -> Option; - fn final_dominates(&self, other: &Self) -> bool; -} -``` - -- Exact mode uses DFS backtracking over elementary paths and streams completed labels into - the terminal front, so dead prefixes are released as each branch returns. Approximate - mode uses per-node bags to apply caller-provided hop, label, expanded-state, and timeout - limits and reports every limit that affected completeness. Every intermediate path - remains distinct: equal cost, size, or growth summaries do not prove identical - constructed problems. Dominance is terminal-only because repository overheads may be - non-monotone. -- Label domains: - - **F3a asymptotic:** label = `BTreeMap` mapping each size field of - the current node to its growth in the source's variables; `extend` substitutes - the edge's overhead expressions; terminal dominance is componentwise. Exponential - growth is comparable via the `exp` field (polynomial paths dominate exponential - ones); `Unknown` fields make a label dominated by any known label — undecidable - paths rank last, which is the honest ranking. - - **F3b instance (measured):** for a concrete instance, formulas are advisory — - **measured sizes are authoritative**. Overhead formulas are scaling upper bounds - over the declared size fields and can be arbitrarily loose on - structure-dependent constructions (see #107), so they must never arbitrate - between concrete candidates. Label = the actual `ProblemSize` measured on the - constructed intermediate problem (plus the reduction chain itself, reused for - solving/witness extraction by the winner); `extend` executes the edge's - `reduce_to()` and measures. The only instance-budget guard is the **measured - budget check after execution**. Evaluating an asymptotic expression at one point - is not a certified concrete bound, so overhead formulas do not prune measured - candidates. This also means the budget cannot prevent the construction itself - from exhausting memory. - - Measured search uses **no dominance pruning**. `ProblemSize` omits instance - structure, and equal-size intermediate instances can produce different sizes under - a later structure-dependent reduction. Even serialized-state equivalence is not - used to discard a route. It is therefore a separate simple-path enumeration, not a - label domain in the Pareto kernel. - - Note the measured label deliberately does **not** use branch-and-bound: a - reduction can *shrink* the measured size, so the cost is non-monotone and a - B&B bound could prune a partial route that would still finish smallest. - Exact mode does not truncate this enumeration, so its time and retained constructed - state can grow exponentially with the number of simple paths. Approximate mode uses - only its explicit reported limits. Neither mode bounds temporary memory used inside - `reduce_to()`. - This fixes the path-dependent-cost hole in the current Dijkstra *and* removes - the dependency on formula accuracy for concrete decisions. -- `find_cheapest_path*` become thin wrappers returning the front (instance mode - typically collapses to a single optimum after the numeric tie-break). -- `find_dominated_rules` / `compare_overhead` (`src/rules/analysis.rs`) are rewired - to the same `dominates` order, deleting their bespoke comparison heuristics — - one trusted comparison everywhere (former F5). -- `all_simple_paths`-based enumeration remains the explicit `--all` listing mechanism; - measured optimum-finding now performs its own execution-aware simple-path enumeration - because no sound state-level dominance relation is available. - -Alternatives considered: unrestricted walks (rejected because cycles make the state -space unbounded); a generic semiring algebraic-path framework (rejected: -over-engineering for two label domains); formula-evaluated instance labels (rejected -after review: overhead formulas are upper bounds over declared size fields and can be -arbitrarily loose on structure-dependent constructions, so a formula-ranked front may -not contain the true winner — measured sizes are the ground truth and affordable at -interactive scales; formulas remain available for asymptotic analysis but do not -decide concrete feasibility). - -### M4 — CLI/MCP surface (`problemreductions-cli/src/commands/graph.rs`, in-place) - -- Asymptotic `pred path S T`: print the Pareto front (typically 1–3 paths), each with - its Big-O per size field; paths whose composed growth is `Unknown` (nonlinear - exponents, factorial) are annotated explicitly instead of showing a fake bound. -- Instance mode (`--size …`): output shape unchanged (single best path). -- `path --all`: keep enumeration; Big-O per path now via M2 (fast); **`--json` mode - no longer builds the text rendering** (the unconditional `format_path_text` call - named in issue #1069). -- All path lists sorted by (hops, lexicographic names). JSON emits the structured - `Growth` serialization. (The paper export consumes raw overhead expressions, not - Big-O strings — verified unaffected.) - -## Quality requirements - -- **Reliability:** every public function terminates with an answer or `Unknown` — - no input can hang or OOM. Regression: issue #1069 path #34; a whole-graph test - enumerating paths (bounded length) between hot pairs asserts Big-O completion - within the CI budget (< 5 s per test). -- **Trustworthiness testing:** each `from_expr` transfer function and the dominance - order get randomized property tests (≥ 5000 checks, matching the repo's - verify-reduction culture): `eval(expr) ≤ C · eval(render(growth(expr)))` at large - sizes; `growth` idempotent on its own rendering; `dominates(a,b)` ⟹ sampled - `eval(b)/eval(a)` grows. Positive monotone overheads preserve `GrowthLabel` order, - but search correctness does not depend on intermediate isotonicity. -- **Determinism:** identical output across platforms; a test compares `pred path` - output against golden files (antichain and front ordering are total and - deterministic by construction). -- **Performance:** `pred path KSat QUBO --all` end-to-end < 1 s (currently OOM). -- **Extensibility:** the linear `exp` field ships in M1 (required by the - find-problem skills' use of `pred-sym big-o` on effective-complexity - expressions). The remaining upgrade path — nonlinear exponents (a polynomial - exponent instead of a linear form), needed only if F8-style effective-complexity - ranking over complexity strings like `2^(num_edges * k)` is ever built — touches - only `dominates`, `mul`, and `from_expr`'s `Pow/Exp` arms; antichain machinery, - caps, search kernel, and serialization are unaffected. - -## Out of scope - -- `#[reduction]` macro, overhead declaration syntax, and all rule files. -- `declare_variants!` complexity strings and their validation - (`is_valid_complexity_notation`) — untouched; they are display-only in this design. -- FuncKind registry, shared parser crate, effective-complexity ranking, hull pruning, - egg display layer (deferred/dropped as listed under Features). - -## References - -- E. Albert, D. Alonso, P. Arenas, S. Genaim, G. Puebla. *Asymptotic Resource Usage - Bounds.* APLAS 2009. (Normal form + `Θ`-preservation theorem.) -- R. Howell. *On Asymptotic Notation with Multiple Variables.* Kansas State - University TR 2007-4. (Multivariate O inconsistencies; `O_∀` definition.) -- A. Guéneau, A. Charguéraud, F. Pottier. *A Fistful of Dollars: Formalizing - Asymptotic Complexity Claims via Deductive Program Verification.* ESOP 2018. - (Filter-based O; nonnegative-monotone cost discipline; documented pitfalls.) -- M. Brockschmidt, F. Emmes, S. Falke, C. Fuhs, J. Giesl. *Analyzing Runtime and Size - Complexity of Integer Programs.* TOPLAS 2016. (Weakly monotone bounds compose.) -- SageMath `sage.rings.asymptotic` (growth groups, O-term absorption) — design - reference only (GPL). -- LLVM `ScalarEvolution` / GCC `tree-chrec` — budgets, sentinels, construction-time - canonicalization. -- D. Delling, T. Pajor, R. Werneck. *Round-Based Public Transit Routing.* ALENEX - 2012 (McRAPTOR bags); E. Martins. *On a Multicriteria Shortest Path Problem.* EJOR - 1984; L. Mandow, J.-L. Pérez de la Cruz. *Multiobjective A\* with Consistent - Heuristics.* JACM 2010. -- D. Gruntz. *On Computing Limits in a Symbolic Manipulation System.* ETH 1996 - (dominance ordering; relevant when the `exp` field is added). -- Issue #1069 — root-cause analysis this design responds to. diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 89907b2ef..71494d2b6 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -1,13 +1,5 @@ //! Multi-label elementary-path search over the reduction graph. //! -//! This module replaces the old scalar Dijkstra (`ReductionGraph::dijkstra`) with a -//! generic multi-label search. As described in -//! `docs/design/symbolic-growth-domain.md`, section M3/F3b, edge costs are -//! **path-dependent**: the cost of a reduction depends on the size of the problem -//! accumulated along the path so far. Scalar Dijkstra keeps only the cheapest-so-far -//! label per node, so a cheaper-but-larger intermediate state can poison downstream -//! choices — it can miss the path whose *final* target is smallest. -//! //! The search keeps multiple path states per node and filters the Pareto front only at //! the destination. Intermediate strict dominance is deliberately forbidden: arbitrary //! reduction overheads may shrink, subtract, or otherwise reverse an apparent order. From a9067297c9b7759b4f1139692553a465de49fed3 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 2 Aug 2026 08:04:27 +0800 Subject: [PATCH 26/31] Make path-cost regressions source-relative --- src/unit_tests/rules/pareto.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 4d6d300cd..dad812588 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -1351,24 +1351,24 @@ fn test_cost_label_path_dependent_cost_keeps_winner() { let graph = ReductionGraph::from_test_edges( &["S", "M", "P", "T"], &[ - // S -> M: cheap prefix (c = 1) but produces a LARGE intermediate size w = 100. + // S -> M: cheap prefix (c = 1) but expands the source size from 10 to 100. ( "S", "M", growth_edge(vec![ ("c", Expr::Const(1.0)), ("wf", Expr::Const(0.0)), - ("w", Expr::Const(100.0)), + ("w", Expr::Const(10.0) * Expr::Var("w")), ]), ), - // S -> P: pricier prefix (c = 3) but a SMALL size w = 1. + // S -> P: pricier prefix (c = 3) but shrinks the source size from 10 to 1. ( "S", "P", growth_edge(vec![ ("c", Expr::Const(3.0)), ("wf", Expr::Const(0.0)), - ("w", Expr::Const(1.0)), + ("w", Expr::Var("w") / Expr::Const(10.0)), ]), ), // P -> M: cheap (c = 1), keeps the small size w = 1. @@ -1378,7 +1378,7 @@ fn test_cost_label_path_dependent_cost_keeps_winner() { growth_edge(vec![ ("c", Expr::Const(1.0)), ("wf", Expr::Const(0.0)), - ("w", Expr::Const(1.0)), + ("w", Expr::Var("w")), ]), ), // M -> T: cost = current w (wf = 1, c = 0); identity on size. @@ -1408,7 +1408,7 @@ fn test_cost_label_path_dependent_cost_keeps_winner() { &empty, "T", &empty, - &ProblemSize::new(vec![("w", 0)]), + &ProblemSize::new(vec![("w", 10)]), &cost_fn, crate::rules::SearchMode::Exact, ) @@ -1438,8 +1438,8 @@ fn test_cost_label_nonmonotone_overhead_does_not_prune_intermediate_winner() { "S", "A", growth_edge(vec![ - ("n", Expr::Const(10.0)), - ("m", Expr::Const(2.0)), + ("n", Expr::Var("n")), + ("m", Expr::Var("m") - Expr::Const(3.0)), ("edge_cost", Expr::Const(0.0)), ]), ), @@ -1456,8 +1456,8 @@ fn test_cost_label_nonmonotone_overhead_does_not_prune_intermediate_winner() { "S", "B", growth_edge(vec![ - ("n", Expr::Const(10.0)), - ("m", Expr::Const(8.0)), + ("n", Expr::Var("n")), + ("m", Expr::Var("m") + Expr::Const(3.0)), ("edge_cost", Expr::Const(1.0)), ]), ), @@ -1501,7 +1501,7 @@ fn test_cost_label_nonmonotone_overhead_does_not_prune_intermediate_winner() { &empty, "T", &empty, - &ProblemSize::new(vec![]), + &ProblemSize::new(vec![("n", 10), ("m", 5)]), &cost_fn, crate::rules::SearchMode::Exact, ) From e5f5e78636e8e5b750a29e54eb889d6d2c4d01ef Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 2 Aug 2026 17:12:16 +0800 Subject: [PATCH 27/31] Fix reduction verification type resolution gate --- .claude/skills/verify-reduction/SKILL.md | 49 ++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/.claude/skills/verify-reduction/SKILL.md b/.claude/skills/verify-reduction/SKILL.md index 76f1246f6..ad101fc7e 100644 --- a/.claude/skills/verify-reduction/SKILL.md +++ b/.claude/skills/verify-reduction/SKILL.md @@ -1,6 +1,6 @@ --- name: verify-reduction -description: Standalone mathematical verification of a reduction rule — generates Typst proof, constructor Python script (>=5000 checks), and adversary Python script (>=5000 independent checks). Reports verdict. No artifacts saved. +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. --- # Verify Reduction @@ -36,22 +36,61 @@ pred show --json ### Type compatibility gate — MANDATORY -Check source/target `Value` types before any work: +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: + +```text +TYPE RESOLUTION: + Source syntax: Min + Substitutions: W = One; ::Sum = i32 + 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` (same type) +- `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 = i32`; `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 @@ -199,6 +238,10 @@ Every item must be YES. If any is NO, go back and fix. - [ ] 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 From 45a7fc61366e414bd8e32a0132dc8d603cd9055f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Wed, 5 Aug 2026 14:52:00 +0800 Subject: [PATCH 28/31] fix reduction path execution contracts Dispatch measured size computation by exact variants, derive path capabilities from real executors, and propagate explicit solution-extraction errors through reduction chains and callers. --- ...hained_reduction_factoring_to_spinglass.rs | 2 +- problemreductions-cli/src/commands/extract.rs | 2 +- problemreductions-cli/src/commands/solve.rs | 2 +- problemreductions-cli/src/dispatch.rs | 6 +- problemreductions-cli/src/mcp/tools.rs | 2 +- problemreductions-cli/src/test_support.rs | 6 +- problemreductions-macros/src/lib.rs | 36 +++- src/example_db/specs.rs | 2 +- src/models/decision.rs | 11 +- src/models/graph/minimum_dominating_set.rs | 4 +- src/rules/acyclicpartition_ilp.rs | 25 ++- .../balancedcompletebipartitesubgraph_ilp.rs | 7 +- src/rules/bicliquecover_bmf.rs | 7 +- src/rules/biconnectivityaugmentation_ilp.rs | 9 +- src/rules/binpacking_ilp.rs | 25 ++- src/rules/bmf_bicliquecover.rs | 7 +- src/rules/bmf_ilp.rs | 13 +- src/rules/bottlenecktravelingsalesman_ilp.rs | 49 +++-- .../boundedcomponentspanningforest_ilp.rs | 27 ++- src/rules/capacityassignment_ilp.rs | 23 ++- src/rules/circuit_ilp.rs | 15 +- src/rules/circuit_sat.rs | 19 +- src/rules/circuit_spinglass.rs | 25 ++- src/rules/closeststring_ilp.rs | 42 ++-- src/rules/closestsubstring_ilp.rs | 69 ++++--- src/rules/closestvectorproblem_qubo.rs | 41 ++-- src/rules/clustering_ilp.rs | 27 ++- src/rules/coloring_ilp.rs | 29 +-- src/rules/coloring_qubo.rs | 23 ++- src/rules/consecutiveblockminimization_ilp.rs | 11 +- .../consecutiveonesmatrixaugmentation_ilp.rs | 14 +- src/rules/consecutiveonessubmatrix_ilp.rs | 13 +- ...onsistencyofdatabasefrequencytables_ilp.rs | 38 ++-- ...imumdominatingset_minimumsummulticenter.rs | 7 +- ...nminimumdominatingset_minmaxmulticenter.rs | 7 +- ...onminimumvertexcover_hamiltoniancircuit.rs | 117 ++++++----- src/rules/directedhamiltonianpath_ilp.rs | 15 +- .../directedtwocommodityintegralflow_ilp.rs | 7 +- src/rules/disjointconnectingpaths_ilp.rs | 29 +-- src/rules/eulerianpath_ilp.rs | 104 +++++----- ...tcoverby3sets_algebraicequationsovergf2.rs | 7 +- ...overby3sets_boundeddiameterspanningtree.rs | 33 +-- src/rules/exactcoverby3sets_ilp.rs | 7 +- .../exactcoverby3sets_maximumsetpacking.rs | 7 +- .../exactcoverby3sets_minimumaxiomset.rs | 15 +- ...verby3sets_minimumfaultdetectiontestset.rs | 7 +- .../exactcoverby3sets_staffscheduling.rs | 15 +- src/rules/exactcoverby3sets_subsetproduct.rs | 7 +- src/rules/expectedretrievalcost_ilp.rs | 29 +-- src/rules/factoring_circuit.rs | 61 +++--- src/rules/factoring_ilp.rs | 35 ++-- src/rules/feasibleregisterassignment_ilp.rs | 7 +- src/rules/flowshopscheduling_ilp.rs | 35 ++-- src/rules/graph.rs | 177 ++++++++-------- src/rules/graph_helpers.rs | 51 ++++- src/rules/graphpartitioning_ilp.rs | 7 +- src/rules/graphpartitioning_maxcut.rs | 7 +- src/rules/graphpartitioning_qubo.rs | 7 +- ...oniancircuit_biconnectivityaugmentation.rs | 93 +++++---- ...niancircuit_bottlenecktravelingsalesman.rs | 5 +- .../hamiltoniancircuit_hamiltonianpath.rs | 67 +++--- .../hamiltoniancircuit_longestcircuit.rs | 5 +- .../hamiltoniancircuit_quadraticassignment.rs | 13 +- src/rules/hamiltoniancircuit_ruralpostman.rs | 90 +++++---- src/rules/hamiltoniancircuit_stackercrane.rs | 15 +- ...ncircuit_strongconnectivityaugmentation.rs | 66 +++--- .../hamiltoniancircuit_travelingsalesman.rs | 5 +- ...onianpath_degreeconstrainedspanningtree.rs | 34 +++- src/rules/hamiltonianpath_ilp.rs | 12 +- .../hamiltonianpath_isomorphicspanningtree.rs | 7 +- ...onianpathbetweentwovertices_longestpath.rs | 65 +++--- src/rules/highlyconnecteddeletion_ilp.rs | 53 +++-- src/rules/ilp_bool_ilp_i32.rs | 7 +- src/rules/ilp_i32_ilp_bool.rs | 31 +-- src/rules/ilp_qubo.rs | 7 +- src/rules/integerknapsack_ilp.rs | 7 +- src/rules/integralflowbundles_ilp.rs | 7 +- src/rules/integralflowhomologousarcs_ilp.rs | 7 +- src/rules/integralflowwithmultipliers_ilp.rs | 7 +- src/rules/isomorphicspanningtree_ilp.rs | 23 ++- ...lique_balancedcompletebipartitesubgraph.rs | 13 +- src/rules/kclique_conjunctivebooleanquery.rs | 10 +- src/rules/kclique_ilp.rs | 7 +- src/rules/kclique_subgraphisomorphism.rs | 9 +- src/rules/kcoloring_bicliquecover.rs | 81 ++++---- src/rules/kcoloring_casts.rs | 1 + src/rules/kcoloring_clustering.rs | 7 +- src/rules/kcoloring_partitionintocliques.rs | 7 +- ...kcoloring_twodimensionalconsecutivesets.rs | 47 +++-- src/rules/knapsack_ilp.rs | 7 +- src/rules/knapsack_qubo.rs | 7 +- src/rules/ksatisfiability_acyclicpartition.rs | 58 ++++-- src/rules/ksatisfiability_bicliquecover.rs | 44 ++-- src/rules/ksatisfiability_casts.rs | 2 + src/rules/ksatisfiability_cyclicordering.rs | 27 ++- ...tisfiability_decisionminimumvertexcover.rs | 5 +- ...bility_directedtwocommodityintegralflow.rs | 31 +-- ...tisfiability_feasibleregisterassignment.rs | 27 ++- src/rules/ksatisfiability_kclique.rs | 47 +++-- src/rules/ksatisfiability_kernel.rs | 13 +- .../ksatisfiability_minimumvertexcover.rs | 27 ++- .../ksatisfiability_monochromatictriangle.rs | 15 +- ...satisfiability_oneinthreesatisfiability.rs | 7 +- .../ksatisfiability_preemptivescheduling.rs | 17 +- .../ksatisfiability_quadraticcongruences.rs | 61 +++--- ...fiability_quadraticdiophantineequations.rs | 39 ++-- src/rules/ksatisfiability_qubo.rs | 14 +- .../ksatisfiability_registersufficiency.rs | 39 ++-- ...atisfiability_simultaneousincongruences.rs | 17 +- src/rules/ksatisfiability_subsetsum.rs | 33 +-- src/rules/ksatisfiability_timetabledesign.rs | 62 +++--- src/rules/lengthboundeddisjointpaths_ilp.rs | 63 +++--- src/rules/longestcircuit_ilp.rs | 7 +- src/rules/longestcommonsubsequence_ilp.rs | 27 ++- ...commonsubsequence_maximumindependentset.rs | 47 +++-- src/rules/longestpath_ilp.rs | 35 ++-- src/rules/maxcut_minimumcutintoboundedsets.rs | 7 +- src/rules/maxcut_minimummatrixcover.rs | 7 +- src/rules/maximalis_ilp.rs | 7 +- src/rules/maximum2satisfiability_ilp.rs | 7 +- src/rules/maximum2satisfiability_maxcut.rs | 15 +- src/rules/maximumclique_ilp.rs | 7 +- .../maximumclique_maximumindependentset.rs | 7 +- src/rules/maximumcokplex_ilp.rs | 7 +- src/rules/maximumcommonedgesubgraph_ilp.rs | 25 ++- src/rules/maximumcontactmapoverlap_ilp.rs | 27 ++- src/rules/maximumdomaticnumber_ilp.rs | 25 ++- src/rules/maximumedgeweightedkclique_ilp.rs | 7 +- src/rules/maximumindependentset_casts.rs | 8 + src/rules/maximumindependentset_gridgraph.rs | 7 +- ...ximumindependentset_integralflowbundles.rs | 27 ++- .../maximumindependentset_maximumclique.rs | 7 +- ...maximumindependentset_maximumsetpacking.rs | 14 +- src/rules/maximumindependentset_triangular.rs | 11 +- src/rules/maximumleafspanningtree_ilp.rs | 11 +- src/rules/maximumlikelihoodranking_ilp.rs | 43 ++-- src/rules/maximummatching_ilp.rs | 7 +- .../maximummatching_maximumsetpacking.rs | 7 +- src/rules/maximumsetpacking_casts.rs | 1 + src/rules/maximumsetpacking_ilp.rs | 7 +- src/rules/maximumsetpacking_qubo.rs | 7 +- .../minimumcapacitatedspanningtree_ilp.rs | 11 +- ...mcostmaximumflow_minimumcostcirculation.rs | 7 +- src/rules/minimumcoveringbycliques_ilp.rs | 31 +-- ...bycliques_minimumintersectiongraphbasis.rs | 33 +-- src/rules/minimumcutintoboundedsets_ilp.rs | 7 +- ...mumdiscreteplanarinversekinematics_qubo.rs | 27 ++- src/rules/minimumdominatingset_ilp.rs | 7 +- src/rules/minimumedgecostflow_ilp.rs | 7 +- ...minimumexternalmacrodatacompression_ilp.rs | 107 +++++----- src/rules/minimumfaultdetectiontestset_ilp.rs | 7 +- src/rules/minimumfeedbackarcset_ilp.rs | 7 +- ...feedbackarcset_maximumlikelihoodranking.rs | 17 +- src/rules/minimumfeedbackvertexset_ilp.rs | 7 +- ...minimumcodegenerationunlimitedregisters.rs | 53 ++--- src/rules/minimumgraphbandwidth_ilp.rs | 23 ++- src/rules/minimumhittingset_ilp.rs | 7 +- ...minimuminternalmacrodatacompression_ilp.rs | 101 ++++----- src/rules/minimummatrixcover_ilp.rs | 11 +- src/rules/minimummaximalmatching_ilp.rs | 7 +- ...maximalmatching_maximumachromaticnumber.rs | 15 +- ...maximalmatching_minimummatrixdomination.rs | 191 +++++++++--------- src/rules/minimummetricdimension_ilp.rs | 7 +- src/rules/minimummultiwaycut_ilp.rs | 11 +- src/rules/minimummultiwaycut_qubo.rs | 53 ++--- src/rules/minimumsetcovering_ilp.rs | 7 +- src/rules/minimumsummulticenter_ilp.rs | 7 +- src/rules/minimumtardinesssequencing_ilp.rs | 26 ++- ...nimumvertexcover_comparativecontainment.rs | 31 +-- .../minimumvertexcover_ensemblecomputation.rs | 51 +++-- ...mumvertexcover_longestcommonsubsequence.rs | 21 +- ...inimumvertexcover_maximumindependentset.rs | 14 +- ...inimumvertexcover_minimumfeedbackarcset.rs | 9 +- ...mumvertexcover_minimumfeedbackvertexset.rs | 7 +- .../minimumvertexcover_minimumhittingset.rs | 7 +- ...nimumvertexcover_minimummaximalmatching.rs | 4 +- .../minimumvertexcover_minimumsetcovering.rs | 7 +- ...imumvertexcover_minimumweightandorgraph.rs | 13 +- src/rules/minimumweightdecoding_ilp.rs | 7 +- src/rules/minmaxmulticenter_ilp.rs | 7 +- src/rules/mixedchinesepostman_ilp.rs | 11 +- src/rules/mod.rs | 5 +- src/rules/monochromatictriangle_ilp.rs | 7 +- src/rules/multiplecopyfileallocation_ilp.rs | 7 +- src/rules/multiprocessorscheduling_ilp.rs | 23 ++- src/rules/naesatisfiability_ilp.rs | 7 +- src/rules/naesatisfiability_maxcut.rs | 13 +- ...fiability_partitionintoperfectmatchings.rs | 17 +- src/rules/naesatisfiability_setsplitting.rs | 21 +- ...atching_numericalmatchingwithtargetsums.rs | 53 ++--- .../numericalmatchingwithtargetsums_ilp.rs | 19 +- src/rules/openshopscheduling_ilp.rs | 41 ++-- ...ement_consecutiveonesmatrixaugmentation.rs | 63 +++--- src/rules/optimallineararrangement_ilp.rs | 23 ++- ...uencingtominimizeweightedcompletiontime.rs | 32 +-- .../optimumcommunicationspanningtree_ilp.rs | 7 +- src/rules/paintshop_ilp.rs | 9 +- src/rules/paintshop_qubo.rs | 7 +- src/rules/pareto.rs | 67 +----- src/rules/partiallyorderedknapsack_ilp.rs | 7 +- src/rules/partition_binpacking.rs | 23 ++- .../partition_cosineproductintegration.rs | 7 +- .../partition_integralflowwithmultipliers.rs | 39 ++-- src/rules/partition_knapsack.rs | 7 +- .../partition_multiprocessorscheduling.rs | 7 +- src/rules/partition_openshopscheduling.rs | 121 +++++------ src/rules/partition_productionplanning.rs | 17 +- ...ion_sequencingtominimizetardytaskweight.rs | 29 +-- src/rules/partition_subsetsum.rs | 23 ++- src/rules/partition_sumofsquarespartition.rs | 17 +- ...ionintocliques_minimumcoveringbycliques.rs | 115 ++++++----- ...flength2_boundedcomponentspanningforest.rs | 7 +- src/rules/partitionintopathsoflength2_ilp.rs | 29 +-- src/rules/partitionintotriangles_ilp.rs | 29 +-- src/rules/pathconstrainednetworkflow_ilp.rs | 7 +- .../precedenceconstrainedscheduling_ilp.rs | 23 ++- src/rules/preemptivescheduling_ilp.rs | 11 +- ...rizecollectingsteinerforest_steinertree.rs | 69 ++++--- src/rules/quadraticassignment_ilp.rs | 23 ++- src/rules/qubo_ilp.rs | 7 +- .../rectilinearpicturecompression_ilp.rs | 7 +- src/rules/registersufficiency_ilp.rs | 7 +- src/rules/registry.rs | 63 ++---- .../resourceconstrainedscheduling_ilp.rs | 23 ++- ...arrangement_rootedtreestorageassignment.rs | 21 +- src/rules/rootedtreestorageassignment_ilp.rs | 25 ++- src/rules/ruralpostman_ilp.rs | 11 +- src/rules/sat_circuitsat.rs | 15 +- src/rules/sat_coloring.rs | 61 +++--- src/rules/sat_ksat.rs | 22 +- src/rules/sat_maximumindependentset.rs | 35 ++-- src/rules/sat_minimumdominatingset.rs | 78 +++---- ...tisfiability_integralflowhomologousarcs.rs | 31 +-- .../satisfiability_maximum2satisfiability.rs | 7 +- src/rules/satisfiability_naesatisfiability.rs | 21 +- src/rules/satisfiability_nontautology.rs | 7 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 21 +- .../schedulingwithindividualdeadlines_ilp.rs | 23 ++- ...cingtominimizemaximumcumulativecost_ilp.rs | 13 +- ...sequencingtominimizetardytaskweight_ilp.rs | 17 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 13 +- ...quencingtominimizeweightedtardiness_ilp.rs | 17 +- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 13 +- src/rules/sequencingwithinintervals_ilp.rs | 25 ++- ...uencingwithreleasetimesanddeadlines_ilp.rs | 37 ++-- src/rules/setsplitting_betweenness.rs | 41 ++-- src/rules/setsplitting_ilp.rs | 7 +- src/rules/shortestcommonsupersequence_ilp.rs | 27 ++- .../shortestweightconstrainedpath_ilp.rs | 35 ++-- src/rules/sparsematrixcompression_ilp.rs | 25 ++- src/rules/spinglass_maxcut.rs | 36 ++-- src/rules/spinglass_qubo.rs | 14 +- src/rules/stackercrane_ilp.rs | 11 +- src/rules/steinertree_ilp.rs | 7 +- src/rules/steinertreeingraphs_ilp.rs | 7 +- src/rules/stringtostringcorrection_ilp.rs | 75 +++---- .../strongconnectivityaugmentation_ilp.rs | 9 +- src/rules/subgraphisomorphism_ilp.rs | 23 ++- src/rules/subsetsum_closestvectorproblem.rs | 7 +- .../subsetsum_integerexpressionmembership.rs | 13 +- src/rules/subsetsum_integerknapsack.rs | 4 +- src/rules/subsetsum_partition.rs | 41 ++-- src/rules/sumofsquarespartition_ilp.rs | 29 +-- src/rules/test_helpers.rs | 40 ++-- src/rules/threedimensionalmatching_ilp.rs | 7 +- ...mensionalmatching_minimumweightdecoding.rs | 17 +- ...sionalmatching_threematroidintersection.rs | 7 +- ...threedimensionalmatching_threepartition.rs | 107 +++++----- ...partition_resourceconstrainedscheduling.rs | 7 +- ..._sequencingwithreleasetimesanddeadlines.rs | 49 +++-- src/rules/timetabledesign_ilp.rs | 7 +- src/rules/traits.rs | 43 +++- src/rules/travelingsalesman_ilp.rs | 51 ++--- src/rules/travelingsalesman_qubo.rs | 47 +++-- src/rules/undirectedflowlowerbounds_ilp.rs | 17 +- .../undirectedtwocommodityintegralflow_ilp.rs | 9 +- src/solvers/ilp/solver.rs | 5 +- src/solvers/registry.rs | 10 +- src/unit_tests/example_db.rs | 4 +- src/unit_tests/reduction_graph.rs | 38 +++- src/unit_tests/rules/acyclicpartition_ilp.rs | 4 +- .../balancedcompletebipartitesubgraph_ilp.rs | 2 +- src/unit_tests/rules/bicliquecover_bmf.rs | 4 +- .../rules/biconnectivityaugmentation_ilp.rs | 8 +- src/unit_tests/rules/binpacking_ilp.rs | 10 +- src/unit_tests/rules/bmf_bicliquecover.rs | 4 +- .../rules/bottlenecktravelingsalesman_ilp.rs | 6 +- .../boundedcomponentspanningforest_ilp.rs | 6 +- .../rules/capacityassignment_ilp.rs | 6 +- src/unit_tests/rules/circuit_ilp.rs | 2 +- src/unit_tests/rules/circuit_sat.rs | 2 +- src/unit_tests/rules/circuit_spinglass.rs | 6 +- src/unit_tests/rules/closeststring_ilp.rs | 21 +- src/unit_tests/rules/closestsubstring_ilp.rs | 21 +- .../rules/closestvectorproblem_qubo.rs | 10 +- src/unit_tests/rules/clustering_ilp.rs | 4 +- src/unit_tests/rules/coloring_ilp.rs | 16 +- src/unit_tests/rules/coloring_qubo.rs | 6 +- .../rules/consecutiveblockminimization_ilp.rs | 2 +- .../consecutiveonesmatrixaugmentation_ilp.rs | 4 +- .../rules/consecutiveonessubmatrix_ilp.rs | 6 +- ...onsistencyofdatabasefrequencytables_ilp.rs | 8 +- ...imumdominatingset_minimumsummulticenter.rs | 4 +- ...nminimumdominatingset_minmaxmulticenter.rs | 2 +- ...onminimumvertexcover_hamiltoniancircuit.rs | 6 +- .../rules/directedhamiltonianpath_ilp.rs | 4 +- .../directedtwocommodityintegralflow_ilp.rs | 4 +- src/unit_tests/rules/eulerianpath_ilp.rs | 6 +- ...tcoverby3sets_algebraicequationsovergf2.rs | 5 +- ...overby3sets_boundeddiameterspanningtree.rs | 4 +- src/unit_tests/rules/exactcoverby3sets_ilp.rs | 4 +- .../exactcoverby3sets_maximumsetpacking.rs | 4 +- .../exactcoverby3sets_minimumaxiomset.rs | 6 +- ...verby3sets_minimumfaultdetectiontestset.rs | 7 +- .../exactcoverby3sets_staffscheduling.rs | 8 +- .../rules/exactcoverby3sets_subsetproduct.rs | 5 +- .../rules/expectedretrievalcost_ilp.rs | 6 +- src/unit_tests/rules/factoring_circuit.rs | 2 +- src/unit_tests/rules/factoring_ilp.rs | 20 +- .../rules/feasibleregisterassignment_ilp.rs | 2 +- .../rules/flowshopscheduling_ilp.rs | 6 +- src/unit_tests/rules/graph.rs | 48 ++--- src/unit_tests/rules/graphpartitioning_ilp.rs | 4 +- .../rules/graphpartitioning_maxcut.rs | 2 +- ...oniancircuit_biconnectivityaugmentation.rs | 2 +- ...niancircuit_bottlenecktravelingsalesman.rs | 2 +- .../hamiltoniancircuit_hamiltonianpath.rs | 4 +- .../hamiltoniancircuit_longestcircuit.rs | 2 +- .../hamiltoniancircuit_quadraticassignment.rs | 6 +- .../rules/hamiltoniancircuit_ruralpostman.rs | 2 +- .../rules/hamiltoniancircuit_stackercrane.rs | 2 +- ...ncircuit_strongconnectivityaugmentation.rs | 2 +- .../hamiltoniancircuit_travelingsalesman.rs | 2 +- ...onianpath_degreeconstrainedspanningtree.rs | 2 +- src/unit_tests/rules/hamiltonianpath_ilp.rs | 6 +- .../hamiltonianpath_isomorphicspanningtree.rs | 2 +- .../rules/highlyconnecteddeletion_ilp.rs | 17 +- src/unit_tests/rules/ilp_bool_ilp_i32.rs | 2 +- src/unit_tests/rules/ilp_i32_ilp_bool.rs | 2 +- src/unit_tests/rules/ilp_qubo.rs | 18 +- src/unit_tests/rules/integerknapsack_ilp.rs | 4 +- .../rules/integralflowbundles_ilp.rs | 4 +- .../rules/integralflowhomologousarcs_ilp.rs | 2 +- .../rules/integralflowwithmultipliers_ilp.rs | 2 +- .../rules/isomorphicspanningtree_ilp.rs | 4 +- ...lique_balancedcompletebipartitesubgraph.rs | 6 +- .../rules/kclique_conjunctivebooleanquery.rs | 4 +- src/unit_tests/rules/kclique_ilp.rs | 4 +- .../rules/kclique_subgraphisomorphism.rs | 6 +- .../rules/kcoloring_bicliquecover.rs | 10 +- src/unit_tests/rules/kcoloring_clustering.rs | 7 +- .../rules/kcoloring_partitionintocliques.rs | 2 +- ...kcoloring_twodimensionalconsecutivesets.rs | 2 +- src/unit_tests/rules/knapsack_ilp.rs | 8 +- src/unit_tests/rules/knapsack_qubo.rs | 6 +- .../rules/ksatisfiability_acyclicpartition.rs | 2 +- .../rules/ksatisfiability_bicliquecover.rs | 22 +- .../rules/ksatisfiability_cyclicordering.rs | 9 +- ...tisfiability_decisionminimumvertexcover.rs | 2 +- ...bility_directedtwocommodityintegralflow.rs | 6 +- ...tisfiability_feasibleregisterassignment.rs | 6 +- .../rules/ksatisfiability_kclique.rs | 8 +- .../rules/ksatisfiability_kernel.rs | 2 +- .../ksatisfiability_minimumvertexcover.rs | 2 +- .../ksatisfiability_monochromatictriangle.rs | 6 +- ...satisfiability_oneinthreesatisfiability.rs | 2 +- .../ksatisfiability_preemptivescheduling.rs | 8 +- .../ksatisfiability_quadraticcongruences.rs | 6 +- ...fiability_quadraticdiophantineequations.rs | 4 +- src/unit_tests/rules/ksatisfiability_qubo.rs | 12 +- .../ksatisfiability_registersufficiency.rs | 7 +- ...atisfiability_simultaneousincongruences.rs | 4 +- .../rules/ksatisfiability_subsetsum.rs | 8 +- .../rules/ksatisfiability_timetabledesign.rs | 6 +- src/unit_tests/rules/longestcircuit_ilp.rs | 4 +- .../rules/longestcommonsubsequence_ilp.rs | 10 +- ...commonsubsequence_maximumindependentset.rs | 2 +- src/unit_tests/rules/longestpath_ilp.rs | 6 +- .../rules/maxcut_minimumcutintoboundedsets.rs | 2 +- .../rules/maxcut_minimummatrixcover.rs | 2 +- src/unit_tests/rules/maximalis_ilp.rs | 4 +- .../rules/maximum2satisfiability_ilp.rs | 6 +- .../rules/maximum2satisfiability_maxcut.rs | 14 +- src/unit_tests/rules/maximumclique_ilp.rs | 16 +- .../maximumclique_maximumindependentset.rs | 2 +- src/unit_tests/rules/maximumcokplex_ilp.rs | 4 +- .../rules/maximumcommonedgesubgraph_ilp.rs | 8 +- .../rules/maximumcontactmapoverlap_ilp.rs | 8 +- .../rules/maximumdomaticnumber_ilp.rs | 8 +- .../rules/maximumedgeweightedkclique_ilp.rs | 2 +- .../rules/maximumindependentset_gridgraph.rs | 2 +- .../rules/maximumindependentset_ilp.rs | 6 +- ...ximumindependentset_integralflowbundles.rs | 10 +- .../maximumindependentset_maximumclique.rs | 2 +- ...maximumindependentset_maximumsetpacking.rs | 4 +- .../rules/maximumindependentset_qubo.rs | 6 +- .../rules/maximumindependentset_triangular.rs | 2 +- .../rules/maximumleafspanningtree_ilp.rs | 14 +- .../rules/maximumlikelihoodranking_ilp.rs | 8 +- src/unit_tests/rules/maximummatching_ilp.rs | 14 +- .../maximummatching_maximumsetpacking.rs | 2 +- .../rules/maximumsetpacking_casts.rs | 4 +- src/unit_tests/rules/maximumsetpacking_ilp.rs | 8 +- .../rules/maximumsetpacking_qubo.rs | 6 +- .../minimumcapacitatedspanningtree_ilp.rs | 10 +- ...mcostmaximumflow_minimumcostcirculation.rs | 10 +- .../rules/minimumcoveringbycliques_ilp.rs | 7 +- ...bycliques_minimumintersectiongraphbasis.rs | 17 +- .../rules/minimumcutintoboundedsets_ilp.rs | 2 +- ...mumdiscreteplanarinversekinematics_qubo.rs | 9 +- .../rules/minimumdominatingset_ilp.rs | 16 +- .../rules/minimumedgecostflow_ilp.rs | 6 +- ...minimumexternalmacrodatacompression_ilp.rs | 10 +- .../rules/minimumfaultdetectiontestset_ilp.rs | 4 +- .../rules/minimumfeedbackarcset_ilp.rs | 6 +- ...feedbackarcset_maximumlikelihoodranking.rs | 2 +- .../rules/minimumfeedbackvertexset_ilp.rs | 14 +- .../rules/minimumgraphbandwidth_ilp.rs | 4 +- src/unit_tests/rules/minimumhittingset_ilp.rs | 4 +- ...minimuminternalmacrodatacompression_ilp.rs | 12 +- .../rules/minimummatrixcover_ilp.rs | 12 +- .../rules/minimummaximalmatching_ilp.rs | 6 +- ...maximalmatching_maximumachromaticnumber.rs | 8 +- ...maximalmatching_minimummatrixdomination.rs | 6 +- .../rules/minimummetricdimension_ilp.rs | 10 +- .../rules/minimummultiwaycut_ilp.rs | 8 +- .../rules/minimummultiwaycut_qubo.rs | 4 +- .../rules/minimumsetcovering_ilp.rs | 10 +- .../rules/minimumsummulticenter_ilp.rs | 8 +- .../rules/minimumtardinesssequencing_ilp.rs | 8 +- ...nimumvertexcover_comparativecontainment.rs | 6 +- .../minimumvertexcover_ensemblecomputation.rs | 6 +- .../rules/minimumvertexcover_ilp.rs | 6 +- ...inimumvertexcover_minimumfeedbackarcset.rs | 2 +- ...mumvertexcover_minimumfeedbackvertexset.rs | 2 +- .../minimumvertexcover_minimumhittingset.rs | 2 +- ...imumvertexcover_minimumweightandorgraph.rs | 5 +- .../rules/minimumvertexcover_qubo.rs | 6 +- .../rules/minimumweightdecoding_ilp.rs | 6 +- src/unit_tests/rules/minmaxmulticenter_ilp.rs | 8 +- .../rules/mixedchinesepostman_ilp.rs | 6 +- .../rules/monochromatictriangle_ilp.rs | 4 +- .../rules/multiplecopyfileallocation_ilp.rs | 6 +- .../rules/multiprocessorscheduling_ilp.rs | 6 +- src/unit_tests/rules/naesatisfiability_ilp.rs | 4 +- .../rules/naesatisfiability_maxcut.rs | 2 +- ...fiability_partitionintoperfectmatchings.rs | 4 +- .../rules/naesatisfiability_setsplitting.rs | 4 +- ...atching_numericalmatchingwithtargetsums.rs | 4 +- .../numericalmatchingwithtargetsums_ilp.rs | 6 +- .../rules/openshopscheduling_ilp.rs | 10 +- ...ement_consecutiveonesmatrixaugmentation.rs | 21 +- .../rules/optimallineararrangement_ilp.rs | 6 +- ...uencingtominimizeweightedcompletiontime.rs | 2 +- .../optimumcommunicationspanningtree_ilp.rs | 6 +- src/unit_tests/rules/paintshop_ilp.rs | 4 +- src/unit_tests/rules/paintshop_qubo.rs | 2 +- src/unit_tests/rules/pareto.rs | 50 ++--- .../rules/partiallyorderedknapsack_ilp.rs | 4 +- src/unit_tests/rules/partition_binpacking.rs | 2 +- .../partition_cosineproductintegration.rs | 2 +- .../partition_integralflowwithmultipliers.rs | 9 +- src/unit_tests/rules/partition_knapsack.rs | 2 +- .../partition_multiprocessorscheduling.rs | 2 +- .../rules/partition_openshopscheduling.rs | 4 +- .../rules/partition_productionplanning.rs | 2 +- ...ion_sequencingtominimizetardytaskweight.rs | 4 +- src/unit_tests/rules/partition_subsetsum.rs | 2 +- .../rules/partition_sumofsquarespartition.rs | 8 +- ...ionintocliques_minimumcoveringbycliques.rs | 17 +- ...flength2_boundedcomponentspanningforest.rs | 2 +- .../rules/partitionintopathsoflength2_ilp.rs | 6 +- .../rules/partitionintotriangles_ilp.rs | 6 +- .../rules/pathconstrainednetworkflow_ilp.rs | 2 +- .../precedenceconstrainedscheduling_ilp.rs | 4 +- .../rules/preemptivescheduling_ilp.rs | 6 +- ...rizecollectingsteinerforest_steinertree.rs | 2 +- .../rules/quadraticassignment_ilp.rs | 8 +- src/unit_tests/rules/qubo_ilp.rs | 6 +- .../rectilinearpicturecompression_ilp.rs | 4 +- src/unit_tests/rules/reduction_path_parity.rs | 4 +- .../rules/registersufficiency_ilp.rs | 4 +- src/unit_tests/rules/registry.rs | 76 +++---- .../resourceconstrainedscheduling_ilp.rs | 2 +- ...arrangement_rootedtreestorageassignment.rs | 2 +- .../rules/rootedtreestorageassignment_ilp.rs | 4 +- src/unit_tests/rules/ruralpostman_ilp.rs | 4 +- src/unit_tests/rules/sat_circuitsat.rs | 2 +- src/unit_tests/rules/sat_coloring.rs | 12 +- src/unit_tests/rules/sat_ksat.rs | 4 +- .../rules/sat_maximumindependentset.rs | 8 +- .../rules/sat_minimumdominatingset.rs | 22 +- ...tisfiability_integralflowhomologousarcs.rs | 2 +- .../satisfiability_maximum2satisfiability.rs | 2 +- .../rules/satisfiability_naesatisfiability.rs | 20 +- .../rules/satisfiability_nontautology.rs | 2 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 10 +- .../schedulingwithindividualdeadlines_ilp.rs | 4 +- ...cingtominimizemaximumcumulativecost_ilp.rs | 6 +- ...sequencingtominimizetardytaskweight_ilp.rs | 6 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 8 +- ...quencingtominimizeweightedtardiness_ilp.rs | 6 +- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 8 +- .../rules/sequencingwithinintervals_ilp.rs | 4 +- ...uencingwithreleasetimesanddeadlines_ilp.rs | 4 +- .../rules/setsplitting_betweenness.rs | 4 +- src/unit_tests/rules/setsplitting_ilp.rs | 4 +- .../rules/shortestcommonsupersequence_ilp.rs | 6 +- .../shortestweightconstrainedpath_ilp.rs | 6 +- .../rules/sparsematrixcompression_ilp.rs | 2 +- src/unit_tests/rules/spinglass_maxcut.rs | 6 +- src/unit_tests/rules/steinertree_ilp.rs | 4 +- .../rules/stringtostringcorrection_ilp.rs | 6 +- .../strongconnectivityaugmentation_ilp.rs | 6 +- .../rules/subgraphisomorphism_ilp.rs | 6 +- .../subsetsum_integerexpressionmembership.rs | 9 +- src/unit_tests/rules/subsetsum_partition.rs | 15 +- .../rules/sumofsquarespartition_ilp.rs | 6 +- .../rules/threedimensionalmatching_ilp.rs | 4 +- ...mensionalmatching_minimumweightdecoding.rs | 6 +- ...threedimensionalmatching_threepartition.rs | 6 +- ...partition_resourceconstrainedscheduling.rs | 2 +- ..._sequencingwithreleasetimesanddeadlines.rs | 2 +- src/unit_tests/rules/timetabledesign_ilp.rs | 4 +- src/unit_tests/rules/traits.rs | 9 +- src/unit_tests/rules/travelingsalesman_ilp.rs | 8 +- .../rules/travelingsalesman_qubo.rs | 4 +- .../rules/undirectedflowlowerbounds_ilp.rs | 4 +- .../undirectedtwocommodityintegralflow_ilp.rs | 4 +- src/unit_tests/solvers/registry.rs | 2 +- ...tisfiability_simultaneous_incongruences.rs | 2 +- tests/suites/reductions.rs | 68 ++++--- .../suites/register_assignment_reductions.rs | 4 +- 533 files changed, 4998 insertions(+), 3551 deletions(-) diff --git a/examples/chained_reduction_factoring_to_spinglass.rs b/examples/chained_reduction_factoring_to_spinglass.rs index 8a09823fe..dc78e76fa 100644 --- a/examples/chained_reduction_factoring_to_spinglass.rs +++ b/examples/chained_reduction_factoring_to_spinglass.rs @@ -47,7 +47,7 @@ pub fn run() { let solver = ILPSolver::new(); let reduction = ReduceTo::>::reduce_to(&factoring); let ilp_solution = solver.solve(reduction.target_problem()).unwrap(); - let solution = reduction.extract_solution(&ilp_solution); + let solution = reduction.extract_solution(&ilp_solution).unwrap(); // ANCHOR_END: step3 // ANCHOR: step4 diff --git a/problemreductions-cli/src/commands/extract.rs b/problemreductions-cli/src/commands/extract.rs index 18f12c377..09c55d9f0 100644 --- a/problemreductions-cli/src/commands/extract.rs +++ b/problemreductions-cli/src/commands/extract.rs @@ -60,7 +60,7 @@ pub fn extract(input: &Path, config_str: &str, out: &OutputConfig) -> Result<()> } let target_eval = replay.target.evaluate_dyn(&target_config); - let (source_config, source_eval) = replay.extract(&target_config); + let (source_config, source_eval) = replay.extract(&target_config)?; let text = format!( "Problem: {}\nSolver: external (via {})\nSolution: {:?}\nEvaluation: {}", diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index 3b6ba801e..b77dfa4af 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -137,7 +137,7 @@ fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputCon ) })?; - let (source_config, source_eval) = replay.extract(target_config); + let (source_config, source_eval) = replay.extract(target_config)?; let solver_desc = format!( "{} (via {})", diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index 5bd39ed75..fad94b40e 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -236,10 +236,10 @@ impl BundleReplay { } /// Map a target-space configuration back to the source space and evaluate it. - pub fn extract(&self, target_config: &[usize]) -> (Vec, String) { - let source_config = self.chain.extract_solution(target_config); + pub fn extract(&self, target_config: &[usize]) -> Result<(Vec, String)> { + let source_config = self.chain.extract_solution(target_config)?; let source_eval = self.source.evaluate_dyn(&source_config); - (source_config, source_eval) + Ok((source_config, source_eval)) } } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index be848d25e..7853e2613 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -1638,7 +1638,7 @@ fn solve_bundle_inner(bundle: ReductionBundle, request: SolverRequest) -> anyhow ) })?; - let (source_config, source_eval) = replay.extract(target_config); + let (source_config, source_eval) = replay.extract(target_config)?; let json = serde_json::json!({ "problem": replay.source_name, diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index 23f6f9d9f..f10613599 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -1,7 +1,7 @@ use crate::dispatch::{PathStep, ProblemJsonOutput, ReductionBundle}; use problemreductions::models::algebraic::{ObjectiveSense, ILP}; use problemreductions::registry::VariantEntry; -use problemreductions::rules::registry::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; +use problemreductions::rules::registry::{ReductionEntry, ReductionOverhead}; use problemreductions::rules::{AggregateReductionResult, ReductionAutoCast}; use problemreductions::solvers::{BruteForce, Solver}; use problemreductions::traits::Problem; @@ -180,7 +180,7 @@ problemreductions::inventory::submit! { }, )) }), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, overhead_eval_fn: |_| ProblemSize::new(vec![]), source_size_fn: |_| ProblemSize::new(vec![]), } @@ -203,7 +203,7 @@ problemreductions::inventory::submit! { target: ILP::new(0, vec![], vec![], ObjectiveSense::Minimize), }) }), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, overhead_eval_fn: |_| ProblemSize::new(vec![]), source_size_fn: |_| ProblemSize::new(vec![]), } diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index ac141e1bc..fc8be8213 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -25,6 +25,8 @@ use syn::{parse_macro_input, GenericArgument, ItemImpl, Path, PathArguments, Typ /// # Attributes /// /// - `overhead = { expr }` — overhead specification +/// - `aggregate = identity` — explicitly register an aggregate executor; compilation +/// requires the reduction result to prove source/target value-type equality /// /// ## New syntax (preferred): /// ```ignore @@ -60,11 +62,15 @@ enum OverheadSpec { /// Parsed attributes from #[reduction(...)] struct ReductionAttrs { overhead: Option, + identity_aggregate: bool, } impl syn::parse::Parse for ReductionAttrs { fn parse(input: syn::parse::ParseStream) -> syn::Result { - let mut attrs = ReductionAttrs { overhead: None }; + let mut attrs = ReductionAttrs { + overhead: None, + identity_aggregate: false, + }; while !input.is_empty() { let ident: syn::Ident = input.parse()?; @@ -76,6 +82,13 @@ impl syn::parse::Parse for ReductionAttrs { syn::braced!(content in input); attrs.overhead = Some(parse_overhead_content(&content)?); } + "aggregate" => { + let value: syn::Ident = input.parse()?; + if value != "identity" { + return Err(syn::Error::new(value.span(), "expected `identity`")); + } + attrs.identity_aggregate = true; + } _ => { return Err(syn::Error::new( ident.span(), @@ -330,10 +343,21 @@ 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 capabilities = if source_name == target_name { - quote! { crate::rules::EdgeCapabilities::both() } + let reduce_aggregate_fn = if attrs.identity_aggregate { + quote! { + Some(|src: &dyn std::any::Any| -> Box { + let src = src.downcast_ref::<#source_type>().unwrap_or_else(|| { + panic!( + "DynAggregateReductionResult: source type mismatch: expected `{}`, got `{}`", + std::any::type_name::<#source_type>(), + std::any::type_name_of_val(src), + ) + }); + Box::new(<#source_type as crate::rules::ReduceTo<#target_type>>::reduce_to(src)) + }) + } } else { - quote! { crate::rules::EdgeCapabilities::witness_only() } + quote! { None } }; // Collect generic parameter info from the impl block @@ -395,8 +419,8 @@ fn generate_reduction_entry( }); Box::new(<#source_type as crate::rules::ReduceTo<#target_type>>::reduce_to(src)) }), - reduce_aggregate_fn: None, - capabilities: #capabilities, + reduce_aggregate_fn: #reduce_aggregate_fn, + turing: false, overhead_eval_fn: #overhead_eval_fn, source_size_fn: #source_size_fn, } diff --git a/src/example_db/specs.rs b/src/example_db/specs.rs index d6facb3ca..a33b9c604 100644 --- a/src/example_db/specs.rs +++ b/src/example_db/specs.rs @@ -81,7 +81,7 @@ 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); + let source_config = reduction.extract_solution(&ilp_solution).unwrap(); assemble_rule_example( &source, reduction.target_problem(), diff --git a/src/models/decision.rs b/src/models/decision.rs index 7ef3d129d..9f8fddc9e 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -87,7 +87,7 @@ macro_rules! register_decision_variant { <$crate::models::decision::Decision<$inner> as $crate::rules::ReduceToAggregate<$inner>>::reduce_to_aggregate(source), ) }), - capabilities: $crate::rules::EdgeCapabilities::both(), + turing: false, overhead_eval_fn: |any| { let source = any .downcast_ref::<$crate::models::decision::Decision<$inner>>() @@ -119,7 +119,7 @@ macro_rules! register_decision_variant { module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: $crate::rules::EdgeCapabilities::turing(), + turing: true, overhead_eval_fn: |any| { let source = any .downcast_ref::<$inner>() @@ -279,8 +279,11 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index d1c7e63e8..66c23396d 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -302,7 +302,7 @@ inventory::submit! { >>::reduce_to_aggregate(source), ) }), - capabilities: crate::rules::EdgeCapabilities::both(), + turing: false, overhead_eval_fn: |any| { let source = any .downcast_ref::>>() @@ -336,7 +336,7 @@ inventory::submit! { module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: crate::rules::EdgeCapabilities::turing(), + turing: true, overhead_eval_fn: |any| { let source = any .downcast_ref::>() diff --git a/src/rules/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index 18a58090a..7ce8944ba 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -25,15 +25,20 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { } /// One-hot decode: for each vertex v, output the unique c with x_{v,c} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - (0..n) - .map(|v| { - (0..n) - .find(|&c| target_solution[v * n + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + (0..n) + .map(|v| { + (0..n) + .find(|&c| target_solution[v * n + c] == 1) + .unwrap_or(0) + }) + .collect() + }) } } @@ -178,7 +183,7 @@ 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 955fd772d..754a46b45 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionBCBSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/bicliquecover_bmf.rs b/src/rules/bicliquecover_bmf.rs index 93f9e93fe..887b084fe 100644 --- a/src/rules/bicliquecover_bmf.rs +++ b/src/rules/bicliquecover_bmf.rs @@ -36,8 +36,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - config_bmf_to_bc(target_solution, self.m, self.n, self.k) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(config_bmf_to_bc(target_solution, self.m, self.n, self.k)) } } diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index 4be442b96..c46aa4e98 100644 --- a/src/rules/biconnectivityaugmentation_ilp.rs +++ b/src/rules/biconnectivityaugmentation_ilp.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionBiconnAugToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_candidates].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_candidates].to_vec()) } } @@ -212,7 +215,7 @@ 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 49dc2f51e..4ce03e6a6 100644 --- a/src/rules/binpacking_ilp.rs +++ b/src/rules/binpacking_ilp.rs @@ -36,18 +36,23 @@ 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(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let mut assignment = vec![0usize; n]; - for i in 0..n { - for j in 0..n { - if target_solution[i * n + j] == 1 { - assignment[i] = j; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + let mut assignment = vec![0usize; n]; + for i in 0..n { + for j in 0..n { + if target_solution[i * n + j] == 1 { + assignment[i] = j; + break; + } } } - } - assignment + assignment + }) } } diff --git a/src/rules/bmf_bicliquecover.rs b/src/rules/bmf_bicliquecover.rs index bafa9b304..cafa92380 100644 --- a/src/rules/bmf_bicliquecover.rs +++ b/src/rules/bmf_bicliquecover.rs @@ -75,8 +75,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - config_bc_to_bmf(target_solution, self.m, self.n, self.k) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(config_bc_to_bmf(target_solution, self.m, self.n, self.k)) } } diff --git a/src/rules/bmf_ilp.rs b/src/rules/bmf_ilp.rs index 2764ef419..452772dae 100644 --- a/src/rules/bmf_ilp.rs +++ b/src/rules/bmf_ilp.rs @@ -25,10 +25,15 @@ impl ReductionResult for ReductionBMFToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Extract B (m x k) then C (k x n) — first m*k + k*n variables - let total = self.m * self.k + self.k * self.n; - target_solution[..total].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Extract B (m x k) then C (k x n) — first m*k + k*n variables + let total = self.m * self.k + self.k * self.n; + target_solution[..total].to_vec() + }) } } diff --git a/src/rules/bottlenecktravelingsalesman_ilp.rs b/src/rules/bottlenecktravelingsalesman_ilp.rs index a67dda8e2..a099b26f7 100644 --- a/src/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/rules/bottlenecktravelingsalesman_ilp.rs @@ -35,34 +35,39 @@ impl ReductionResult for ReductionBTSPToILP { } /// Extract: decode tour from x variables, then mark selected edges. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - - // Decode tour: for each position p, find vertex v with x_{v,p} = 1 - let mut tour = vec![0usize; n]; - for p in 0..n { - for v in 0..n { - if target_solution[v * n + p] == 1 { - tour[p] = v; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + + // Decode tour: for each position p, find vertex v with x_{v,p} = 1 + let mut tour = vec![0usize; n]; + for p in 0..n { + for v in 0..n { + if target_solution[v * n + p] == 1 { + tour[p] = v; + break; + } } } - } - // Map tour to edge selection - let mut edge_selection = vec![0usize; self.source_edges.len()]; - for p in 0..n { - let u = tour[p]; - let v = tour[(p + 1) % n]; - for (idx, &(a, b)) in self.source_edges.iter().enumerate() { - if (a == u && b == v) || (a == v && b == u) { - edge_selection[idx] = 1; - break; + // Map tour to edge selection + let mut edge_selection = vec![0usize; self.source_edges.len()]; + for p in 0..n { + let u = tour[p]; + let v = tour[(p + 1) % n]; + for (idx, &(a, b)) in self.source_edges.iter().enumerate() { + if (a == u && b == v) || (a == v && b == u) { + edge_selection[idx] = 1; + break; + } } } - } - edge_selection + edge_selection + }) } } diff --git a/src/rules/boundedcomponentspanningforest_ilp.rs b/src/rules/boundedcomponentspanningforest_ilp.rs index 7688ddff6..3722a430c 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -26,16 +26,21 @@ impl ReductionResult for ReductionBCSFToILP { } /// One-hot decode: for each vertex v, output the unique component c with x_{v,c} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let k = self.k; - (0..n) - .map(|v| { - (0..k) - .find(|&c| target_solution[v * k + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + let k = self.k; + (0..n) + .map(|v| { + (0..k) + .find(|&c| target_solution[v * k + c] == 1) + .unwrap_or(0) + }) + .collect() + }) } } @@ -203,7 +208,7 @@ 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 798646c85..bec8a0981 100644 --- a/src/rules/capacityassignment_ilp.rs +++ b/src/rules/capacityassignment_ilp.rs @@ -34,15 +34,20 @@ impl ReductionResult for ReductionCAToILP { } /// Extract solution: for each link l, find the unique capacity c where x_{l,c} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_capacities = self.num_capacities; - (0..self.num_links) - .map(|l| { - (0..num_capacities) - .find(|&c| target_solution[l * num_capacities + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_capacities = self.num_capacities; + (0..self.num_links) + .map(|l| { + (0..num_capacities) + .find(|&c| target_solution[l * num_capacities + c] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/circuit_ilp.rs b/src/rules/circuit_ilp.rs index fcd26f97a..76f410ad9 100644 --- a/src/rules/circuit_ilp.rs +++ b/src/rules/circuit_ilp.rs @@ -36,11 +36,16 @@ impl ReductionResult for ReductionCircuitToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_variables - .iter() - .map(|name| target_solution[self.variable_map[name]]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.source_variables + .iter() + .map(|name| target_solution[self.variable_map[name]]) + .collect() + }) } } diff --git a/src/rules/circuit_sat.rs b/src/rules/circuit_sat.rs index b0cbb6760..316d3cb67 100644 --- a/src/rules/circuit_sat.rs +++ b/src/rules/circuit_sat.rs @@ -293,12 +293,17 @@ impl ReductionResult for ReductionCircuitSATToSAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution - .iter() - .take(self.source_var_count) - .copied() - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + target_solution + .iter() + .take(self.source_var_count) + .copied() + .collect() + }) } } @@ -350,7 +355,7 @@ 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 da080d921..8ffcb6265 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -196,16 +196,21 @@ impl ReductionResult for ReductionCircuitToSG { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_variables - .iter() - .map(|var| { - self.variable_map - .get(var) - .and_then(|&idx| target_solution.get(idx).copied()) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.source_variables + .iter() + .map(|var| { + self.variable_map + .get(var) + .and_then(|&idx| target_solution.get(idx).copied()) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/closeststring_ilp.rs b/src/rules/closeststring_ilp.rs index 222c60186..79abbc4b0 100644 --- a/src/rules/closeststring_ilp.rs +++ b/src/rules/closeststring_ilp.rs @@ -50,19 +50,37 @@ impl ReductionResult for ReductionClosestStringToILP { /// Decode the integer ILP assignment into the source center config. /// /// For every position `j`, choose the unique alphabet symbol `a` with - /// `x_{j, a} = 1`. If the target assignment is missing or none of the - /// per-position `x_{j, *}` variables are set to 1, we fall back to symbol - /// `0` so the returned vector still has the expected length; partial / - /// infeasible ILP solutions are the caller's responsibility. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + /// `x_{j, a} = 1`. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + if target_solution.len() != self.target.num_vars { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} ILP values, got {}", + self.target.num_vars, + target_solution.len() + ))); + } + let q = self.alphabet_size; - (0..self.string_length) - .map(|j| { - (0..q) - .find(|&a| target_solution.get(j * q + a).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + 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) } } diff --git a/src/rules/closestsubstring_ilp.rs b/src/rules/closestsubstring_ilp.rs index dccc61963..77dff6561 100644 --- a/src/rules/closestsubstring_ilp.rs +++ b/src/rules/closestsubstring_ilp.rs @@ -70,41 +70,58 @@ impl ReductionResult for ReductionClosestSubstringToILP { /// first `ell` entries are the center symbols, the remaining `n` entries /// 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`. When no - /// indicator is set to 1 in some block (which only happens on partial / - /// infeasible ILP solutions), we fall back to 0 so the returned vector - /// still has the expected shape. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + /// `s_i`, we pick the unique window start `p` with `y_{i, p} = 1`. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + if target_solution.len() != self.target.num_vars { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} ILP values, got {}", + self.target.num_vars, + target_solution.len() + ))); + } + 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()); - // Center symbols. - for r in 0..ell { - let symbol = (0..q) - .find(|&a| target_solution.get(r * q + a).copied().unwrap_or(0) == 1) - .unwrap_or(0); - out.push(symbol); + for position in 0..ell { + let block = &target_solution[position * q..(position + 1) * q]; + out.push(decode_one_hot(block, "center position", position)?); } - - // Window starts. - for (i, &w_i) in self.window_counts.iter().enumerate() { - let start = (0..w_i) - .find(|&p| { - target_solution - .get(y_base + self.window_offsets[i] + p) - .copied() - .unwrap_or(0) - == 1 - }) - .unwrap_or(0); - out.push(start); + 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 + Ok(out) + } +} + +fn decode_one_hot( + block: &[usize], + 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( diff --git a/src/rules/closestvectorproblem_qubo.rs b/src/rules/closestvectorproblem_qubo.rs index bfc4b6c73..b2046d02e 100644 --- a/src/rules/closestvectorproblem_qubo.rs +++ b/src/rules/closestvectorproblem_qubo.rs @@ -31,24 +31,29 @@ impl ReductionResult for ReductionCVPToQUBO { } /// Reconstruct the source configuration offsets from the encoded QUBO bits. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.encodings - .iter() - .map(|encoding| { - encoding - .weights - .iter() - .enumerate() - .map(|(offset, weight)| { - target_solution - .get(encoding.start + offset) - .copied() - .unwrap_or(0) - * weight - }) - .sum() - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.encodings + .iter() + .map(|encoding| { + encoding + .weights + .iter() + .enumerate() + .map(|(offset, weight)| { + target_solution + .get(encoding.start + offset) + .copied() + .unwrap_or(0) + * weight + }) + .sum() + }) + .collect() + }) } } diff --git a/src/rules/clustering_ilp.rs b/src/rules/clustering_ilp.rs index 659eb0d38..00e80e4b4 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -32,17 +32,22 @@ impl ReductionResult for ReductionClusteringToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_elements) - .map(|element| { - (0..self.num_clusters) - .find(|&cluster| { - let idx = self.var_index(element, cluster); - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_elements) + .map(|element| { + (0..self.num_clusters) + .find(|&cluster| { + let idx = self.var_index(element, cluster); + idx < target_solution.len() && target_solution[idx] == 1 + }) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/coloring_ilp.rs b/src/rules/coloring_ilp.rs index 8fa5095c8..dd9d4b266 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -50,18 +50,23 @@ where /// /// The ILP solution has num_vertices * K binary variables. /// For each vertex, we find which color has value 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let k = self.num_colors; - (0..self.num_vertices) - .map(|v| { - (0..k) - .find(|&c| { - let var_idx = self.var_index(v, c); - var_idx < target_solution.len() && target_solution[var_idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let k = self.num_colors; + (0..self.num_vertices) + .map(|v| { + (0..k) + .find(|&c| { + let var_idx = self.var_index(v, c); + var_idx < target_solution.len() && target_solution[var_idx] == 1 + }) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/coloring_qubo.rs b/src/rules/coloring_qubo.rs index e85c498f8..fede8ccb1 100644 --- a/src/rules/coloring_qubo.rs +++ b/src/rules/coloring_qubo.rs @@ -33,15 +33,20 @@ impl ReductionResult for ReductionKColoringToQUBO { } /// Decode one-hot: for each vertex, find which color bit is 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let k = self.num_colors; - (0..self.num_vertices) - .map(|v| { - (0..k) - .find(|&c| target_solution[v * k + c] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let k = self.num_colors; + (0..self.num_vertices) + .map(|v| { + (0..k) + .find(|&c| target_solution[v * k + c] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/consecutiveblockminimization_ilp.rs b/src/rules/consecutiveblockminimization_ilp.rs index f63ff9770..519616040 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -24,9 +24,14 @@ impl ReductionResult for ReductionCBMToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Decode the column permutation from x_{c,p} - one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Decode the column permutation from x_{c,p} + 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 41a475898..aadf9abd0 100644 --- a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -25,8 +25,16 @@ impl ReductionResult for ReductionCOMAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(one_hot_decode( + target_solution, + self.num_cols, + self.num_cols, + 0, + )) } } @@ -187,7 +195,7 @@ 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 0703410b1..03bb93dcf 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -22,9 +22,14 @@ impl ReductionResult for ReductionCOSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Output the selection bits s_c (first num_cols variables) - target_solution[..self.num_cols].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Output the selection bits s_c (first num_cols variables) + target_solution[..self.num_cols].to_vec() + }) } } @@ -211,7 +216,7 @@ 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 a900f93de..712e0509a 100644 --- a/src/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -90,23 +90,29 @@ impl ReductionResult for ReductionCDFTToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - 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 value = (0..domain_size) - .find(|&candidate| { - target_solution - .get(self.assignment_var_index(object, attribute, candidate)) - .copied() - .unwrap_or(0) - == 1 - }) - .unwrap_or(0); - source_solution.push(value); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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 value = (0..domain_size) + .find(|&candidate| { + target_solution + .get(self.assignment_var_index(object, attribute, candidate)) + .copied() + .unwrap_or(0) + == 1 + }) + .unwrap_or(0); + source_solution.push(value); + } } - } - source_solution + source_solution + }) } } diff --git a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 807b03c34..104180d06 100644 --- a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMultic &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs index 26af00e85..38bfdb5ff 100644 --- a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticente &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 963e3f5e1..99a082038 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -14,7 +14,7 @@ use std::collections::BTreeSet; #[derive(Debug, Clone)] enum ConstructionKind { FixedYes { source_cover: Vec }, - FixedNo { num_source_vertices: usize }, + FixedNo, Theorem(TheoremConstruction), } @@ -186,43 +186,51 @@ impl TheoremConstruction { &self, target_problem: &HamiltonianCircuit, target_solution: &[usize], - ) -> Vec { - let mut source_cover = vec![0; self.num_source_vertices]; - if !target_problem.evaluate(target_solution).0 { - return source_cover; - } - - 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 vec![0; self.num_source_vertices]; + ) -> crate::rules::ExtractionResult> { + Ok({ + let mut source_cover = vec![0; self.num_source_vertices]; + if !target_problem.evaluate(target_solution).0 { + return Err(crate::rules::ExtractionError::invalid( + "target configuration is not a Hamiltonian circuit", + )); } - positions[vertex] = idx; - } - let len = target_solution.len(); - let touches_selector = |vertex: usize| { - let idx = positions[vertex]; - let prev = target_solution[(idx + len - 1) % len]; - let next = target_solution[(idx + 1) % len]; - prev < self.selector_count || next < self.selector_count - }; + 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; + } - for vertex in self.active_vertices() { - let Some((start, end)) = self.path_endpoints(vertex) else { - continue; + let len = target_solution.len(); + let touches_selector = |vertex: usize| { + let idx = positions[vertex]; + let prev = target_solution[(idx + len - 1) % len]; + let next = target_solution[(idx + 1) % len]; + prev < self.selector_count || next < self.selector_count }; - if touches_selector(start) && touches_selector(end) { - source_cover[vertex] = 1; + + for vertex in self.active_vertices() { + let Some((start, end)) = self.path_endpoints(vertex) else { + continue; + }; + if touches_selector(start) && touches_selector(end) { + source_cover[vertex] = 1; + } } - } - let selected_count = source_cover.iter().filter(|&&x| x == 1).count(); - if selected_count != self.selector_count || !self.covers_all_edges(&source_cover) { - return vec![0; self.num_source_vertices]; - } + let selected_count = source_cover.iter().filter(|&&x| x == 1).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 + source_cover + }) } } @@ -239,7 +247,7 @@ impl ReductionDecisionMinimumVertexCoverToHamiltonianCircuit { fn build_target_witness(&self, source_cover: &[usize]) -> Vec { match &self.construction { ConstructionKind::FixedYes { .. } => vec![0, 1, 2], - ConstructionKind::FixedNo { .. } => Vec::new(), + ConstructionKind::FixedNo => Vec::new(), ConstructionKind::Theorem(construction) => { construction.build_target_witness(source_cover) } @@ -255,22 +263,31 @@ impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - match &self.construction { - ConstructionKind::FixedYes { source_cover } => { - if self.target.evaluate(target_solution).0 { - source_cover.clone() - } else { - vec![0; source_cover.len()] + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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::Theorem(construction) => { + construction.extract_solution(&self.target, target_solution)? } } - ConstructionKind::FixedNo { - num_source_vertices, - } => vec![0; *num_source_vertices], - ConstructionKind::Theorem(construction) => { - construction.extract_solution(&self.target, target_solution) - } - } + }) } } @@ -309,9 +326,7 @@ impl ReduceTo> for Decision> for Decision Vec { - let n = self.num_vertices; - // Decode one-hot assignment: permutation[k] = v where x_{v,k} = 1 - let perm = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&perm) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + // Decode one-hot assignment: permutation[k] = v where x_{v,k} = 1 + let perm = one_hot_decode(target_solution, n, n, 0); + permutation_to_lehmer(&perm) + }) } } diff --git a/src/rules/directedtwocommodityintegralflow_ilp.rs b/src/rules/directedtwocommodityintegralflow_ilp.rs index 86f625769..013e3f684 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -37,8 +37,11 @@ impl ReductionResult for ReductionD2CIFToILP { } /// Extract flow solution: all 2*|A| variables directly encode the flow. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..2 * self.num_arcs].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..2 * self.num_arcs].to_vec()) } } diff --git a/src/rules/disjointconnectingpaths_ilp.rs b/src/rules/disjointconnectingpaths_ilp.rs index 1c4b7f5df..fb4fb415c 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -34,20 +34,25 @@ impl ReductionResult for ReductionDCPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Mark an edge selected iff some orientation carries flow for some commodity. - let m = self.edges.len(); - let mut result = vec![0usize; m]; - for k in 0..self.num_commodities { - for e in 0..m { - let fwd = target_solution[k * self.num_edge_vars_per_commodity + 2 * e]; - let rev = target_solution[k * self.num_edge_vars_per_commodity + 2 * e + 1]; - if fwd == 1 || rev == 1 { - result[e] = 1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Mark an edge selected iff some orientation carries flow for some commodity. + let m = self.edges.len(); + let mut result = vec![0usize; m]; + for k in 0..self.num_commodities { + for e in 0..m { + let fwd = target_solution[k * self.num_edge_vars_per_commodity + 2 * e]; + let rev = target_solution[k * self.num_edge_vars_per_commodity + 2 * e + 1]; + if fwd == 1 || rev == 1 { + result[e] = 1; + } } } - } - result + result + }) } } diff --git a/src/rules/eulerianpath_ilp.rs b/src/rules/eulerianpath_ilp.rs index 5701d0039..468bb6bdc 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -68,67 +68,61 @@ 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`. If the assignment is malformed (no start, - /// no successor mid-walk, or revisits an arc) we fall back to the identity - /// ordering `0..m` in release builds; debug builds trip a - /// `debug_assert!` to surface the caller bug. Callers must independently - /// check feasibility on the source side via - /// `EulerianPath::is_valid_solution`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let m = self.num_arcs; - if m == 0 { - return Vec::new(); - } - let fallback: Vec = (0..m).collect(); - - // Find the unique active start arc. - let mut current = match (0..m) - .find(|&a| target_solution.get(self.s_idx(a)).copied().unwrap_or(0) == 1) - { - Some(a) => a, - None => { - debug_assert!( - false, - "EulerianPath -> ILP extract_solution: malformed assignment, no active start arc (expected exactly one s_a = 1)", - ); - return fallback; + /// permutation of length `m`. Malformed assignments return an extraction + /// error instead of fabricating an ordering. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let m = self.num_arcs; + if m == 0 { + return Ok(Vec::new()); } - }; - // Walk the active successor relation, recording each visited arc. - let mut order = Vec::with_capacity(m); - let mut visited = vec![false; m]; - order.push(current); - visited[current] = true; + // Find the unique active start arc. + let mut current = match (0..m) + .find(|&a| target_solution.get(self.s_idx(a)).copied().unwrap_or(0) == 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 order = Vec::with_capacity(m); + let mut visited = vec![false; m]; + order.push(current); + visited[current] = true; - for _ in 1..m { - let next = self - .pairs - .iter() - .enumerate() - .find(|&(k, &(a, _))| { - a == current && target_solution.get(k).copied().unwrap_or(0) == 1 - }) - .map(|(_, &(_, b))| b); + for _ in 1..m { + let next = self + .pairs + .iter() + .enumerate() + .find(|&(k, &(a, _))| { + a == current && target_solution.get(k).copied().unwrap_or(0) == 1 + }) + .map(|(_, &(_, b))| b); - match next { - Some(b) if !visited[b] => { - order.push(b); - visited[b] = true; - current = b; - } - _ => { - debug_assert!( - false, - "EulerianPath -> ILP extract_solution: malformed assignment at arc {} (expected exactly one active successor y_{{{},b}} = 1 leading to an unvisited arc)", - current, - current, - ); - return fallback; + 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}", + ))); + } } } - } - order + order + }) } } diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs index c931682fc..a94de0a8b 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -18,8 +18,11 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index 27a9d654a..22aa8da46 100644 --- a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -58,20 +58,25 @@ 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(&self, target_solution: &[usize]) -> Vec { - let m = self.source_num_subsets; - let root_to_set_offset = 2; - (0..m) - .map(|i| { - usize::from( - target_solution - .get(root_to_set_offset + i) - .copied() - .unwrap_or(0) - == 1, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let m = self.source_num_subsets; + let root_to_set_offset = 2; + (0..m) + .map(|i| { + usize::from( + target_solution + .get(root_to_set_offset + i) + .copied() + .unwrap_or(0) + == 1, + ) + }) + .collect() + }) } } diff --git a/src/rules/exactcoverby3sets_ilp.rs b/src/rules/exactcoverby3sets_ilp.rs index e7a81a0d3..8a9f2e4c6 100644 --- a/src/rules/exactcoverby3sets_ilp.rs +++ b/src/rules/exactcoverby3sets_ilp.rs @@ -21,8 +21,11 @@ impl ReductionResult for ReductionX3CToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_maximumsetpacking.rs b/src/rules/exactcoverby3sets_maximumsetpacking.rs index 8718485c6..8155b236e 100644 --- a/src/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/rules/exactcoverby3sets_maximumsetpacking.rs @@ -29,8 +29,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_minimumaxiomset.rs b/src/rules/exactcoverby3sets_minimumaxiomset.rs index a09c2ebbd..d1035a9a7 100644 --- a/src/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/rules/exactcoverby3sets_minimumaxiomset.rs @@ -29,11 +29,16 @@ impl ReductionResult for ReductionXC3SToMinimumAxiomSet { /// 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(&self, target_solution: &[usize]) -> Vec { - let set_offset = self.source_universe_size; - (0..self.source_num_subsets) - .map(|j| usize::from(target_solution.get(set_offset + j).copied().unwrap_or(0) > 0)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let set_offset = self.source_universe_size; + (0..self.source_num_subsets) + .map(|j| usize::from(target_solution.get(set_offset + j).copied().unwrap_or(0) > 0)) + .collect() + }) } } diff --git a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index 427c9d01f..e16724e38 100644 --- a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_staffscheduling.rs b/src/rules/exactcoverby3sets_staffscheduling.rs index 68684bc5e..70585f0bd 100644 --- a/src/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/rules/exactcoverby3sets_staffscheduling.rs @@ -33,11 +33,16 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution - .iter() - .map(|&count| if count > 0 { 1 } else { 0 }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + target_solution + .iter() + .map(|&count| if count > 0 { 1 } else { 0 }) + .collect() + }) } } diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index 3b6aa896e..0662a9295 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -26,8 +26,11 @@ impl ReductionResult for ReductionX3CToSubsetProduct { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/expectedretrievalcost_ilp.rs b/src/rules/expectedretrievalcost_ilp.rs index 23b285509..5e6d88acf 100644 --- a/src/rules/expectedretrievalcost_ilp.rs +++ b/src/rules/expectedretrievalcost_ilp.rs @@ -65,18 +65,23 @@ impl ReductionResult for ReductionERCToILP { } /// Extract solution: for each record r, find the unique sector s where x_{r,s} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_sectors = self.num_sectors; - (0..self.num_records) - .map(|r| { - (0..num_sectors) - .find(|&s| { - let idx = r * num_sectors + s; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_sectors = self.num_sectors; + (0..self.num_records) + .map(|r| { + (0..num_sectors) + .find(|&s| { + let idx = r * num_sectors + s; + idx < target_solution.len() && target_solution[idx] == 1 + }) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/factoring_circuit.rs b/src/rules/factoring_circuit.rs index b000c7c8e..af5ad802e 100644 --- a/src/rules/factoring_circuit.rs +++ b/src/rules/factoring_circuit.rs @@ -42,34 +42,39 @@ impl ReductionResult for ReductionFactoringToCircuit { /// /// Returns a configuration where the first m bits are the first factor p, /// and the next n bits are the second factor q. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let var_names = self.target.variable_names(); - - // Build a map from variable name to its value - let var_map: std::collections::HashMap<&str, usize> = var_names - .iter() - .enumerate() - .map(|(i, name)| (name.as_str(), target_solution.get(i).copied().unwrap_or(0))) - .collect(); - - // Extract p bits - let p_bits: Vec = self - .p_vars - .iter() - .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) - .collect(); - - // Extract q bits - let q_bits: Vec = self - .q_vars - .iter() - .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) - .collect(); - - // Concatenate p and q bits - let mut result = p_bits; - result.extend(q_bits); - result + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let var_names = self.target.variable_names(); + + // Build a map from variable name to its value + let var_map: std::collections::HashMap<&str, usize> = var_names + .iter() + .enumerate() + .map(|(i, name)| (name.as_str(), target_solution.get(i).copied().unwrap_or(0))) + .collect(); + + // Extract p bits + let p_bits: Vec = self + .p_vars + .iter() + .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) + .collect(); + + // Extract q bits + let q_bits: Vec = self + .q_vars + .iter() + .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) + .collect(); + + // Concatenate p and q bits + let mut result = p_bits; + result.extend(q_bits); + result + }) } } diff --git a/src/rules/factoring_ilp.rs b/src/rules/factoring_ilp.rs index a3bffb0e9..51d3ea332 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -75,21 +75,26 @@ 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 concatenated bit vector [p_0, ..., p_{m-1}, q_0, ..., q_{n-1}]. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Extract p bits (first factor) - let p_bits: Vec = (0..self.m) - .map(|i| target_solution.get(self.p_var(i)).copied().unwrap_or(0)) - .collect(); - - // Extract q bits (second factor) - let q_bits: Vec = (0..self.n) - .map(|j| target_solution.get(self.q_var(j)).copied().unwrap_or(0)) - .collect(); - - // Concatenate p and q bits - let mut result = p_bits; - result.extend(q_bits); - result + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Extract p bits (first factor) + let p_bits: Vec = (0..self.m) + .map(|i| target_solution.get(self.p_var(i)).copied().unwrap_or(0)) + .collect(); + + // Extract q bits (second factor) + let q_bits: Vec = (0..self.n) + .map(|j| target_solution.get(self.q_var(j)).copied().unwrap_or(0)) + .collect(); + + // Concatenate p and q bits + let mut result = p_bits; + result.extend(q_bits); + result + }) } } diff --git a/src/rules/feasibleregisterassignment_ilp.rs b/src/rules/feasibleregisterassignment_ilp.rs index def32c86d..ad0028b63 100644 --- a/src/rules/feasibleregisterassignment_ilp.rs +++ b/src/rules/feasibleregisterassignment_ilp.rs @@ -29,8 +29,11 @@ impl ReductionResult for ReductionFeasibleRegisterAssignmentToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/flowshopscheduling_ilp.rs b/src/rules/flowshopscheduling_ilp.rs index 7f15251e2..14c0de42f 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -53,21 +53,26 @@ impl ReductionResult for ReductionFSSToILP { /// Extract solution: sort jobs by final-machine completion time C_{j,m-1}, /// then convert permutation to Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_jobs; - let m = self.num_machines; - let c_offset = self.num_order_vars; - let mut jobs: Vec = (0..n).collect(); - jobs.sort_by_key(|&j| { - let idx = c_offset + j * m + (m - 1); - (target_solution.get(idx).copied().unwrap_or(0), j) - }); - let perm = permutation_to_lehmer(&jobs); - Self::encode_schedule_as_lehmer(&jobs) - .into_iter() - .zip(perm) - .map(|(lehmer, _)| lehmer) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_jobs; + let m = self.num_machines; + let c_offset = self.num_order_vars; + let mut jobs: Vec = (0..n).collect(); + jobs.sort_by_key(|&j| { + let idx = c_offset + j * m + (m - 1); + (target_solution.get(idx).copied().unwrap_or(0), j) + }); + let perm = permutation_to_lehmer(&jobs); + Self::encode_schedule_as_lehmer(&jobs) + .into_iter() + .zip(perm) + .map(|(lehmer, _)| lehmer) + .collect() + }) } } diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 8840c51cb..9447dc460 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -1,9 +1,7 @@ //! Runtime reduction graph for discovering and executing reduction paths. //! //! The graph uses variant-level nodes: each node is a unique `(problem_name, variant)` pair. -//! Nodes are built in two phases: -//! 1. From `VariantEntry` inventory (with complexity metadata) -//! 2. From `ReductionEntry` inventory (fallback for backwards compatibility) +//! Nodes come from `VariantEntry` inventory, and `ReductionEntry` inventory supplies edges. //! //! Edges come exclusively from `#[reduction]` registrations via `inventory::iter::`. //! @@ -49,7 +47,13 @@ pub(crate) struct ReductionEdgeData { pub overhead: ReductionOverhead, pub reduce_fn: Option, pub reduce_aggregate_fn: Option, - pub capabilities: EdgeCapabilities, + pub turing: bool, +} + +impl ReductionEdgeData { + fn capabilities(&self) -> EdgeCapabilities { + EdgeCapabilities::from_executors(self.reduce_fn, self.reduce_aggregate_fn, self.turing) + } } /// JSON-serializable representation of the reduction graph. @@ -334,7 +338,6 @@ impl ExactParetoDfs<'_, '_, L> { let edge = ReductionEdge { overhead: &weight.overhead, reduce_fn: weight.reduce_fn, - capabilities: weight.capabilities, target_name: target_node.name, target_variant: &target_node.variant, }; @@ -429,26 +432,14 @@ impl ReductionGraph { let source_variant = Self::variant_to_map(&entry.source_variant()); let target_variant = Self::variant_to_map(&entry.target_variant()); - // Nodes should already exist from Phase 1. - // Fall back to creating them with empty complexity for backwards compatibility. - let src_idx = ensure_node( - entry.source_name, - source_variant, - "", - &mut nodes, - &mut graph, - &mut node_index, - &mut name_to_nodes, - ); - let dst_idx = ensure_node( - entry.target_name, - target_variant, - "", - &mut nodes, - &mut graph, - &mut node_index, - &mut name_to_nodes, - ); + let src_idx = node_index[&VariantRef { + name: entry.source_name.to_string(), + variant: source_variant, + }]; + let dst_idx = node_index[&VariantRef { + name: entry.target_name.to_string(), + variant: target_variant, + }]; let overhead = entry.overhead(); if graph.find_edge(src_idx, dst_idx).is_none() { @@ -459,7 +450,7 @@ impl ReductionGraph { overhead, reduce_fn: entry.reduce_fn, reduce_aggregate_fn: entry.reduce_aggregate_fn, - capabilities: entry.capabilities, + turing: entry.turing, }, ); } @@ -500,9 +491,9 @@ impl ReductionGraph { fn edge_supports_mode(edge: &ReductionEdgeData, mode: ReductionMode) -> bool { match mode { - ReductionMode::Witness => edge.capabilities.witness, - ReductionMode::Aggregate => edge.capabilities.aggregate, - ReductionMode::Turing => edge.capabilities.turing, + ReductionMode::Witness => edge.reduce_fn.is_some(), + ReductionMode::Aggregate => edge.reduce_aggregate_fn.is_some(), + ReductionMode::Turing => edge.turing, } } @@ -698,7 +689,6 @@ impl ReductionGraph { let redge = ReductionEdge { overhead: &weight.overhead, reduce_fn: weight.reduce_fn, - capabilities: weight.capabilities, target_name: target_node.name, target_variant: &target_node.variant, }; @@ -1013,7 +1003,6 @@ impl ReductionGraph { let edge = ReductionEdge { overhead: &weight.overhead, reduce_fn: weight.reduce_fn, - capabilities: weight.capabilities, target_name: target_node.name, target_variant: &target_node.variant, }; @@ -1406,7 +1395,7 @@ impl ReductionGraph { target_name: dst.name, target_variant: dst.variant.clone(), overhead: self.graph[e.id()].overhead.clone(), - capabilities: self.graph[e.id()].capabilities, + capabilities: self.graph[e.id()].capabilities(), } }) .collect() @@ -1462,28 +1451,36 @@ impl ReductionGraph { /// Compute the source problem's size from a type-erased instance. /// - /// Iterates over all registered reduction entries with a matching source name - /// and merges their `source_size_fn` results to capture all size fields. + /// Iterates over all registered reduction entries with an exact source name and + /// variant match, then merges their `source_size_fn` results to capture all size fields. /// Different entries may reference different getter methods (e.g., one uses /// `num_vertices` while another also uses `num_edges`). - pub fn compute_source_size(name: &str, instance: &dyn Any) -> ProblemSize { + pub fn compute_source_size( + name: &str, + variant: &BTreeMap, + instance: &dyn Any, + ) -> ProblemSize { let mut merged: Vec<(String, usize)> = Vec::new(); let mut seen: HashSet = HashSet::new(); for entry in inventory::iter:: { - if entry.source_name == name { - // A reduction's `source_size_fn` downcasts `instance` to its own - // source variant and panics on a mismatch; iterating every - // same-name entry means the non-matching variants panic-and-recover. - // Route through the silencer so these expected, caught panics do not - // spam stderr (the plain `catch_unwind` here did). - let result = - crate::rules::pareto::catch_reduction(|| (entry.source_size_fn)(instance)); - if let Some(size) = result { - for (k, v) in size.components { - if seen.insert(k.clone()) { - merged.push((k, v)); - } + if entry.source_name != name { + continue; + } + let entry_variant = entry.source_variant(); + let variant_matches = entry_variant.len() == variant.len() + && entry_variant.iter().all(|(key, value)| { + let value = if *key == "graph" && value.is_empty() { + "SimpleGraph" + } else { + value + }; + variant.get(*key).is_some_and(|expected| expected == value) + }); + if variant_matches { + for (k, v) in (entry.source_size_fn)(instance).components { + if seen.insert(k.clone()) { + merged.push((k, v)); } } } @@ -1509,7 +1506,7 @@ impl ReductionGraph { target_name: dst.name, target_variant: dst.variant.clone(), overhead: self.graph[e.id()].overhead.clone(), - capabilities: self.graph[e.id()].capabilities, + capabilities: self.graph[e.id()].capabilities(), } }) .collect() @@ -1721,7 +1718,7 @@ impl ReductionGraph { let src_node_id = self.graph[edge_ref.source()]; let dst_node_id = self.graph[edge_ref.target()]; let overhead = &edge_ref.weight().overhead; - let capabilities = edge_ref.weight().capabilities; + let capabilities = edge_ref.weight().capabilities(); let overhead_fields = overhead .output_size @@ -1909,13 +1906,15 @@ impl ReductionChain { } /// Extract a solution from target space back to source space. - pub fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.steps - .iter() - .rev() - .fold(target_solution.to_vec(), |sol, step| { - step.extract_solution_dyn(&sol) - }) + pub fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + let mut solution = target_solution.to_vec(); + for step in self.steps.iter().rev() { + solution = step.extract_solution_dyn(&solution)?; + } + Ok(solution) } } @@ -1952,20 +1951,6 @@ impl AggregateReductionChain { } } -struct WitnessBackedIdentityAggregateStep { - inner: Box, -} - -impl DynAggregateReductionResult for WitnessBackedIdentityAggregateStep { - fn target_problem_any(&self) -> &dyn Any { - self.inner.target_problem_any() - } - - fn extract_value_dyn(&self, target_value: serde_json::Value) -> serde_json::Value { - target_value - } -} - impl ReductionGraph { fn execute_aggregate_edge( &self, @@ -1977,18 +1962,7 @@ impl ReductionGraph { return None; } - if let Some(edge_fn) = edge.reduce_aggregate_fn { - return Some(edge_fn(input)); - } - - if edge.capabilities.witness && edge.capabilities.aggregate { - let edge_fn = edge.reduce_fn?; - return Some(Box::new(WitnessBackedIdentityAggregateStep { - inner: edge_fn(input), - })); - } - - None + Some(edge.reduce_aggregate_fn?(input)) } /// Execute a reduction path on a source problem instance. @@ -2093,13 +2067,15 @@ impl MeasuredPath { } /// Extract a solution from target space back to source space. - pub fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.steps - .iter() - .rev() - .fold(target_solution.to_vec(), |sol, step| { - step.extract_solution_dyn(&sol) - }) + pub fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + let mut solution = target_solution.to_vec(); + for step in self.steps.iter().rev() { + solution = step.extract_solution_dyn(&solution)?; + } + Ok(solution) } } @@ -2145,7 +2121,7 @@ impl ReductionGraph { if src == dst { return tracker.finish(None); } - let source_size = Self::compute_source_size(source, source_instance); + let source_size = Self::compute_source_size(source, source_variant, source_instance); let initial = MeasuredLabel::new(source_instance, source_size, budget); let targets = HashSet::from([dst]); let result = self @@ -2249,7 +2225,7 @@ impl ReductionGraph { return tracker.finish(None); } - let source_size = Self::compute_source_size(source, source_instance); + let source_size = Self::compute_source_size(source, source_variant, source_instance); let initial = MeasuredLabel::new(source_instance, source_size, budget); let result = self .measured_best_simple_path(src, &targets, mode, initial, &mut tracker) @@ -2269,17 +2245,30 @@ impl ReductionGraph { pub(crate) fn from_test_edges( node_names: &[&'static str], edges: &[(&'static str, &'static str, ReductionEdgeData)], + ) -> Self { + Self::from_test_variant_edges( + &node_names + .iter() + .map(|&name| (name, BTreeMap::new())) + .collect::>(), + edges, + ) + } + + pub(crate) fn from_test_variant_edges( + test_nodes: &[(&'static str, BTreeMap)], + edges: &[(&'static str, &'static str, ReductionEdgeData)], ) -> Self { let mut graph: DiGraph = DiGraph::new(); let mut nodes: Vec = Vec::new(); let mut name_to_nodes: HashMap<&'static str, Vec> = HashMap::new(); let mut index_of: HashMap<&'static str, NodeIndex> = HashMap::new(); - for &name in node_names { + for (name, variant) in test_nodes { let node_id = nodes.len(); nodes.push(VariantNode { name, - variant: BTreeMap::new(), + variant: variant.clone(), complexity: "", }); let idx = graph.add_node(node_id); diff --git a/src/rules/graph_helpers.rs b/src/rules/graph_helpers.rs index bdc02ae88..cf3594ab2 100644 --- a/src/rules/graph_helpers.rs +++ b/src/rules/graph_helpers.rs @@ -6,21 +6,33 @@ use crate::topology::{Graph, SimpleGraph}; /// /// 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 `vec![0; n]` if the selection does not form a valid Hamiltonian cycle. -pub(crate) fn edges_to_cycle_order(graph: &G, target_solution: &[usize]) -> Vec { +/// Returns an error if the selection does not form a valid Hamiltonian cycle. +pub(crate) fn edges_to_cycle_order( + graph: &G, + target_solution: &[usize], +) -> crate::rules::ExtractionResult> { let n = graph.num_vertices(); if n == 0 { - return vec![]; + return Ok(vec![]); } let edges = graph.edges(); if target_solution.len() != edges.len() { - return vec![0; n]; + 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 > 1 { + return Err(crate::rules::ExtractionError::invalid( + "edge-selection values must be binary", + )); + } if selected != 1 { continue; } @@ -31,14 +43,23 @@ pub(crate) fn edges_to_cycle_order(graph: &G, target_solution: &[usize } if selected_count != n || adjacency.iter().any(|neighbors| neighbors.len() != 2) { - return vec![0; n]; + 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; 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 { @@ -55,7 +76,13 @@ pub(crate) fn edges_to_cycle_order(graph: &G, target_solution: &[usize current = next; } - order + if current != 0 || visited.iter().any(|seen| !seen) { + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not form one Hamiltonian cycle", + )); + } + + Ok(order) } /// Build the complement graph edges: edges between all non-adjacent vertex pairs. @@ -71,3 +98,15 @@ 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, &[1; 6]).is_err()); + } +} diff --git a/src/rules/graphpartitioning_ilp.rs b/src/rules/graphpartitioning_ilp.rs index 94b280286..5f04aa3c7 100644 --- a/src/rules/graphpartitioning_ilp.rs +++ b/src/rules/graphpartitioning_ilp.rs @@ -30,8 +30,11 @@ impl ReductionResult for ReductionGraphPartitioningToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs index 0bf31b369..5ab10a2bc 100644 --- a/src/rules/graphpartitioning_maxcut.rs +++ b/src/rules/graphpartitioning_maxcut.rs @@ -22,8 +22,11 @@ impl ReductionResult for ReductionGPToMaxCut { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/graphpartitioning_qubo.rs b/src/rules/graphpartitioning_qubo.rs index 8e32846c9..ca592d8c9 100644 --- a/src/rules/graphpartitioning_qubo.rs +++ b/src/rules/graphpartitioning_qubo.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionGraphPartitioningToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index cc3e75aae..9b7b3bdc4 100644 --- a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -44,52 +44,65 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - if n < 3 { - return vec![0; n]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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() { - if i < target_solution.len() && target_solution[i] == 1 { - adj[u].push(v); - adj[v].push(u); + // 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() { + if i < target_solution.len() && target_solution[i] == 1 { + adj[u].push(v); + adj[v].push(u); + } } - } - // Check that every vertex has exactly degree 2 (Hamiltonian cycle) - if adj.iter().any(|neighbors| neighbors.len() != 2) { - return vec![0; n]; - } + // 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 { - circuit.push(current); - let next = if adj[current][0] == prev { - adj[current][1] - } else { - adj[current][0] - }; - prev = current; - current = next; - - // Safety: if we've visited more than n vertices, something is wrong - if circuit.len() > n { - return vec![0; n]; + // 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 { + circuit.push(current); + let next = if adj[current][0] == prev { + adj[current][1] + } else { + adj[current][0] + }; + prev = 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 { - vec![0; n] - } + if circuit.len() == n { + circuit + } else { + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not form a spanning circuit", + )); + } + }) } } diff --git a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index 061ac0e81..34c6f6e5a 100644 --- a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -23,7 +23,10 @@ impl ReductionResult for ReductionHamiltonianCircuitToBottleneckTravelingSalesma &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { 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 a3b20d080..1a7ad073d 100644 --- a/src/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -36,36 +36,51 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_original_vertices; - if n == 0 { - return vec![]; - } - - if target_solution.len() != n + 3 { - return vec![0; n]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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() + if target_solution.len() != n + 3 { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} path vertices, got {}", + n + 3, + target_solution.len() + ))); } - _ => return vec![0; n], - }; - if oriented.get(1) != Some(&0) || oriented.get(n + 1) != Some(&v_prime) { - return vec![0; n]; - } + 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", + )); + } - oriented[1..=n].to_vec() + oriented[1..=n].to_vec() + }) } } diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs index c6d9a0bba..3fc0d3d4a 100644 --- a/src/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -23,7 +23,10 @@ impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } } diff --git a/src/rules/hamiltoniancircuit_quadraticassignment.rs b/src/rules/hamiltoniancircuit_quadraticassignment.rs index f366b4770..d5c4a5571 100644 --- a/src/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -26,10 +26,15 @@ impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // QAP config is a permutation γ mapping positions to vertices, - // which is directly the Hamiltonian circuit visit order. - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // QAP config is a permutation γ mapping positions to vertices, + // which is directly the Hamiltonian circuit visit order. + target_solution.to_vec() + }) } } diff --git a/src/rules/hamiltoniancircuit_ruralpostman.rs b/src/rules/hamiltoniancircuit_ruralpostman.rs index 277b1aa18..f9b879091 100644 --- a/src/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -46,52 +46,58 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // The target solution is edge multiplicities. - // Required edges are indices 0..n (the {v_i^a, v_i^b} edges). - // Connectivity edges start at index n. - // For each source edge (v_i, v_j) at source index k: - // target edge n + 2*k is {v_i^b, v_j^a} - // target edge n + 2*k + 1 is {v_j^b, v_i^a} - // - // A connectivity edge {v_i^b, v_j^a} used with multiplicity 1 means - // the tour goes from vertex i to vertex j (j follows i in the HC). - - let n = self.n; - - // Build successor map from connectivity edges used exactly once - let mut successor = vec![usize::MAX; n]; - for (k, &(vi, vj)) in self.source_edges.iter().enumerate() { - let fwd_idx = n + 2 * k; // {v_i^b, v_j^a} - let bwd_idx = n + 2 * k + 1; // {v_j^b, v_i^a} - - let fwd_mult = target_solution.get(fwd_idx).copied().unwrap_or(0); - let bwd_mult = target_solution.get(bwd_idx).copied().unwrap_or(0); - - // In an optimal HC solution, each connectivity edge is used 0 or 1 times. - // Each vertex should have exactly one outgoing connectivity edge. - if fwd_mult > 0 && successor[vi] == usize::MAX { - successor[vi] = vj; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // The target solution is edge multiplicities. + // Required edges are indices 0..n (the {v_i^a, v_i^b} edges). + // Connectivity edges start at index n. + // For each source edge (v_i, v_j) at source index k: + // target edge n + 2*k is {v_i^b, v_j^a} + // target edge n + 2*k + 1 is {v_j^b, v_i^a} + // + // A connectivity edge {v_i^b, v_j^a} used with multiplicity 1 means + // the tour goes from vertex i to vertex j (j follows i in the HC). + + let n = self.n; + + // Build successor map from connectivity edges used exactly once + let mut successor = vec![usize::MAX; n]; + for (k, &(vi, vj)) in self.source_edges.iter().enumerate() { + let fwd_idx = n + 2 * k; // {v_i^b, v_j^a} + let bwd_idx = n + 2 * k + 1; // {v_j^b, v_i^a} + + let fwd_mult = target_solution.get(fwd_idx).copied().unwrap_or(0); + let bwd_mult = target_solution.get(bwd_idx).copied().unwrap_or(0); + + // In an optimal HC solution, each connectivity edge is used 0 or 1 times. + // Each vertex should have exactly one outgoing connectivity edge. + if fwd_mult > 0 && successor[vi] == usize::MAX { + successor[vi] = vj; + } + if bwd_mult > 0 && successor[vj] == usize::MAX { + successor[vj] = vi; + } } - if bwd_mult > 0 && successor[vj] == usize::MAX { - successor[vj] = vi; - } - } - // Walk the successor chain starting from vertex 0 - let mut cycle = Vec::with_capacity(n); - let mut current = 0; - for _ in 0..n { - cycle.push(current); - let next = successor[current]; - if next == usize::MAX { - // No valid successor found; return fallback - return vec![0; n]; + // Walk the successor chain starting from vertex 0 + let mut cycle = Vec::with_capacity(n); + let mut current = 0; + 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; } - current = next; - } - cycle + cycle + }) } } diff --git a/src/rules/hamiltoniancircuit_stackercrane.rs b/src/rules/hamiltoniancircuit_stackercrane.rs index 7b8d05345..86f5900d6 100644 --- a/src/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -32,11 +32,16 @@ impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // The target config is a permutation of arc indices. - // Arc i corresponds to original vertex i (arc from 2i to 2i+1). - // The permutation order directly gives the Hamiltonian circuit vertex order. - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // The target config is a permutation of arc indices. + // Arc i corresponds to original vertex i (arc from 2i to 2i+1). + // The permutation order directly gives the Hamiltonian circuit vertex order. + target_solution.to_vec() + }) } } diff --git a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index e90842ca6..e56791739 100644 --- a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -27,40 +27,48 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - if n == 0 { - return vec![]; - } - - // Build directed adjacency from selected arcs. - let candidate_arcs = self.target.candidate_arcs(); - let mut successors = vec![Vec::new(); n]; - for (idx, &selected) in target_solution.iter().enumerate() { - if selected == 1 { - let (u, v, _) = candidate_arcs[idx]; - successors[u].push(v); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + if n == 0 { + return Ok(vec![]); } - } - // 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] { - // Not a valid Hamiltonian cycle; return fallback. - return vec![0; n]; + // Build directed adjacency from selected arcs. + let candidate_arcs = self.target.candidate_arcs(); + let mut successors = vec![Vec::new(); n]; + for (idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let (u, v, _) = candidate_arcs[idx]; + successors[u].push(v); + } } - visited[current] = true; - order.push(current); - if successors[current].len() != 1 { - return vec![0; n]; + + // 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]; } - current = successors[current][0]; - } - order + order + }) } } diff --git a/src/rules/hamiltoniancircuit_travelingsalesman.rs b/src/rules/hamiltoniancircuit_travelingsalesman.rs index eebeb26af..19ba0211f 100644 --- a/src/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -23,7 +23,10 @@ impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { 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 e6fc6c548..1b46decf5 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -21,7 +21,10 @@ impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { extract_hamiltonian_order(self.target.graph(), target_solution) } } @@ -44,18 +47,25 @@ impl ReduceTo> for HamiltonianPath Vec { +fn extract_hamiltonian_order( + graph: &SimpleGraph, + target_solution: &[usize], +) -> crate::rules::ExtractionResult> { let num_vertices = graph.num_vertices(); if num_vertices == 0 { - return vec![]; + return Ok(vec![]); } if num_vertices == 1 { - return vec![0]; + return Ok(vec![0]); } let edges = graph.edges(); if target_solution.len() != edges.len() { - return vec![]; + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} edge-selection values, got {}", + edges.len(), + target_solution.len() + ))); } let mut adjacency = vec![Vec::new(); num_vertices]; @@ -74,7 +84,9 @@ fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[usize]) -> .collect(); endpoints.sort_unstable(); if endpoints.len() != 2 { - return vec![]; + return Err(crate::rules::ExtractionError::invalid( + "selected edges do not form a Hamiltonian path", + )); } let mut order = Vec::with_capacity(num_vertices); @@ -84,7 +96,9 @@ fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[usize]) -> loop { if visited[current] { - return vec![]; + return Err(crate::rules::ExtractionError::invalid( + "selected edges contain a cycle", + )); } visited[current] = true; order.push(current); @@ -103,9 +117,11 @@ fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[usize]) -> } if order.len() == num_vertices { - order + Ok(order) } else { - vec![] + Err(crate::rules::ExtractionError::invalid( + "selected edges do not span every source vertex", + )) } } diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index d60f1291a..c15336d73 100644 --- a/src/rules/hamiltonianpath_ilp.rs +++ b/src/rules/hamiltonianpath_ilp.rs @@ -35,8 +35,16 @@ impl ReductionResult for ReductionHamiltonianPathToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - one_hot_decode(target_solution, self.num_vertices, self.num_vertices, 0) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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 a5a96e829..5e4687483 100644 --- a/src/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -28,8 +28,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index bc177227e..51d67471f 100644 --- a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -33,41 +33,46 @@ 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(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - - // Build adjacency from selected edges - let mut adj: Vec> = vec![Vec::new(); n]; - for (idx, &selected) in target_solution.iter().enumerate() { - if selected == 1 { - let (u, v) = self.edges[idx]; - adj[u].push(v); - adj[v].push(u); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + + // Build adjacency from selected edges + let mut adj: Vec> = vec![Vec::new(); n]; + for (idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let (u, v) = self.edges[idx]; + adj[u].push(v); + adj[v].push(u); + } } - } - // Walk the path from source - let mut path = Vec::with_capacity(n); - let mut current = self.source_vertex; - let mut prev = usize::MAX; // sentinel for "no previous" - path.push(current); - - while path.len() < n { - let next = adj[current] - .iter() - .find(|&&neighbor| neighbor != prev) - .copied(); - match next { - Some(next_vertex) => { - prev = current; - current = next_vertex; - path.push(current); + // Walk the path from source + let mut path = Vec::with_capacity(n); + let mut current = self.source_vertex; + let mut prev = usize::MAX; // sentinel for "no previous" + path.push(current); + + while path.len() < n { + let next = adj[current] + .iter() + .find(|&&neighbor| neighbor != prev) + .copied(); + match next { + Some(next_vertex) => { + prev = current; + current = next_vertex; + path.push(current); + } + None => break, } - None => break, } - } - path + path + }) } } diff --git a/src/rules/highlyconnecteddeletion_ilp.rs b/src/rules/highlyconnecteddeletion_ilp.rs index 74493280f..eaff32c3e 100644 --- a/src/rules/highlyconnecteddeletion_ilp.rs +++ b/src/rules/highlyconnecteddeletion_ilp.rs @@ -60,36 +60,47 @@ 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(&self, target_solution: &[usize]) -> Vec { - // Map every vertex to the (unique, for a feasible ILP solution) chosen - // cluster id. For partial/infeasible target assignments we fall back to - // `None`, which forces the corresponding source edges to be marked - // deleted -- preserving feasibility of `is_valid_solution` is the - // caller's responsibility, not ours. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + if target_solution.len() != self.clusters.len() { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} cluster-selection values, got {}", + self.clusters.len(), + target_solution.len() + ))); + } + let mut cluster_of: Vec> = vec![None; vertex_count(&self.clusters)]; for (c, cluster) in self.clusters.iter().enumerate() { - if target_solution.get(c).copied().unwrap_or(0) == 1 { + 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" + ))); } } - self.edges + 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() - .map(|&(u, v)| { - debug_assert!( - cluster_of[u].is_some() && cluster_of[v].is_some(), - "extract_solution invariant violated: edge ({}, {}) has endpoint(s) with no cluster assignment; a well-formed ILP witness assigns every vertex to exactly one selected cluster", - u, - v - ); - match (cluster_of[u], cluster_of[v]) { - (Some(cu), Some(cv)) if cu == cv => 0, - _ => 1, - } - }) - .collect() + .map(|&(u, v)| usize::from(cluster_of[u] != cluster_of[v])) + .collect()) } } diff --git a/src/rules/ilp_bool_ilp_i32.rs b/src/rules/ilp_bool_ilp_i32.rs index 5e36032a8..7df8576c3 100644 --- a/src/rules/ilp_bool_ilp_i32.rs +++ b/src/rules/ilp_bool_ilp_i32.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionBinaryILPToIntILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/ilp_i32_ilp_bool.rs b/src/rules/ilp_i32_ilp_bool.rs index 98460be44..53d1cfbf9 100644 --- a/src/rules/ilp_i32_ilp_bool.rs +++ b/src/rules/ilp_i32_ilp_bool.rs @@ -247,19 +247,24 @@ impl ReductionResult for ReductionIntILPToBinaryILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.encodings - .iter() - .map(|enc| { - let val: i64 = enc - .weights - .iter() - .enumerate() - .map(|(j, &w)| w * target_solution[enc.start + j] as i64) - .sum(); - val as usize - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.encodings + .iter() + .map(|enc| { + let val: i64 = enc + .weights + .iter() + .enumerate() + .map(|(j, &w)| w * target_solution[enc.start + j] as i64) + .sum(); + val as usize + }) + .collect() + }) } } diff --git a/src/rules/ilp_qubo.rs b/src/rules/ilp_qubo.rs index 829bab31c..9e099a241 100644 --- a/src/rules/ilp_qubo.rs +++ b/src/rules/ilp_qubo.rs @@ -29,8 +29,11 @@ impl ReductionResult for ReductionILPToQUBO { } /// Extract only the original variables (discard slack). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_original_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_original_vars].to_vec()) } } diff --git a/src/rules/integerknapsack_ilp.rs b/src/rules/integerknapsack_ilp.rs index d5e7ef33d..6b8afb4a1 100644 --- a/src/rules/integerknapsack_ilp.rs +++ b/src/rules/integerknapsack_ilp.rs @@ -22,8 +22,11 @@ impl ReductionResult for ReductionIntegerKnapsackToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/integralflowbundles_ilp.rs b/src/rules/integralflowbundles_ilp.rs index ad423ec32..70d1823b5 100644 --- a/src/rules/integralflowbundles_ilp.rs +++ b/src/rules/integralflowbundles_ilp.rs @@ -23,8 +23,11 @@ impl ReductionResult for ReductionIFBToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 05c6cfc1e..8d810fb1a 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -22,8 +22,11 @@ impl ReductionResult for ReductionIFHAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/integralflowwithmultipliers_ilp.rs b/src/rules/integralflowwithmultipliers_ilp.rs index c53b35bc4..f52533bb4 100644 --- a/src/rules/integralflowwithmultipliers_ilp.rs +++ b/src/rules/integralflowwithmultipliers_ilp.rs @@ -22,8 +22,11 @@ impl ReductionResult for ReductionIFWMToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index dad7a8126..c28f3cfd9 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -24,15 +24,20 @@ impl ReductionResult for ReductionISTToILP { } /// For each tree vertex u, output the unique graph vertex v with x_{u,v} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - (0..n) - .map(|u| { - (0..n) - .find(|&v| target_solution[u * n + v] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + (0..n) + .map(|u| { + (0..n) + .find(|&v| target_solution[u * n + v] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/rules/kclique_balancedcompletebipartitesubgraph.rs index 0c84772a3..6817bf98e 100644 --- a/src/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -34,10 +34,15 @@ 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): /// source_config[v] = 1 - target_config[v]. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_original_vertices) - .map(|v| 1 - target_solution[v]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_original_vertices) + .map(|v| 1 - target_solution[v]) + .collect() + }) } } diff --git a/src/rules/kclique_conjunctivebooleanquery.rs b/src/rules/kclique_conjunctivebooleanquery.rs index 0dcd3c4ca..273ca0d00 100644 --- a/src/rules/kclique_conjunctivebooleanquery.rs +++ b/src/rules/kclique_conjunctivebooleanquery.rs @@ -34,8 +34,14 @@ 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(&self, target_solution: &[usize]) -> Vec { - KClique::::config_from_vertices(self.num_vertices, target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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 96db11c91..4e15084bf 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -39,8 +39,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/kclique_subgraphisomorphism.rs b/src/rules/kclique_subgraphisomorphism.rs index e351a2cfa..3e8c518f2 100644 --- a/src/rules/kclique_subgraphisomorphism.rs +++ b/src/rules/kclique_subgraphisomorphism.rs @@ -34,8 +34,13 @@ 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(&self, target_solution: &[usize]) -> Vec { - KClique::::config_from_vertices(self.num_source_vertices, target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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 2c28ced38..cdaeb6507 100644 --- a/src/rules/kcoloring_bicliquecover.rs +++ b/src/rules/kcoloring_bicliquecover.rs @@ -71,49 +71,54 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { /// If the witness is invalid (e.g. some diagonal edge is uncovered), /// the extracted entry for `v` falls back to color `0`. Validation /// downstream is the responsibility of `source.is_valid_solution`. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - let k = self.target.k(); - let left_size = 2 * n; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + let k = self.target.k(); + let left_size = 2 * n; - // For each source vertex v, find the first biclique r that contains - // both a_v (unified index v) and b_v (unified index left_size + v). - let mut diagonal_biclique = vec![None; n]; - for (v, slot) in diagonal_biclique.iter_mut().enumerate() { - let a_v = v; - let b_v = left_size + v; - for r in 0..k { - let a_idx = a_v * k + r; - let b_idx = b_v * k + r; - if target_solution.get(a_idx).copied().unwrap_or(0) == 1 - && target_solution.get(b_idx).copied().unwrap_or(0) == 1 - { - *slot = Some(r); - break; + // For each source vertex v, find the first biclique r that contains + // both a_v (unified index v) and b_v (unified index left_size + v). + let mut diagonal_biclique = vec![None; n]; + for (v, slot) in diagonal_biclique.iter_mut().enumerate() { + let a_v = v; + let b_v = left_size + v; + for r in 0..k { + let a_idx = a_v * k + r; + let b_idx = b_v * k + r; + if target_solution.get(a_idx).copied().unwrap_or(0) == 1 + && target_solution.get(b_idx).copied().unwrap_or(0) == 1 + { + *slot = Some(r); + break; + } } } - } - // Compact distinct biclique indices into colors 0..q-1 in first-seen order. - let mut color_of_biclique: std::collections::HashMap = - std::collections::HashMap::new(); - let mut coloring = vec![0usize; n]; - for (v, slot) in diagonal_biclique.iter().enumerate() { - if let Some(r) = *slot { - let next_color = color_of_biclique.len(); - let color = *color_of_biclique.entry(r).or_insert(next_color); - // Clamp into [0, q-1]: if the witness exceeds q distinct - // diagonal bicliques (which a valid cover never does) keep - // the entry in range so the downstream validator can - // simply reject it as an improper coloring. - coloring[v] = if self.num_colors == 0 { - 0 - } else { - color.min(self.num_colors - 1) - }; + // Compact distinct biclique indices into colors 0..q-1 in first-seen order. + let mut color_of_biclique: std::collections::HashMap = + std::collections::HashMap::new(); + let mut coloring = vec![0usize; n]; + for (v, slot) in diagonal_biclique.iter().enumerate() { + if let Some(r) = *slot { + let next_color = color_of_biclique.len(); + let color = *color_of_biclique.entry(r).or_insert(next_color); + // Clamp into [0, q-1]: if the witness exceeds q distinct + // diagonal bicliques (which a valid cover never does) keep + // the entry in range so the downstream validator can + // simply reject it as an improper coloring. + coloring[v] = if self.num_colors == 0 { + 0 + } else { + color.min(self.num_colors - 1) + }; + } } - } - coloring + coloring + }) } } diff --git a/src/rules/kcoloring_casts.rs b/src/rules/kcoloring_casts.rs index 800584dcf..a3e1f6789 100644 --- a/src/rules/kcoloring_casts.rs +++ b/src/rules/kcoloring_casts.rs @@ -9,5 +9,6 @@ impl_variant_reduction!( KColoring, => , fields: [num_vertices, num_edges], + 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 3ce0ef69e..79b77e7b2 100644 --- a/src/rules/kcoloring_clustering.rs +++ b/src/rules/kcoloring_clustering.rs @@ -28,8 +28,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vertices.min(target_solution.len())].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.source_num_vertices.min(target_solution.len())].to_vec()) } } diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs index 081a0d82c..3fc634caa 100644 --- a/src/rules/kcoloring_partitionintocliques.rs +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -25,8 +25,11 @@ impl ReductionResult for ReductionKColoringToPartitionIntoCliques { } /// Solution extraction is the identity: color classes become clique classes. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs index 18fd9575e..2a7208af6 100644 --- a/src/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -39,27 +39,32 @@ 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(&self, target_solution: &[usize]) -> Vec { - // The target solution is config[symbol] = group_index. - // Vertex symbols are indices 0..num_vertices. - // We need to remap the group indices to colors 0, 1, 2. - // The target may use any labels, so we compress the distinct - // group indices used by vertex symbols to 0..2. - - let vertex_groups = &target_solution[..self.num_vertices]; - - // Collect distinct group indices used by vertices and map to 0..k-1 - let mut used: Vec = vertex_groups.to_vec(); - used.sort(); - used.dedup(); - - let group_to_color: std::collections::HashMap = used - .into_iter() - .enumerate() - .map(|(color, group)| (group, color % 3)) - .collect(); - - vertex_groups.iter().map(|&g| group_to_color[&g]).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // The target solution is config[symbol] = group_index. + // Vertex symbols are indices 0..num_vertices. + // We need to remap the group indices to colors 0, 1, 2. + // The target may use any labels, so we compress the distinct + // group indices used by vertex symbols to 0..2. + + let vertex_groups = &target_solution[..self.num_vertices]; + + // Collect distinct group indices used by vertices and map to 0..k-1 + let mut used: Vec = vertex_groups.to_vec(); + used.sort(); + used.dedup(); + + let group_to_color: std::collections::HashMap = used + .into_iter() + .enumerate() + .map(|(color, group)| (group, color % 3)) + .collect(); + + vertex_groups.iter().map(|&g| group_to_color[&g]).collect() + }) } } diff --git a/src/rules/knapsack_ilp.rs b/src/rules/knapsack_ilp.rs index 6732870ac..ffa4c2473 100644 --- a/src/rules/knapsack_ilp.rs +++ b/src/rules/knapsack_ilp.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionKnapsackToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/knapsack_qubo.rs b/src/rules/knapsack_qubo.rs index fd8898ea2..fa4c4d973 100644 --- a/src/rules/knapsack_qubo.rs +++ b/src/rules/knapsack_qubo.rs @@ -30,8 +30,11 @@ impl ReductionResult for ReductionKnapsackToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_items].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_items].to_vec()) } } diff --git a/src/rules/ksatisfiability_acyclicpartition.rs b/src/rules/ksatisfiability_acyclicpartition.rs index 6e747aa60..c93c296fe 100644 --- a/src/rules/ksatisfiability_acyclicpartition.rs +++ b/src/rules/ksatisfiability_acyclicpartition.rs @@ -99,21 +99,30 @@ impl ReductionResult for ReductionPartitionToAcyclicPartition { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() != self.source_num_elements + 2 { - return vec![0; self.source_num_elements]; - } - - let source_label = target_solution[self.source_vertex]; - let sink_label = target_solution[self.sink_vertex]; - debug_assert_ne!( - source_label, sink_label, - "valid target witnesses must place source and sink in different blocks" - ); - - (0..self.source_num_elements) - .map(|item| usize::from(target_solution[item] == sink_label)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if target_solution.len() != self.source_num_elements + 2 { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} partition labels, got {}", + self.source_num_elements + 2, + target_solution.len() + ))); + } + + let source_label = target_solution[self.source_vertex]; + let sink_label = target_solution[self.sink_vertex]; + debug_assert_ne!( + source_label, sink_label, + "valid target witnesses must place source and sink in different blocks" + ); + + (0..self.source_num_elements) + .map(|item| usize::from(target_solution[item] == sink_label)) + .collect() + }) } } @@ -133,12 +142,19 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { self.partition_to_acyclic.target_problem() } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let partition_solution = self.partition_to_acyclic.extract_solution(target_solution); - let subset_solution = self - .subset_to_partition - .extract_solution(&partition_solution); - self.sat_to_subset.extract_solution(&subset_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let partition_solution = self + .partition_to_acyclic + .extract_solution(target_solution)?; + let subset_solution = self + .subset_to_partition + .extract_solution(&partition_solution)?; + self.sat_to_subset.extract_solution(&subset_solution)? + }) } } diff --git a/src/rules/ksatisfiability_bicliquecover.rs b/src/rules/ksatisfiability_bicliquecover.rs index 9e01fc831..806234010 100644 --- a/src/rules/ksatisfiability_bicliquecover.rs +++ b/src/rules/ksatisfiability_bicliquecover.rs @@ -98,12 +98,25 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { /// 4. Map normalized variables back to source variables by reading /// each original `t_i`. /// - /// If no qualifying `B_1` is found (e.g. the witness is invalid), - /// the extracted assignment defaults to all-false. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { let n = self.normalized_n; let left_size = self.target.left_size(); let k = self.target.k(); + let expected_len = (left_size + self.target.right_size()) * k; + if target_solution.len() != expected_len { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {expected_len} biclique-membership values, got {}", + target_solution.len() + ))); + } + if target_solution.iter().any(|&value| value > 1) { + return Err(crate::rules::ExtractionError::invalid( + "biclique-membership values must be binary", + )); + } // Unified-vertex helpers for the named gadget anchors. let s11_u = self.s1_left_offset; // s_{1,1}^u @@ -115,11 +128,9 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { // Find a biclique containing both s_11^u and s_11^v, but no // Y-matching vertex. By Lemma 17, free-edge bicliques touch the // Y matching; the important-edge biclique B_1 does not. - let mut b1_index: Option = None; + let mut b1_index = None; for r in 0..k { - let in_b1 = |vertex: usize| -> bool { - target_solution.get(vertex * k + r).copied().unwrap_or(0) == 1 - }; + let in_b1 = |vertex: usize| target_solution[vertex * k + r] == 1; if !in_b1(s11_u) || !in_b1(s11_v) { continue; } @@ -133,11 +144,14 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { } // Read off normalized assignment: t_i = (h_i^u in B_1) for i in 0..n. + let b1_index = b1_index.ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target configuration has no important-edge biclique B_1", + ) + })?; let mut normalized_assignment = vec![false; n]; - if let Some(r) = b1_index { - for (i, slot) in normalized_assignment.iter_mut().enumerate() { - *slot = target_solution.get(h_left(i) * k + r).copied().unwrap_or(0) == 1; - } + for (i, slot) in normalized_assignment.iter_mut().enumerate() { + *slot = target_solution[h_left(i) * k + b1_index] == 1; } // Map normalized t_i back to the source: source x_s = t_s @@ -146,13 +160,9 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { let mut source_assignment = vec![0usize; self.source_num_vars]; for (s, slot) in source_assignment.iter_mut().enumerate() { let t_idx = 2 * s; - *slot = if normalized_assignment.get(t_idx).copied().unwrap_or(false) { - 1 - } else { - 0 - }; + *slot = if normalized_assignment[t_idx] { 1 } else { 0 }; } - source_assignment + Ok(source_assignment) } } diff --git a/src/rules/ksatisfiability_casts.rs b/src/rules/ksatisfiability_casts.rs index e98a02a1f..02dda10fe 100644 --- a/src/rules/ksatisfiability_casts.rs +++ b/src/rules/ksatisfiability_casts.rs @@ -8,6 +8,7 @@ impl_variant_reduction!( KSatisfiability, => , fields: [num_vars, num_clauses], + aggregate: identity, |src| KSatisfiability::new_allow_less(src.num_vars(), src.clauses().to_vec()) ); @@ -15,5 +16,6 @@ impl_variant_reduction!( KSatisfiability, => , fields: [num_vars, num_clauses], + 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 d56c3b49d..e67b1f7e6 100644 --- a/src/rules/ksatisfiability_cyclicordering.rs +++ b/src/rules/ksatisfiability_cyclicordering.rs @@ -30,17 +30,22 @@ impl ReductionResult for Reduction3SATToCyclicOrdering { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|var_idx| { - let (alpha, beta, gamma) = variable_triple(var_idx); - usize::from(!is_cyclic_order( - target_solution[alpha], - target_solution[beta], - target_solution[gamma], - )) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.source_num_vars) + .map(|var_idx| { + let (alpha, beta, gamma) = variable_triple(var_idx); + usize::from(!is_cyclic_order( + target_solution[alpha], + target_solution[beta], + target_solution[gamma], + )) + }) + .collect() + }) } } diff --git a/src/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/rules/ksatisfiability_decisionminimumvertexcover.rs index 37d22483f..dd22cacce 100644 --- a/src/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -28,7 +28,10 @@ impl ReductionResult for Reduction3SATToDecisionMVC { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { self.base_reduction.extract_solution(target_solution) } } diff --git a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs index d0cde28e5..fbd7c60b4 100644 --- a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -171,19 +171,24 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.variable_paths - .iter() - .map(|paths| { - usize::from( - target_solution - .get(paths.lower_entry_arc) - .copied() - .unwrap_or(0) - > 0, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.variable_paths + .iter() + .map(|paths| { + usize::from( + target_solution + .get(paths.lower_entry_arc) + .copied() + .unwrap_or(0) + > 0, + ) + }) + .collect() + }) } } diff --git a/src/rules/ksatisfiability_feasibleregisterassignment.rs b/src/rules/ksatisfiability_feasibleregisterassignment.rs index 80f6d0ade..07bcd6f31 100644 --- a/src/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/rules/ksatisfiability_feasibleregisterassignment.rs @@ -69,15 +69,20 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_vars) - .map(|var| { - usize::from( - target_solution[s_pos_idx(var)] - < target_solution[s_neg_idx(self.num_vars, var)], - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_vars) + .map(|var| { + usize::from( + target_solution[s_pos_idx(var)] + < target_solution[s_neg_idx(self.num_vars, var)], + ) + }) + .collect() + }) } } @@ -180,8 +185,8 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - let n = self.source_num_vars; - // Start with all variables unset (false = 0). - let mut assignment = vec![0usize; n]; - // Track which variables have been explicitly set by a clique vertex. - let mut set = vec![false; n]; - - for (v, &val) in target_solution.iter().enumerate() { - if val != 1 { - continue; - } - // Vertex v corresponds to clause j, position p. - let j = v / 3; - let p = v % 3; - let lit = self.source_clauses[j][p]; - let var_idx = (lit.unsigned_abs() as usize) - 1; // 0-indexed - if !set[var_idx] { - assignment[var_idx] = if lit > 0 { 1 } else { 0 }; - set[var_idx] = true; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.source_num_vars; + // Start with all variables unset (false = 0). + let mut assignment = vec![0usize; n]; + // Track which variables have been explicitly set by a clique vertex. + let mut set = vec![false; n]; + + for (v, &val) in target_solution.iter().enumerate() { + if val != 1 { + continue; + } + // Vertex v corresponds to clause j, position p. + let j = v / 3; + let p = v % 3; + let lit = self.source_clauses[j][p]; + let var_idx = (lit.unsigned_abs() as usize) - 1; // 0-indexed + if !set[var_idx] { + assignment[var_idx] = if lit > 0 { 1 } else { 0 }; + set[var_idx] = true; + } } - } - assignment + assignment + }) } } diff --git a/src/rules/ksatisfiability_kernel.rs b/src/rules/ksatisfiability_kernel.rs index c09f9aca9..02b2568f5 100644 --- a/src/rules/ksatisfiability_kernel.rs +++ b/src/rules/ksatisfiability_kernel.rs @@ -25,10 +25,15 @@ impl ReductionResult for Reduction3SatToKernel { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|i| usize::from(target_solution.get(2 * i).copied().unwrap_or(0) == 1)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.source_num_vars) + .map(|i| usize::from(target_solution.get(2 * i).copied().unwrap_or(0) == 1)) + .collect() + }) } } diff --git a/src/rules/ksatisfiability_minimumvertexcover.rs b/src/rules/ksatisfiability_minimumvertexcover.rs index 9e881dd4e..c3d7faa62 100644 --- a/src/rules/ksatisfiability_minimumvertexcover.rs +++ b/src/rules/ksatisfiability_minimumvertexcover.rs @@ -40,17 +40,22 @@ impl ReductionResult for Reduction3SATToMVC { /// 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: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|i| { - // u_i is at index 2*i, not-u_i is at index 2*i+1 - if target_solution[2 * i] == 1 { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.source_num_vars) + .map(|i| { + // u_i is at index 2*i, not-u_i is at index 2*i+1 + if target_solution[2 * i] == 1 { + 1 + } else { + 0 + } + }) + .collect() + }) } } diff --git a/src/rules/ksatisfiability_monochromatictriangle.rs b/src/rules/ksatisfiability_monochromatictriangle.rs index d2a49e311..1c756d1ef 100644 --- a/src/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/rules/ksatisfiability_monochromatictriangle.rs @@ -47,7 +47,10 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { let direct: Vec = self .negation_edge_indices .iter() @@ -59,15 +62,17 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { ) .collect(); if self.source.evaluate(&direct).0 { - return direct; + return Ok(direct); } let complement: Vec = direct.iter().map(|&value| 1 - value).collect(); if self.source.evaluate(&complement).0 { - return complement; + return Ok(complement); } - direct + Err(crate::rules::ExtractionError::invalid( + "target coloring does not map to a satisfying source assignment", + )) } } @@ -154,7 +159,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.source_num_vars].to_vec()) } } diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index ec7ec9bc3..b4df7c385 100644 --- a/src/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/rules/ksatisfiability_preemptivescheduling.rs @@ -335,12 +335,17 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let d_max = self.target.d_max(); - self.positive_start_jobs - .iter() - .map(|&job| usize::from(task_slot(target_solution, job, d_max) == Some(0))) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let d_max = self.target.d_max(); + self.positive_start_jobs + .iter() + .map(|&job| usize::from(task_slot(target_solution, job, d_max) == Some(0))) + .collect() + }) } } diff --git a/src/rules/ksatisfiability_quadraticcongruences.rs b/src/rules/ksatisfiability_quadraticcongruences.rs index e24189349..d4c635559 100644 --- a/src/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/rules/ksatisfiability_quadraticcongruences.rs @@ -31,37 +31,46 @@ impl ReductionResult for Reduction3SATToQuadraticCongruences { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut source_assignment = vec![0; self.source_num_vars]; - let Some(x) = self.target.decode_witness(target_solution) else { - return source_assignment; - }; - if x > self.h { - return source_assignment; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let mut source_assignment = vec![0; self.source_num_vars]; + let Some(x) = self.target.decode_witness(target_solution) else { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode a quadratic-congruence witness", + )); + }; + if x > self.h { + return Err(crate::rules::ExtractionError::invalid( + "decoded quadratic-congruence witness exceeds the construction bound", + )); + } - let h_minus_x = &self.h - &x; - let h_plus_x = &self.h + &x; - let mut alpha = vec![0i8; self.prime_powers.len()]; + let h_minus_x = &self.h - &x; + let h_plus_x = &self.h + &x; + let mut alpha = vec![0i8; self.prime_powers.len()]; - for (j, prime_power) in self.prime_powers.iter().enumerate() { - if (&h_minus_x % prime_power).is_zero() { - alpha[j] = 1; - } else if (&h_plus_x % prime_power).is_zero() { - alpha[j] = -1; + for (j, prime_power) in self.prime_powers.iter().enumerate() { + if (&h_minus_x % prime_power).is_zero() { + alpha[j] = 1; + } else if (&h_plus_x % prime_power).is_zero() { + alpha[j] = -1; + } } - } - for (active_index, &source_index) in self.active_to_source.iter().enumerate() { - let alpha_index = 2 * self.standard_clause_count + active_index + 1; - source_assignment[source_index] = if alpha.get(alpha_index) == Some(&-1) { - 1 - } else { - 0 - }; - } + for (active_index, &source_index) in self.active_to_source.iter().enumerate() { + let alpha_index = 2 * self.standard_clause_count + active_index + 1; + source_assignment[source_index] = if alpha.get(alpha_index) == Some(&-1) { + 1 + } else { + 0 + }; + } - source_assignment + source_assignment + }) } } diff --git a/src/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/rules/ksatisfiability_quadraticdiophantineequations.rs index dc82fed95..bff64c52e 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -28,21 +28,30 @@ impl ReductionResult for Reduction3SATToQuadraticDiophantineEquations { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let Some(x) = self.target.decode_witness(target_solution) else { - return self.congruence_reduction.extract_solution(&[]); - }; - - let Some(congruence_config) = self - .congruence_reduction - .target_problem() - .encode_witness(&x) - else { - return self.congruence_reduction.extract_solution(&[]); - }; - - self.congruence_reduction - .extract_solution(&congruence_config) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let Some(x) = self.target.decode_witness(target_solution) else { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode a Diophantine witness", + )); + }; + + let Some(congruence_config) = self + .congruence_reduction + .target_problem() + .encode_witness(&x) + else { + return Err(crate::rules::ExtractionError::invalid( + "decoded Diophantine witness cannot be encoded for the source congruence", + )); + }; + + self.congruence_reduction + .extract_solution(&congruence_config)? + }) } } diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index a39f404c7..7233435a5 100644 --- a/src/rules/ksatisfiability_qubo.rs +++ b/src/rules/ksatisfiability_qubo.rs @@ -32,8 +32,11 @@ impl ReductionResult for ReductionKSatToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.source_num_vars].to_vec()) } } @@ -52,8 +55,11 @@ impl ReductionResult for Reduction3SATToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.source_num_vars].to_vec()) } } diff --git a/src/rules/ksatisfiability_registersufficiency.rs b/src/rules/ksatisfiability_registersufficiency.rs index 30caf6c25..d342553b1 100644 --- a/src/rules/ksatisfiability_registersufficiency.rs +++ b/src/rules/ksatisfiability_registersufficiency.rs @@ -199,23 +199,28 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if self.layout.num_vars == 0 { - return Vec::new(); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if self.layout.num_vars == 0 { + return Ok(Vec::new()); + } - let cutoff = target_solution[self.layout.w(self.layout.num_vars - 1)]; - (0..self.layout.num_vars) - .map(|var| { - let x_pos_before = target_solution[self.layout.x_pos(var)] < cutoff; - let x_neg_before = target_solution[self.layout.x_neg(var)] < cutoff; - debug_assert!( - !(x_pos_before && x_neg_before), - "Sethi extraction expects at most one of x_pos/x_neg before w[n]", - ); - usize::from(x_pos_before) - }) - .collect() + let cutoff = target_solution[self.layout.w(self.layout.num_vars - 1)]; + (0..self.layout.num_vars) + .map(|var| { + let x_pos_before = target_solution[self.layout.x_pos(var)] < cutoff; + let x_neg_before = target_solution[self.layout.x_neg(var)] < cutoff; + debug_assert!( + !(x_pos_before && x_neg_before), + "Sethi extraction expects at most one of x_pos/x_neg before w[n]", + ); + usize::from(x_pos_before) + }) + .collect() + }) } } @@ -377,7 +382,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - let x = target_solution.first().copied().unwrap_or(0) as u64; - self.variable_primes - .iter() - .map(|&prime| if x % prime == 1 { 1 } else { 0 }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let x = target_solution.first().copied().unwrap_or(0) as u64; + self.variable_primes + .iter() + .map(|&prime| if x % prime == 1 { 1 } else { 0 }) + .collect() + }) } } diff --git a/src/rules/ksatisfiability_subsetsum.rs b/src/rules/ksatisfiability_subsetsum.rs index 1f4efc32b..1f4d575c5 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -35,20 +35,25 @@ impl ReductionResult for Reduction3SATToSubsetSum { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // 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. - // If y_i is selected (target_solution[2*i] == 1), set x_i = 1; otherwise x_i = 0. - (0..self.source_num_vars) - .map(|i| { - let y_selected = target_solution[2 * i] == 1; - if y_selected { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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. + // If y_i is selected (target_solution[2*i] == 1), set x_i = 1; otherwise x_i = 0. + (0..self.source_num_vars) + .map(|i| { + let y_selected = target_solution[2 * i] == 1; + if y_selected { + 1 + } else { + 0 + } + }) + .collect() + }) } } diff --git a/src/rules/ksatisfiability_timetabledesign.rs b/src/rules/ksatisfiability_timetabledesign.rs index 08f9e4d0a..23517ff36 100644 --- a/src/rules/ksatisfiability_timetabledesign.rs +++ b/src/rules/ksatisfiability_timetabledesign.rs @@ -745,39 +745,45 @@ impl ReductionResult for Reduction3SATToTimetableDesign { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_tasks = self.target.num_tasks(); - let num_periods = self.target.num_periods(); - - let mut transformed_assignment = vec![0usize; self.layout.transformed_to_original.len()]; - for (index, encoding) in self.layout.variable_encodings.iter().enumerate() { - let vb_pair = match &encoding.vb { - EdgeEncoding::Direct { edge, .. } => self.layout.edge_pairs[*edge], - EdgeEncoding::TwoList { left_outer, .. } => self.layout.edge_pairs[*left_outer], - }; - let vb_color = core_edge_color(target_solution, vb_pair, num_tasks, num_periods); - transformed_assignment[index] = usize::from(vb_color == encoding.neg2); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_tasks = self.target.num_tasks(); + let num_periods = self.target.num_periods(); + + let mut transformed_assignment = + vec![0usize; self.layout.transformed_to_original.len()]; + for (index, encoding) in self.layout.variable_encodings.iter().enumerate() { + let vb_pair = match &encoding.vb { + EdgeEncoding::Direct { edge, .. } => self.layout.edge_pairs[*edge], + EdgeEncoding::TwoList { left_outer, .. } => self.layout.edge_pairs[*left_outer], + }; + let vb_color = core_edge_color(target_solution, vb_pair, num_tasks, num_periods); + transformed_assignment[index] = usize::from(vb_color == encoding.neg2); + } - let mut source_assignment = vec![0usize; self.layout.source_num_vars]; - for (var, fixed) in self.layout.pure_assignments.iter().copied().enumerate() { - if let Some(value) = fixed { - source_assignment[var] = value; + let mut source_assignment = vec![0usize; self.layout.source_num_vars]; + for (var, fixed) in self.layout.pure_assignments.iter().copied().enumerate() { + if let Some(value) = fixed { + source_assignment[var] = value; + } } - } - let mut seen_transformed = vec![false; self.layout.source_num_vars]; - for (value, &original_var) in transformed_assignment - .iter() - .zip(self.layout.transformed_to_original.iter()) - { - if !seen_transformed[original_var] { - source_assignment[original_var] = *value; - seen_transformed[original_var] = true; + let mut seen_transformed = vec![false; self.layout.source_num_vars]; + for (value, &original_var) in transformed_assignment + .iter() + .zip(self.layout.transformed_to_original.iter()) + { + if !seen_transformed[original_var] { + source_assignment[original_var] = *value; + seen_transformed[original_var] = true; + } } - } - source_assignment + source_assignment + }) } } diff --git a/src/rules/lengthboundeddisjointpaths_ilp.rs b/src/rules/lengthboundeddisjointpaths_ilp.rs index 08eaadc37..37cacb4e5 100644 --- a/src/rules/lengthboundeddisjointpaths_ilp.rs +++ b/src/rules/lengthboundeddisjointpaths_ilp.rs @@ -32,38 +32,43 @@ impl ReductionResult for ReductionLBDPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // For each path slot k, set the source vertex-indicator block to 1 - // exactly on the vertices incident to the commodity-k path, including s and t. - let m = self.edges.len(); - let n = self.num_vertices; - let j = self.num_paths; - let flow_vars_per_k = 2 * m; - - let mut result = vec![0usize; j * n]; - for k in 0..j { - // Find which vertices are on the path for commodity k - let mut on_path = vec![false; n]; - for e in 0..m { - let (u, v) = self.edges[e]; - let fwd = target_solution[k * flow_vars_per_k + 2 * e]; - let rev = target_solution[k * flow_vars_per_k + 2 * e + 1]; - if fwd == 1 { - on_path[u] = true; - on_path[v] = true; - } - if rev == 1 { - on_path[u] = true; - on_path[v] = true; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // For each path slot k, set the source vertex-indicator block to 1 + // exactly on the vertices incident to the commodity-k path, including s and t. + let m = self.edges.len(); + let n = self.num_vertices; + let j = self.num_paths; + let flow_vars_per_k = 2 * m; + + let mut result = vec![0usize; j * n]; + for k in 0..j { + // Find which vertices are on the path for commodity k + let mut on_path = vec![false; n]; + for e in 0..m { + let (u, v) = self.edges[e]; + let fwd = target_solution[k * flow_vars_per_k + 2 * e]; + let rev = target_solution[k * flow_vars_per_k + 2 * e + 1]; + if fwd == 1 { + on_path[u] = true; + on_path[v] = true; + } + if rev == 1 { + on_path[u] = true; + on_path[v] = true; + } } - } - for v in 0..n { - if on_path[v] { - result[k * n + v] = 1; + for v in 0..n { + if on_path[v] { + result[k * n + v] = 1; + } } } - } - result + result + }) } } diff --git a/src/rules/longestcircuit_ilp.rs b/src/rules/longestcircuit_ilp.rs index e52911a30..47f733d2b 100644 --- a/src/rules/longestcircuit_ilp.rs +++ b/src/rules/longestcircuit_ilp.rs @@ -35,8 +35,11 @@ impl ReductionResult for ReductionLongestCircuitToILP { } /// Extract: output the binary edge-selection vector (y_e). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/longestcommonsubsequence_ilp.rs b/src/rules/longestcommonsubsequence_ilp.rs index 565305fc2..b840018a2 100644 --- a/src/rules/longestcommonsubsequence_ilp.rs +++ b/src/rules/longestcommonsubsequence_ilp.rs @@ -31,16 +31,23 @@ impl ReductionResult for ReductionLCSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_symbols = self.alphabet_size + 1; - let mut witness = Vec::with_capacity(self.max_length); - for position in 0..self.max_length { - let selected = (0..num_symbols) - .find(|&symbol| target_solution.get(position * num_symbols + symbol) == Some(&1)) - .unwrap_or(self.alphabet_size); - witness.push(selected); - } - witness + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_symbols = self.alphabet_size + 1; + let mut witness = Vec::with_capacity(self.max_length); + for position in 0..self.max_length { + let selected = (0..num_symbols) + .find(|&symbol| { + target_solution.get(position * num_symbols + symbol) == Some(&1) + }) + .unwrap_or(self.alphabet_size); + witness.push(selected); + } + witness + }) } } diff --git a/src/rules/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs index 9cecb576a..bcb89bcf7 100644 --- a/src/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -48,27 +48,32 @@ 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(&self, target_solution: &[usize]) -> Vec { - // Collect selected match nodes with their characters - let mut selected: Vec<(usize, usize)> = target_solution - .iter() - .enumerate() - .filter(|(_, &v)| v == 1) - .map(|(i, _)| (self.match_nodes[i][0], self.match_chars[i])) - .collect(); - // Sort by position in the first string - selected.sort_by_key(|&(pos, _)| pos); - - // Build config: characters followed by padding - let mut config = Vec::with_capacity(self.max_length); - for &(_, ch) in &selected { - config.push(ch); - } - // Pad with alphabet_size (the padding symbol) - while config.len() < self.max_length { - config.push(self.alphabet_size); - } - config + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Collect selected match nodes with their characters + let mut selected: Vec<(usize, usize)> = target_solution + .iter() + .enumerate() + .filter(|(_, &v)| v == 1) + .map(|(i, _)| (self.match_nodes[i][0], self.match_chars[i])) + .collect(); + // Sort by position in the first string + selected.sort_by_key(|&(pos, _)| pos); + + // Build config: characters followed by padding + let mut config = Vec::with_capacity(self.max_length); + for &(_, ch) in &selected { + config.push(ch); + } + // Pad with alphabet_size (the padding symbol) + while config.len() < self.max_length { + config.push(self.alphabet_size); + } + config + }) } } diff --git a/src/rules/longestpath_ilp.rs b/src/rules/longestpath_ilp.rs index 7c43a1a74..28b8e41de 100644 --- a/src/rules/longestpath_ilp.rs +++ b/src/rules/longestpath_ilp.rs @@ -31,23 +31,28 @@ impl ReductionResult for ReductionLongestPathToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_edges) - .map(|edge_idx| { - usize::from( - target_solution - .get(Self::arc_var(edge_idx, 0)) - .copied() - .unwrap_or(0) - > 0 - || target_solution - .get(Self::arc_var(edge_idx, 1)) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_edges) + .map(|edge_idx| { + usize::from( + target_solution + .get(Self::arc_var(edge_idx, 0)) .copied() .unwrap_or(0) - > 0, - ) - }) - .collect() + > 0 + || target_solution + .get(Self::arc_var(edge_idx, 1)) + .copied() + .unwrap_or(0) + > 0, + ) + }) + .collect() + }) } } diff --git a/src/rules/maxcut_minimumcutintoboundedsets.rs b/src/rules/maxcut_minimumcutintoboundedsets.rs index e3289b666..72dc2c678 100644 --- a/src/rules/maxcut_minimumcutintoboundedsets.rs +++ b/src/rules/maxcut_minimumcutintoboundedsets.rs @@ -30,8 +30,11 @@ impl ReductionResult for ReductionMaxCutToMinCutBounded { /// Extract the source solution from the target balanced bisection. /// Take only the first `original_n` vertex assignments. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.original_n].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.original_n].to_vec()) } } diff --git a/src/rules/maxcut_minimummatrixcover.rs b/src/rules/maxcut_minimummatrixcover.rs index 3cc465185..c577dbd97 100644 --- a/src/rules/maxcut_minimummatrixcover.rs +++ b/src/rules/maxcut_minimummatrixcover.rs @@ -48,8 +48,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximalis_ilp.rs b/src/rules/maximalis_ilp.rs index 8e0f45a00..abb063b50 100644 --- a/src/rules/maximalis_ilp.rs +++ b/src/rules/maximalis_ilp.rs @@ -22,8 +22,11 @@ impl ReductionResult for ReductionMxISToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximum2satisfiability_ilp.rs b/src/rules/maximum2satisfiability_ilp.rs index 1631aff91..8d2cdbb62 100644 --- a/src/rules/maximum2satisfiability_ilp.rs +++ b/src/rules/maximum2satisfiability_ilp.rs @@ -27,8 +27,11 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vars].to_vec()) } } diff --git a/src/rules/maximum2satisfiability_maxcut.rs b/src/rules/maximum2satisfiability_maxcut.rs index 8b0e8d6cd..f2e5ddfc1 100644 --- a/src/rules/maximum2satisfiability_maxcut.rs +++ b/src/rules/maximum2satisfiability_maxcut.rs @@ -33,11 +33,16 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToMaxCut { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let reference_side = target_solution[0]; - (0..self.source_num_vars) - .map(|i| usize::from(target_solution[i + 1] == reference_side)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let reference_side = target_solution[0]; + (0..self.source_num_vars) + .map(|i| usize::from(target_solution[i + 1] == reference_side)) + .collect() + }) } } diff --git a/src/rules/maximumclique_ilp.rs b/src/rules/maximumclique_ilp.rs index c0ac43130..145c2b506 100644 --- a/src/rules/maximumclique_ilp.rs +++ b/src/rules/maximumclique_ilp.rs @@ -35,8 +35,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumclique_maximumindependentset.rs b/src/rules/maximumclique_maximumindependentset.rs index 91bd6ebbf..6d03be0bb 100644 --- a/src/rules/maximumclique_maximumindependentset.rs +++ b/src/rules/maximumclique_maximumindependentset.rs @@ -28,8 +28,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumcokplex_ilp.rs b/src/rules/maximumcokplex_ilp.rs index 4e809c7f2..9cc1751c3 100644 --- a/src/rules/maximumcokplex_ilp.rs +++ b/src/rules/maximumcokplex_ilp.rs @@ -31,8 +31,11 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumcommonedgesubgraph_ilp.rs b/src/rules/maximumcommonedgesubgraph_ilp.rs index a36090ec4..2f1df2648 100644 --- a/src/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/rules/maximumcommonedgesubgraph_ilp.rs @@ -43,16 +43,21 @@ 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(&self, target_solution: &[usize]) -> Vec { - let n1 = self.num_vertices_1; - let n2 = self.num_vertices_2; - (0..n1) - .map(|u| { - (0..n2) - .find(|&p| target_solution[u * n2 + p] == 1) - .unwrap_or(n2) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n1 = self.num_vertices_1; + let n2 = self.num_vertices_2; + (0..n1) + .map(|u| { + (0..n2) + .find(|&p| target_solution[u * n2 + p] == 1) + .unwrap_or(n2) + }) + .collect() + }) } } diff --git a/src/rules/maximumcontactmapoverlap_ilp.rs b/src/rules/maximumcontactmapoverlap_ilp.rs index 6607997da..b666fe801 100644 --- a/src/rules/maximumcontactmapoverlap_ilp.rs +++ b/src/rules/maximumcontactmapoverlap_ilp.rs @@ -46,17 +46,22 @@ 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(&self, target_solution: &[usize]) -> Vec { - let n1 = self.num_vertices_1; - let n2 = self.num_vertices_2; - (0..n1) - .map(|i| { - (0..n2) - .find(|&j| target_solution[i * n2 + j] == 1) - .map(|j| j + 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n1 = self.num_vertices_1; + let n2 = self.num_vertices_2; + (0..n1) + .map(|i| { + (0..n2) + .find(|&j| target_solution[i * n2 + j] == 1) + .map(|j| j + 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/maximumdomaticnumber_ilp.rs b/src/rules/maximumdomaticnumber_ilp.rs index ebf772153..494f62716 100644 --- a/src/rules/maximumdomaticnumber_ilp.rs +++ b/src/rules/maximumdomaticnumber_ilp.rs @@ -36,18 +36,23 @@ 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(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let mut config = vec![0; n]; - for v in 0..n { - for i in 0..n { - if target_solution[v * n + i] == 1 { - config[v] = i; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + let mut config = vec![0; n]; + for v in 0..n { + for i in 0..n { + if target_solution[v * n + i] == 1 { + config[v] = i; + break; + } } } - } - config + config + }) } } diff --git a/src/rules/maximumedgeweightedkclique_ilp.rs b/src/rules/maximumedgeweightedkclique_ilp.rs index 5db911e78..c7a0d42e9 100644 --- a/src/rules/maximumedgeweightedkclique_ilp.rs +++ b/src/rules/maximumedgeweightedkclique_ilp.rs @@ -58,8 +58,11 @@ where /// Extract: take the first `num_vertices` entries of the ILP solution. /// They are exactly the binary `x_v` selection variables. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/maximumindependentset_casts.rs b/src/rules/maximumindependentset_casts.rs index c293f0019..fe4b527bd 100644 --- a/src/rules/maximumindependentset_casts.rs +++ b/src/rules/maximumindependentset_casts.rs @@ -13,6 +13,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -21,6 +22,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -29,6 +31,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -38,6 +41,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -46,6 +50,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().cast_to_parent(), src.weights().to_vec()) ); @@ -55,6 +60,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), src.weights().iter().map(|w| w.cast_to_parent()).collect()) ); @@ -63,6 +69,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), src.weights().iter().map(|w| w.cast_to_parent()).collect()) ); @@ -71,6 +78,7 @@ impl_variant_reduction!( MaximumIndependentSet, => , fields: [num_vertices, num_edges], + aggregate: identity, |src| MaximumIndependentSet::new( src.graph().clone(), src.weights().iter().map(|w| w.cast_to_parent()).collect()) ); diff --git a/src/rules/maximumindependentset_gridgraph.rs b/src/rules/maximumindependentset_gridgraph.rs index 2515371b8..36cf30bd2 100644 --- a/src/rules/maximumindependentset_gridgraph.rs +++ b/src/rules/maximumindependentset_gridgraph.rs @@ -25,8 +25,11 @@ impl ReductionResult for ReductionISSimpleOneToGridOne { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.mapping_result.map_config_back(target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(self.mapping_result.map_config_back(target_solution)) } } diff --git a/src/rules/maximumindependentset_integralflowbundles.rs b/src/rules/maximumindependentset_integralflowbundles.rs index 6928d7336..8699ac72d 100644 --- a/src/rules/maximumindependentset_integralflowbundles.rs +++ b/src/rules/maximumindependentset_integralflowbundles.rs @@ -43,16 +43,21 @@ impl ReductionResult for ReductionMISToIFB { /// Extract solution: vertex i is selected iff arc_out_i (index 2i + 1) /// has nonzero flow. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_source_vertices) - .map(|i| { - if target_solution.get(2 * i + 1).copied().unwrap_or(0) > 0 { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_source_vertices) + .map(|i| { + if target_solution.get(2 * i + 1).copied().unwrap_or(0) > 0 { + 1 + } else { + 0 + } + }) + .collect() + }) } } @@ -141,7 +146,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( source, diff --git a/src/rules/maximumindependentset_maximumclique.rs b/src/rules/maximumindependentset_maximumclique.rs index f65042b51..701d6ab2e 100644 --- a/src/rules/maximumindependentset_maximumclique.rs +++ b/src/rules/maximumindependentset_maximumclique.rs @@ -28,8 +28,11 @@ where /// Solution extraction: identity mapping. /// A vertex selected in the clique (target) is also selected in the independent set (source). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumindependentset_maximumsetpacking.rs b/src/rules/maximumindependentset_maximumsetpacking.rs index fbe156436..62b575a6b 100644 --- a/src/rules/maximumindependentset_maximumsetpacking.rs +++ b/src/rules/maximumindependentset_maximumsetpacking.rs @@ -29,8 +29,11 @@ where } /// Solutions map directly: vertex selection = set selection. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -80,8 +83,11 @@ where } /// Solutions map directly. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumindependentset_triangular.rs b/src/rules/maximumindependentset_triangular.rs index 6d9bd44c5..d83489aef 100644 --- a/src/rules/maximumindependentset_triangular.rs +++ b/src/rules/maximumindependentset_triangular.rs @@ -27,9 +27,14 @@ impl ReductionResult for ReductionISSimpleToTriangular { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.mapping_result - .map_config_back_via_centers(target_solution) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.mapping_result + .map_config_back_via_centers(target_solution) + }) } } diff --git a/src/rules/maximumleafspanningtree_ilp.rs b/src/rules/maximumleafspanningtree_ilp.rs index c6bdcb78d..e29c034ca 100644 --- a/src/rules/maximumleafspanningtree_ilp.rs +++ b/src/rules/maximumleafspanningtree_ilp.rs @@ -39,9 +39,14 @@ impl ReductionResult for ReductionMaximumLeafSpanningTreeToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // First m variables are edge selectors - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // First m variables are edge selectors + target_solution[..self.num_edges].to_vec() + }) } } diff --git a/src/rules/maximumlikelihoodranking_ilp.rs b/src/rules/maximumlikelihoodranking_ilp.rs index 52abbac60..fe0525792 100644 --- a/src/rules/maximumlikelihoodranking_ilp.rs +++ b/src/rules/maximumlikelihoodranking_ilp.rs @@ -39,29 +39,34 @@ impl ReductionResult for ReductionMaximumLikelihoodRankingToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - if n == 0 { - return vec![]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + if n == 0 { + return Ok(vec![]); + } - // Count how many items are ranked before each item i. - // config[i] = number of items ranked before i = rank of item i. - let mut config = vec![0usize; n]; - for i in 0..n { - for j in (i + 1)..n { - let idx = pair_index(i, j, n); - if target_solution[idx] == 1 { - // i is before j -> contributes 1 to config[j] - config[j] += 1; - } else { - // j is before i -> contributes 1 to config[i] - config[i] += 1; + // Count how many items are ranked before each item i. + // config[i] = number of items ranked before i = rank of item i. + let mut config = vec![0usize; n]; + for i in 0..n { + for j in (i + 1)..n { + let idx = pair_index(i, j, n); + if target_solution[idx] == 1 { + // i is before j -> contributes 1 to config[j] + config[j] += 1; + } else { + // j is before i -> contributes 1 to config[i] + config[i] += 1; + } } } - } - config + config + }) } } diff --git a/src/rules/maximummatching_ilp.rs b/src/rules/maximummatching_ilp.rs index 329a104d5..840b817fe 100644 --- a/src/rules/maximummatching_ilp.rs +++ b/src/rules/maximummatching_ilp.rs @@ -35,8 +35,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximummatching_maximumsetpacking.rs b/src/rules/maximummatching_maximumsetpacking.rs index 9c74bf411..da3161860 100644 --- a/src/rules/maximummatching_maximumsetpacking.rs +++ b/src/rules/maximummatching_maximumsetpacking.rs @@ -30,8 +30,11 @@ where } /// Solutions map directly: edge i in MaximumMatching = set i in MaximumSetPacking. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumsetpacking_casts.rs b/src/rules/maximumsetpacking_casts.rs index e9afd996f..23ff12005 100644 --- a/src/rules/maximumsetpacking_casts.rs +++ b/src/rules/maximumsetpacking_casts.rs @@ -9,6 +9,7 @@ impl_variant_reduction!( MaximumSetPacking, => , fields: [num_sets, universe_size], + aggregate: identity, |src| MaximumSetPacking::with_weights( src.sets().to_vec(), src.weights_ref().iter().map(|w| w.cast_to_parent()).collect()) diff --git a/src/rules/maximumsetpacking_ilp.rs b/src/rules/maximumsetpacking_ilp.rs index 7ccd7de47..c464fc9a8 100644 --- a/src/rules/maximumsetpacking_ilp.rs +++ b/src/rules/maximumsetpacking_ilp.rs @@ -29,8 +29,11 @@ impl ReductionResult for ReductionSPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumsetpacking_qubo.rs b/src/rules/maximumsetpacking_qubo.rs index a3b13949c..901d7f7f2 100644 --- a/src/rules/maximumsetpacking_qubo.rs +++ b/src/rules/maximumsetpacking_qubo.rs @@ -25,8 +25,11 @@ impl ReductionResult for ReductionSPToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumcapacitatedspanningtree_ilp.rs b/src/rules/minimumcapacitatedspanningtree_ilp.rs index 7208dc432..55854846b 100644 --- a/src/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/rules/minimumcapacitatedspanningtree_ilp.rs @@ -42,9 +42,14 @@ impl ReductionResult for ReductionMinimumCapacitatedSpanningTreeToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // First m variables are edge selectors - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // First m variables are edge selectors + target_solution[..self.num_edges].to_vec() + }) } } diff --git a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs index 36c941c36..7f90a1780 100644 --- a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs +++ b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs @@ -43,8 +43,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_original_arcs].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_original_arcs].to_vec()) } } diff --git a/src/rules/minimumcoveringbycliques_ilp.rs b/src/rules/minimumcoveringbycliques_ilp.rs index 52c643111..7f9fcf584 100644 --- a/src/rules/minimumcoveringbycliques_ilp.rs +++ b/src/rules/minimumcoveringbycliques_ilp.rs @@ -39,20 +39,25 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if self.num_edges == 0 { - return vec![]; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if self.num_edges == 0 { + return Ok(vec![]); + } - (0..self.num_edges) - .map(|edge_idx| { - (0..self.num_edges) - .find(|&slot| { - target_solution[self.y_offset + edge_idx * self.num_edges + slot] == 1 - }) - .unwrap_or(0) - }) - .collect() + (0..self.num_edges) + .map(|edge_idx| { + (0..self.num_edges) + .find(|&slot| { + target_solution[self.y_offset + edge_idx * self.num_edges + slot] == 1 + }) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index a700acdfe..cd905db87 100644 --- a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -16,15 +16,6 @@ pub struct ReductionMinimumCoveringByCliquesToMinimumIntersectionGraphBasis { target: MinimumIntersectionGraphBasis, } -fn invalid_source_solution(num_edges: usize) -> Vec { - if num_edges == 0 { - // Deliberately wrong length so source `evaluate` returns `Min(None)`. - vec![0] - } else { - vec![0; num_edges - 1] - } -} - fn extract_edge_clique_cover(graph: &SimpleGraph, target_solution: &[usize]) -> Option> { let n = graph.num_vertices(); let m = graph.num_edges(); @@ -89,13 +80,23 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToMinimumIntersectionG &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if !self.target.evaluate(target_solution).is_valid() { - return invalid_source_solution(self.target.num_edges()); - } - - extract_edge_clique_cover(self.target.graph(), target_solution) - .unwrap_or_else(|| invalid_source_solution(self.target.num_edges())) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if !self.target.evaluate(target_solution).is_valid() { + return Err(crate::rules::ExtractionError::invalid( + "target configuration is not a valid intersection graph basis", + )); + } + + extract_edge_clique_cover(self.target.graph(), target_solution).ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target basis does not assign a shared label to every source edge", + ) + })? + }) } } diff --git a/src/rules/minimumcutintoboundedsets_ilp.rs b/src/rules/minimumcutintoboundedsets_ilp.rs index 8edb3a65d..44c29cd66 100644 --- a/src/rules/minimumcutintoboundedsets_ilp.rs +++ b/src/rules/minimumcutintoboundedsets_ilp.rs @@ -26,8 +26,11 @@ impl ReductionResult for ReductionMinCutBSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs index 317daa77c..e99f9817d 100644 --- a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -39,17 +39,22 @@ impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.block_offsets - .iter() - .zip(&self.block_sizes) - .map(|(&start, &size)| { - target_solution[start..start + size] - .iter() - .position(|&bit| bit == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.block_offsets + .iter() + .zip(&self.block_sizes) + .map(|(&start, &size)| { + target_solution[start..start + size] + .iter() + .position(|&bit| bit == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/minimumdominatingset_ilp.rs b/src/rules/minimumdominatingset_ilp.rs index 7aa9933c0..4d46d094c 100644 --- a/src/rules/minimumdominatingset_ilp.rs +++ b/src/rules/minimumdominatingset_ilp.rs @@ -36,8 +36,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumedgecostflow_ilp.rs b/src/rules/minimumedgecostflow_ilp.rs index fda1c6908..206a1ec33 100644 --- a/src/rules/minimumedgecostflow_ilp.rs +++ b/src/rules/minimumedgecostflow_ilp.rs @@ -43,8 +43,11 @@ impl ReductionResult for ReductionMECFToILP { } /// Extract flow solution: first m variables are the flow values. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/minimumexternalmacrodatacompression_ilp.rs b/src/rules/minimumexternalmacrodatacompression_ilp.rs index 9e50b9f1a..042943139 100644 --- a/src/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/rules/minimumexternalmacrodatacompression_ilp.rs @@ -121,66 +121,71 @@ impl ReductionResult for ReductionEMDCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.layout.n; - let k = self.alphabet_size; - let empty = k; // empty marker - - // Build D-slots - let mut d_slots = vec![empty; n]; - for j in 0..n { - if target_solution[self.layout.d_used_var(j)] == 1 { - for c in 0..k { - if target_solution[self.layout.d_var(j, c)] == 1 { - d_slots[j] = c; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.layout.n; + let k = self.alphabet_size; + let empty = k; // empty marker + + // Build D-slots + let mut d_slots = vec![empty; n]; + for j in 0..n { + if target_solution[self.layout.d_used_var(j)] == 1 { + for c in 0..k { + if target_solution[self.layout.d_var(j, c)] == 1 { + d_slots[j] = c; + break; + } } } } - } - // Walk through active segments to build C-slots - let mut c_slots = vec![empty; n]; - let mut c_pos = 0; - let mut pos = 0; - while pos < n { - // Check if lit[pos] = 1 - if target_solution[self.layout.lit_var(pos)] == 1 { - // Literal at position pos - c_slots[c_pos] = self.source_string[pos]; - c_pos += 1; - pos += 1; - continue; - } - // Check for an active pointer starting at pos - let mut found = false; - for l in 1..=(n - pos) { - for d_start in 0..=(n - l) { - let var_idx = self.layout.ptr_var(pos, l, d_start); - if target_solution[var_idx] == 1 { - // Encode pointer (d_start, l) as EMDC pointer index - let ptr_idx = encode_pointer(n, d_start, l); - c_slots[c_pos] = k + 1 + ptr_idx; - c_pos += 1; - pos += l; - found = true; + // Walk through active segments to build C-slots + let mut c_slots = vec![empty; n]; + let mut c_pos = 0; + let mut pos = 0; + while pos < n { + // Check if lit[pos] = 1 + if target_solution[self.layout.lit_var(pos)] == 1 { + // Literal at position pos + c_slots[c_pos] = self.source_string[pos]; + c_pos += 1; + pos += 1; + continue; + } + // Check for an active pointer starting at pos + let mut found = false; + for l in 1..=(n - pos) { + for d_start in 0..=(n - l) { + let var_idx = self.layout.ptr_var(pos, l, d_start); + if target_solution[var_idx] == 1 { + // Encode pointer (d_start, l) as EMDC pointer index + let ptr_idx = encode_pointer(n, d_start, l); + c_slots[c_pos] = k + 1 + ptr_idx; + c_pos += 1; + pos += l; + found = true; + break; + } + } + if found { break; } } - if found { - break; + if !found { + // Should not happen with a valid ILP solution + pos += 1; } } - if !found { - // Should not happen with a valid ILP solution - pos += 1; - } - } - // Combine D-slots and C-slots - let mut config = d_slots; - config.extend(c_slots); - config + // Combine D-slots and C-slots + let mut config = d_slots; + config.extend(c_slots); + config + }) } } @@ -387,7 +392,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumfeedbackarcset_ilp.rs b/src/rules/minimumfeedbackarcset_ilp.rs index fcce6d4ec..58d65af24 100644 --- a/src/rules/minimumfeedbackarcset_ilp.rs +++ b/src/rules/minimumfeedbackarcset_ilp.rs @@ -41,8 +41,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_arcs].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_arcs].to_vec()) } } diff --git a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs index 3b07146a5..e5e493698 100644 --- a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs @@ -48,11 +48,16 @@ impl ReductionResult for ReductionFASToMLR { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_arcs - .iter() - .map(|&(u, v)| usize::from(target_solution[u] > target_solution[v])) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.source_arcs + .iter() + .map(|&(u, v)| usize::from(target_solution[u] > target_solution[v])) + .collect() + }) } } @@ -96,7 +101,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec( source, diff --git a/src/rules/minimumfeedbackvertexset_ilp.rs b/src/rules/minimumfeedbackvertexset_ilp.rs index 1f3e45032..5ceaac91d 100644 --- a/src/rules/minimumfeedbackvertexset_ilp.rs +++ b/src/rules/minimumfeedbackvertexset_ilp.rs @@ -38,8 +38,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs index 82f5db3d7..397d5dfe0 100644 --- a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs +++ b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs @@ -37,33 +37,38 @@ impl ReductionResult for ReductionFVSToCodeGen { /// A leaf register R_x is destroyed when x¹ executes (left operand). /// If any right-child user of x⁰ is evaluated after x¹, a LOAD was needed, /// meaning x is in the feedback vertex set. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_source_vertices; - let mut source_config = vec![0usize; n]; - - // target_solution[i] = evaluation position for the i-th internal node - // Internal nodes are indices n, n+1, ..., n+m-1 (sorted), so - // target_solution[j] = position for internal node (n + j). - - // eval_pos[j] = evaluation position for internal node (n + j) - let eval_pos = target_solution; - - for (x, cfg) in source_config.iter_mut().enumerate() { - if let Some(chain_start_idx) = self.chain_start[x] { - let start_j = chain_start_idx - n; - let start_pos = eval_pos[start_j]; - - for &user_idx in &self.right_child_users[x] { - let user_j = user_idx - n; - if eval_pos[user_j] > start_pos { - *cfg = 1; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_source_vertices; + let mut source_config = vec![0usize; n]; + + // target_solution[i] = evaluation position for the i-th internal node + // Internal nodes are indices n, n+1, ..., n+m-1 (sorted), so + // target_solution[j] = position for internal node (n + j). + + // eval_pos[j] = evaluation position for internal node (n + j) + let eval_pos = target_solution; + + for (x, cfg) in source_config.iter_mut().enumerate() { + if let Some(chain_start_idx) = self.chain_start[x] { + let start_j = chain_start_idx - n; + let start_pos = eval_pos[start_j]; + + for &user_idx in &self.right_child_users[x] { + let user_j = user_idx - n; + if eval_pos[user_j] > start_pos { + *cfg = 1; + break; + } } } } - } - source_config + source_config + }) } } @@ -162,7 +167,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - let n = self.num_vertices; - (0..n) - .map(|v| { - (0..n) - .find(|&p| target_solution[v * n + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + (0..n) + .map(|v| { + (0..n) + .find(|&p| target_solution[v * n + p] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/minimumhittingset_ilp.rs b/src/rules/minimumhittingset_ilp.rs index 14018ffaf..3940752c4 100644 --- a/src/rules/minimumhittingset_ilp.rs +++ b/src/rules/minimumhittingset_ilp.rs @@ -21,8 +21,11 @@ impl ReductionResult for ReductionHSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimuminternalmacrodatacompression_ilp.rs b/src/rules/minimuminternalmacrodatacompression_ilp.rs index fe442a3e2..9d9d68d7a 100644 --- a/src/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/rules/minimuminternalmacrodatacompression_ilp.rs @@ -95,60 +95,65 @@ impl ReductionResult for ReductionIMDCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.layout.n; - let k = self.alphabet_size; - let eos = k; // end-of-string marker - - // First pass: collect segments and build source-to-compressed-position map. - // source_to_c_pos[i] = compressed position that covers source position i. - let mut source_to_c_pos = vec![0usize; n]; - let mut segments: Vec<(usize, usize, Option)> = Vec::new(); // (source_start, len, ref_source_pos) - let mut c_pos = 0; - let mut pos = 0; - - while pos < n { - if target_solution[self.layout.lit_var(pos)] == 1 { - source_to_c_pos[pos] = c_pos; - segments.push((pos, 1, None)); - c_pos += 1; - pos += 1; - continue; - } - let mut found = false; - for (idx, &(i, l, r)) in self.layout.ptr_triples.iter().enumerate() { - if i == pos && target_solution[self.layout.ptr_offset + idx] == 1 { - for offset in 0..l { - source_to_c_pos[pos + offset] = c_pos; - } - segments.push((pos, l, Some(r))); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.layout.n; + let k = self.alphabet_size; + let eos = k; // end-of-string marker + + // First pass: collect segments and build source-to-compressed-position map. + // source_to_c_pos[i] = compressed position that covers source position i. + let mut source_to_c_pos = vec![0usize; n]; + let mut segments: Vec<(usize, usize, Option)> = Vec::new(); // (source_start, len, ref_source_pos) + let mut c_pos = 0; + let mut pos = 0; + + while pos < n { + if target_solution[self.layout.lit_var(pos)] == 1 { + source_to_c_pos[pos] = c_pos; + segments.push((pos, 1, None)); c_pos += 1; - pos += l; - found = true; - break; + pos += 1; + continue; + } + let mut found = false; + for (idx, &(i, l, r)) in self.layout.ptr_triples.iter().enumerate() { + if i == pos && target_solution[self.layout.ptr_offset + idx] == 1 { + for offset in 0..l { + source_to_c_pos[pos + offset] = c_pos; + } + segments.push((pos, l, Some(r))); + c_pos += 1; + pos += l; + found = true; + break; + } + } + if !found { + pos += 1; } } - if !found { - pos += 1; - } - } - // Second pass: build config using source_to_c_pos for pointer references - let mut config = vec![eos; n]; - for (idx, &(src_start, _len, ref_pos)) in segments.iter().enumerate() { - match ref_pos { - None => { - config[idx] = self.source_string[src_start]; - } - Some(r) => { - // Pointer references source position r, which is at - // compressed position source_to_c_pos[r] - config[idx] = k + 1 + source_to_c_pos[r]; + // Second pass: build config using source_to_c_pos for pointer references + let mut config = vec![eos; n]; + for (idx, &(src_start, _len, ref_pos)) in segments.iter().enumerate() { + match ref_pos { + None => { + config[idx] = self.source_string[src_start]; + } + Some(r) => { + // Pointer references source position r, which is at + // compressed position source_to_c_pos[r] + config[idx] = k + 1 + source_to_c_pos[r]; + } } } - } - config + config + }) } } @@ -287,7 +292,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, diff --git a/src/rules/minimummatrixcover_ilp.rs b/src/rules/minimummatrixcover_ilp.rs index ecbfe96e5..bd23fbee6 100644 --- a/src/rules/minimummatrixcover_ilp.rs +++ b/src/rules/minimummatrixcover_ilp.rs @@ -27,9 +27,14 @@ impl ReductionResult for ReductionMinimumMatrixCoverToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // First n variables are the sign variables x_0,...,x_{n-1} - target_solution[..self.n].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // First n variables are the sign variables x_0,...,x_{n-1} + target_solution[..self.n].to_vec() + }) } } diff --git a/src/rules/minimummaximalmatching_ilp.rs b/src/rules/minimummaximalmatching_ilp.rs index bbb39f80c..f99124ed4 100644 --- a/src/rules/minimummaximalmatching_ilp.rs +++ b/src/rules/minimummaximalmatching_ilp.rs @@ -38,8 +38,11 @@ impl ReductionResult for ReductionMMMToILP { /// /// Since the mapping is 1:1 (each edge maps to one binary variable), /// the solution extraction is simply copying the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs index 81bbbb893..eda43212a 100644 --- a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs +++ b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs @@ -42,11 +42,16 @@ 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(&self, target_solution: &[usize]) -> Vec { - self.source_edges - .iter() - .map(|&(u, v)| usize::from(target_solution[u] == target_solution[v])) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.source_edges + .iter() + .map(|&(u, v)| usize::from(target_solution[u] == target_solution[v])) + .collect() + }) } } diff --git a/src/rules/minimummaximalmatching_minimummatrixdomination.rs b/src/rules/minimummaximalmatching_minimummatrixdomination.rs index 97c5d4d75..3909625cc 100644 --- a/src/rules/minimummaximalmatching_minimummatrixdomination.rs +++ b/src/rules/minimummaximalmatching_minimummatrixdomination.rs @@ -93,107 +93,112 @@ impl ReductionResult for ReductionMMMToMatrixDomination { /// and a swap candidate, 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(&self, target_solution: &[usize]) -> Vec { - let graph = self.source.graph(); - let edges = graph.edges(); - let num_source_edges = edges.len(); - let m = graph.left_size(); - let target_ones = self.target.ones(); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let graph = self.source.graph(); + let edges = graph.edges(); + let num_source_edges = edges.len(); + let m = graph.left_size(); + let target_ones = self.target.ones(); - // Step 1: map selected target 1-entries back to source edge indices. - // The reduction places source edge `(l_i, r_j)` (in bipartite-local - // form) at matrix cell `(i, m + j)`, which equals the global edge - // `(i, m + j)` returned by `Graph::edges()`. Build the lookup from - // matrix cell -> source edge index so we are robust to any ordering - // discrepancy between `Graph::edges()` and row-major 1-entries. - let cell_to_source_edge: std::collections::HashMap<(usize, usize), usize> = edges - .iter() - .enumerate() - .map(|(idx, &(u, v))| { - // Source edge endpoints in bipartite global coords are - // (left_idx, m + right_idx); matrix cell is (row=left, col=m+right). - let (row, col) = if u < m { (u, v) } else { (v, u) }; - ((row, col), idx) - }) - .collect(); - let mut d: Vec = target_solution - .iter() - .zip(target_ones.iter()) - .filter_map(|(&sel, &cell)| { - if sel == 1 { - cell_to_source_edge.get(&cell).copied() - } else { - None - } - }) - .collect(); + // Step 1: map selected target 1-entries back to source edge indices. + // The reduction places source edge `(l_i, r_j)` (in bipartite-local + // form) at matrix cell `(i, m + j)`, which equals the global edge + // `(i, m + j)` returned by `Graph::edges()`. Build the lookup from + // matrix cell -> source edge index so we are robust to any ordering + // discrepancy between `Graph::edges()` and row-major 1-entries. + let cell_to_source_edge: std::collections::HashMap<(usize, usize), usize> = edges + .iter() + .enumerate() + .map(|(idx, &(u, v))| { + // Source edge endpoints in bipartite global coords are + // (left_idx, m + right_idx); matrix cell is (row=left, col=m+right). + let (row, col) = if u < m { (u, v) } else { (v, u) }; + ((row, col), idx) + }) + .collect(); + let mut d: Vec = target_solution + .iter() + .zip(target_ones.iter()) + .filter_map(|(&sel, &cell)| { + if sel == 1 { + cell_to_source_edge.get(&cell).copied() + } 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. - }; + // 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(); - without_e1.swap_remove(d.iter().position(|&x| x == e1_idx).unwrap()); - if is_edge_dominating_set(&without_e1, &edges) { - d = without_e1; - continue; - } - let mut without_e2 = d.clone(); - without_e2.swap_remove(d.iter().position(|&x| x == e2_idx).unwrap()); - if is_edge_dominating_set(&without_e2, &edges) { - d = without_e2; - continue; - } + // Try dropping e1_idx or e2_idx if the remainder is still an EDS. + let mut without_e1 = d.clone(); + without_e1.swap_remove(d.iter().position(|&x| x == e1_idx).unwrap()); + if is_edge_dominating_set(&without_e1, &edges) { + d = without_e1; + continue; + } + let mut without_e2 = d.clone(); + without_e2.swap_remove(d.iter().position(|&x| x == e2_idx).unwrap()); + 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 }; + // 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 }; - // 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) { - replace_in(&mut d, e1_idx, new_idx); - continue; - } - // Symmetric swap on e2. - if let Some(new_idx) = find_swap_edge(w, e2_idx, &d, &edges) { - replace_in(&mut d, e2_idx, new_idx); - continue; - } + // 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) { + replace_in(&mut d, e1_idx, new_idx); + continue; + } + // Symmetric swap on e2. + if let Some(new_idx) = find_swap_edge(w, e2_idx, &d, &edges) { + replace_in(&mut d, e2_idx, 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. - unreachable!( - "Yannakakis-Gavril EDS->IEDS transformation could not progress; \ + // 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. + unreachable!( + "Yannakakis-Gavril EDS->IEDS transformation could not progress; \ target witness must be a feasible (dominating) MMD configuration" - ); - } + ); + } - // Step 3: encode the matching as a binary configuration over source edges. - let mut config = vec![0usize; num_source_edges]; - for &idx in &d { - config[idx] = 1; - } - config + // Step 3: encode the matching as a binary configuration over source edges. + let mut config = vec![0usize; num_source_edges]; + for &idx in &d { + config[idx] = 1; + } + config + }) } } diff --git a/src/rules/minimummetricdimension_ilp.rs b/src/rules/minimummetricdimension_ilp.rs index 16516c72a..8f0982d03 100644 --- a/src/rules/minimummetricdimension_ilp.rs +++ b/src/rules/minimummetricdimension_ilp.rs @@ -38,8 +38,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimummultiwaycut_ilp.rs b/src/rules/minimummultiwaycut_ilp.rs index 62f442eaf..bb130dd7f 100644 --- a/src/rules/minimummultiwaycut_ilp.rs +++ b/src/rules/minimummultiwaycut_ilp.rs @@ -42,9 +42,14 @@ 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(&self, target_solution: &[usize]) -> Vec { - let offset = self.k * self.n; - (0..self.m).map(|e| target_solution[offset + e]).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let offset = self.k * self.n; + (0..self.m).map(|e| target_solution[offset + e]).collect() + }) } } diff --git a/src/rules/minimummultiwaycut_qubo.rs b/src/rules/minimummultiwaycut_qubo.rs index 610ec7397..e29b0c45a 100644 --- a/src/rules/minimummultiwaycut_qubo.rs +++ b/src/rules/minimummultiwaycut_qubo.rs @@ -36,30 +36,35 @@ impl ReductionResult for ReductionMinimumMultiwayCutToQUBO { /// Decode one-hot assignment: for each vertex find its terminal, then /// for each edge check if endpoints are in different terminals. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - 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(|u| { - (0..k) - .find(|&t| target_solution[u * k + t] == 1) - .unwrap_or(0) - }) - .collect(); - - // For each edge, output 1 (cut) if endpoints differ, 0 (keep) otherwise - self.edges - .iter() - .map(|&(u, v)| { - if assignments[u] != assignments[v] { - 1 - } else { - 0 - } - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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(|u| { + (0..k) + .find(|&t| target_solution[u * k + t] == 1) + .unwrap_or(0) + }) + .collect(); + + // For each edge, output 1 (cut) if endpoints differ, 0 (keep) otherwise + self.edges + .iter() + .map(|&(u, v)| { + if assignments[u] != assignments[v] { + 1 + } else { + 0 + } + }) + .collect() + }) } } diff --git a/src/rules/minimumsetcovering_ilp.rs b/src/rules/minimumsetcovering_ilp.rs index 7befcbaca..2b17f517e 100644 --- a/src/rules/minimumsetcovering_ilp.rs +++ b/src/rules/minimumsetcovering_ilp.rs @@ -33,8 +33,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumsummulticenter_ilp.rs b/src/rules/minimumsummulticenter_ilp.rs index 976f1a387..9e78166df 100644 --- a/src/rules/minimumsummulticenter_ilp.rs +++ b/src/rules/minimumsummulticenter_ilp.rs @@ -41,8 +41,11 @@ impl ReductionResult for ReductionMSMCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/minimumtardinesssequencing_ilp.rs b/src/rules/minimumtardinesssequencing_ilp.rs index f09bdc7f4..0c4335ede 100644 --- a/src/rules/minimumtardinesssequencing_ilp.rs +++ b/src/rules/minimumtardinesssequencing_ilp.rs @@ -26,10 +26,15 @@ impl ReductionResult for ReductionMTSToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_tasks; + let schedule = one_hot_decode(target_solution, n, n, 0); + permutation_to_lehmer(&schedule) + }) } } @@ -48,10 +53,15 @@ impl ReductionResult for ReductionMTSWeightedToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_tasks; + let schedule = one_hot_decode(target_solution, n, n, 0); + permutation_to_lehmer(&schedule) + }) } } diff --git a/src/rules/minimumvertexcover_comparativecontainment.rs b/src/rules/minimumvertexcover_comparativecontainment.rs index 64344c219..898f1eae9 100644 --- a/src/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/rules/minimumvertexcover_comparativecontainment.rs @@ -45,19 +45,24 @@ impl ReductionResult for ReductionDecisionMVCToComparativeContainment { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if let Some(witness) = &self.trivial_yes { - return witness.clone(); - } - let mut cover = vec![0; self.num_source_vertices]; - for (vertex, &selected) in target_solution - .iter() - .take(self.num_source_vertices) - .enumerate() - { - cover[vertex] = selected; - } - cover + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if let Some(witness) = &self.trivial_yes { + return Ok(witness.clone()); + } + let mut cover = vec![0; self.num_source_vertices]; + for (vertex, &selected) in target_solution + .iter() + .take(self.num_source_vertices) + .enumerate() + { + cover[vertex] = selected; + } + cover + }) } } diff --git a/src/rules/minimumvertexcover_ensemblecomputation.rs b/src/rules/minimumvertexcover_ensemblecomputation.rs index 158fc65dc..292a57245 100644 --- a/src/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/rules/minimumvertexcover_ensemblecomputation.rs @@ -45,29 +45,38 @@ impl ReductionResult for ReductionVCToEC { /// We collect all vertices that appear as singleton operands (index < |V|) /// in the meaningful steps only (before all required subsets are covered). /// Padding steps beyond the coverage point are ignored. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - use crate::traits::Problem; - use crate::types::Min; - - let meaningful_steps = match self.target.evaluate(target_solution) { - Min(Some(n)) => n, - _ => return vec![0; self.num_vertices], - }; - let mut cover = vec![0usize; self.num_vertices]; - - for step in 0..meaningful_steps { - let left = target_solution[2 * step]; - let right = target_solution[2 * step + 1]; - - if left < self.num_vertices { - cover[left] = 1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + use crate::traits::Problem; + use crate::types::Min; + + let meaningful_steps = match self.target.evaluate(target_solution) { + Min(Some(n)) => n, + _ => { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode a valid ensemble computation", + )) + } + }; + let mut cover = vec![0usize; self.num_vertices]; + + for step in 0..meaningful_steps { + let left = target_solution[2 * step]; + let right = target_solution[2 * step + 1]; + + if left < self.num_vertices { + cover[left] = 1; + } + if right < self.num_vertices { + cover[right] = 1; + } } - if right < self.num_vertices { - cover[right] = 1; - } - } - cover + cover + }) } } diff --git a/src/rules/minimumvertexcover_longestcommonsubsequence.rs b/src/rules/minimumvertexcover_longestcommonsubsequence.rs index 0c99aa568..324fd5692 100644 --- a/src/rules/minimumvertexcover_longestcommonsubsequence.rs +++ b/src/rules/minimumvertexcover_longestcommonsubsequence.rs @@ -21,15 +21,20 @@ impl ReductionResult for ReductionVCToLCS { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut cover = vec![1; self.num_vertices]; - for &symbol in target_solution { - if symbol >= self.num_vertices { - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let mut cover = vec![1; self.num_vertices]; + for &symbol in target_solution { + if symbol >= self.num_vertices { + break; + } + cover[symbol] = 0; } - cover[symbol] = 0; - } - cover + cover + }) } } diff --git a/src/rules/minimumvertexcover_maximumindependentset.rs b/src/rules/minimumvertexcover_maximumindependentset.rs index 85a650286..3ed74e3be 100644 --- a/src/rules/minimumvertexcover_maximumindependentset.rs +++ b/src/rules/minimumvertexcover_maximumindependentset.rs @@ -27,8 +27,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.iter().map(|&x| 1 - x).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.iter().map(|&x| 1 - x).collect()) } } @@ -68,8 +71,11 @@ where } /// Solution extraction: complement the configuration. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.iter().map(|&x| 1 - x).collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.iter().map(|&x| 1 - x).collect()) } } diff --git a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs index b616f2b77..f8a45f664 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -31,8 +31,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_source_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_source_vertices].to_vec()) } } @@ -105,7 +108,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, diff --git a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs index 7f984aa67..e8af6b26f 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs @@ -26,8 +26,11 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumvertexcover_minimumhittingset.rs b/src/rules/minimumvertexcover_minimumhittingset.rs index 0b426e715..57e9b6ed2 100644 --- a/src/rules/minimumvertexcover_minimumhittingset.rs +++ b/src/rules/minimumvertexcover_minimumhittingset.rs @@ -26,8 +26,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumvertexcover_minimummaximalmatching.rs b/src/rules/minimumvertexcover_minimummaximalmatching.rs index 3556e510d..93dde62ec 100644 --- a/src/rules/minimumvertexcover_minimummaximalmatching.rs +++ b/src/rules/minimumvertexcover_minimummaximalmatching.rs @@ -8,7 +8,7 @@ //! (for example, on `C5`, `mmm(G) = 2` but `mvc(G) = 3`). use crate::models::graph::{MinimumMaximalMatching, MinimumVertexCover}; -use crate::rules::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; +use crate::rules::{ReductionEntry, ReductionOverhead}; use crate::topology::SimpleGraph; use crate::traits::Problem; use crate::types::{One, ProblemSize}; @@ -34,7 +34,7 @@ inventory::submit! { module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::none(), + turing: false, overhead_eval_fn: source_problem_size, source_size_fn: source_problem_size, } diff --git a/src/rules/minimumvertexcover_minimumsetcovering.rs b/src/rules/minimumvertexcover_minimumsetcovering.rs index c15f5f8c0..bbff2c664 100644 --- a/src/rules/minimumvertexcover_minimumsetcovering.rs +++ b/src/rules/minimumvertexcover_minimumsetcovering.rs @@ -29,8 +29,11 @@ where /// Solution extraction: variables correspond 1:1. /// Vertex i in VC corresponds to set i in SC. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/rules/minimumvertexcover_minimumweightandorgraph.rs index feeefdf1a..5628d979f 100644 --- a/src/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -23,10 +23,15 @@ impl ReductionResult for ReductionVCToAndOrGraph { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_source_vertices) - .map(|j| usize::from(target_solution.get(self.sink_arc_start + j) == Some(&1))) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_source_vertices) + .map(|j| usize::from(target_solution.get(self.sink_arc_start + j) == Some(&1))) + .collect() + }) } } diff --git a/src/rules/minimumweightdecoding_ilp.rs b/src/rules/minimumweightdecoding_ilp.rs index 2961fac19..daf35aa05 100644 --- a/src/rules/minimumweightdecoding_ilp.rs +++ b/src/rules/minimumweightdecoding_ilp.rs @@ -40,8 +40,11 @@ impl ReductionResult for ReductionMinimumWeightDecodingToILP { } /// Extract the source solution: first m variables are the binary x_j values. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_cols].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_cols].to_vec()) } } diff --git a/src/rules/minmaxmulticenter_ilp.rs b/src/rules/minmaxmulticenter_ilp.rs index 0e475e6a3..eb9ccd79e 100644 --- a/src/rules/minmaxmulticenter_ilp.rs +++ b/src/rules/minmaxmulticenter_ilp.rs @@ -45,8 +45,11 @@ impl ReductionResult for ReductionMMCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/mixedchinesepostman_ilp.rs b/src/rules/mixedchinesepostman_ilp.rs index ded42a6fb..173fa5a94 100644 --- a/src/rules/mixedchinesepostman_ilp.rs +++ b/src/rules/mixedchinesepostman_ilp.rs @@ -26,9 +26,14 @@ impl ReductionResult for ReductionMCPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Return the orientation bits d_k in source edge order - target_solution[..self.num_undirected_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Return the orientation bits d_k in source edge order + target_solution[..self.num_undirected_edges].to_vec() + }) } } diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 95eb5f477..7a9dafa97 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -419,7 +419,8 @@ pub use search::{ }; pub(crate) use traits::DynReductionResult; pub use traits::{ - AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, + AggregateReductionResult, ExtractionError, ExtractionResult, ReduceTo, ReduceToAggregate, + ReductionAutoCast, ReductionResult, }; #[cfg(feature = "example-db")] @@ -735,6 +736,7 @@ macro_rules! impl_variant_reduction { ($problem:ident, < $($src_param:ty),+ > => < $($dst_param:ty),+ >, fields: [$($field:ident),+], + $(aggregate: $aggregate:ident,)? |$src:ident| $body:expr) => { #[$crate::reduction( overhead = { @@ -742,6 +744,7 @@ macro_rules! impl_variant_reduction { &[$(stringify!($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 f9c06851a..4da4805c3 100644 --- a/src/rules/monochromatictriangle_ilp.rs +++ b/src/rules/monochromatictriangle_ilp.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionMonochromaticTriangleToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/multiplecopyfileallocation_ilp.rs b/src/rules/multiplecopyfileallocation_ilp.rs index 1852fb2a6..87c238c6c 100644 --- a/src/rules/multiplecopyfileallocation_ilp.rs +++ b/src/rules/multiplecopyfileallocation_ilp.rs @@ -36,8 +36,11 @@ impl ReductionResult for ReductionMCFAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/multiprocessorscheduling_ilp.rs b/src/rules/multiprocessorscheduling_ilp.rs index f96d7ff4d..9487a8a2b 100644 --- a/src/rules/multiprocessorscheduling_ilp.rs +++ b/src/rules/multiprocessorscheduling_ilp.rs @@ -33,15 +33,20 @@ impl ReductionResult for ReductionMSToILP { } /// Extract solution: for each task j, find the unique processor p where x_{j,p} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_processors = self.num_processors; - (0..self.num_tasks) - .map(|j| { - (0..num_processors) - .find(|&p| target_solution[j * num_processors + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_processors = self.num_processors; + (0..self.num_tasks) + .map(|j| { + (0..num_processors) + .find(|&p| target_solution[j * num_processors + p] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/naesatisfiability_ilp.rs b/src/rules/naesatisfiability_ilp.rs index 382fa58f5..bed2ca447 100644 --- a/src/rules/naesatisfiability_ilp.rs +++ b/src/rules/naesatisfiability_ilp.rs @@ -26,8 +26,11 @@ impl ReductionResult for ReductionNAESATToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/naesatisfiability_maxcut.rs b/src/rules/naesatisfiability_maxcut.rs index 5bdda85d9..eda476a4c 100644 --- a/src/rules/naesatisfiability_maxcut.rs +++ b/src/rules/naesatisfiability_maxcut.rs @@ -36,10 +36,15 @@ 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(&self, target_solution: &[usize]) -> Vec { - (0..self.source_num_vars) - .map(|i| target_solution[2 * i]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.source_num_vars) + .map(|i| target_solution[2 * i]) + .collect() + }) } } diff --git a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs index 43f363de1..447f73832 100644 --- a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -65,12 +65,17 @@ impl ReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.layout - .variables - .iter() - .map(|variable| usize::from(target_solution[variable.t] == 0)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.layout + .variables + .iter() + .map(|variable| usize::from(target_solution[variable.t] == 0)) + .collect() + }) } } diff --git a/src/rules/naesatisfiability_setsplitting.rs b/src/rules/naesatisfiability_setsplitting.rs index f27732a6a..915df3614 100644 --- a/src/rules/naesatisfiability_setsplitting.rs +++ b/src/rules/naesatisfiability_setsplitting.rs @@ -25,14 +25,19 @@ impl ReductionResult for ReductionNAESATToSetSplitting { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - assert!( - target_solution.len() >= self.num_source_variables, - "SetSplitting solution has {} variables but source requires {}", - target_solution.len(), - self.num_source_variables, - ); - target_solution[..self.num_source_variables].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + assert!( + target_solution.len() >= self.num_source_variables, + "SetSplitting solution has {} variables but source requires {}", + target_solution.len(), + self.num_source_variables, + ); + target_solution[..self.num_source_variables].to_vec() + }) } } diff --git a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 3177b1f93..982048245 100644 --- a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -26,32 +26,37 @@ impl ReductionResult for ReductionN3DMToNMTS { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - 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]) - .expect("NMTS witness must not overflow i64 pair sums"); - x_indices_by_pair_sum - .entry(pair_sum) - .or_default() - .push(x_index); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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]) + .expect("NMTS witness must not overflow i64 pair sums"); + x_indices_by_pair_sum + .entry(pair_sum) + .or_default() + .push(x_index); + } - 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_to_i64(self.source_bound, w_size); - let x_index = x_indices_by_pair_sum - .get_mut(&target_sum) - .and_then(Vec::pop) - .expect("satisfying NMTS witness must realize every target complement"); - x_perm.push(x_index); - y_perm.push(target_solution[x_index]); - } + 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_to_i64(self.source_bound, w_size); + let x_index = x_indices_by_pair_sum + .get_mut(&target_sum) + .and_then(Vec::pop) + .expect("satisfying NMTS witness must realize every target complement"); + x_perm.push(x_index); + y_perm.push(target_solution[x_index]); + } - x_perm.extend(y_perm); - x_perm + x_perm.extend(y_perm); + x_perm + }) } } diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index 17b7aeb03..ae04dcc03 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -44,14 +44,19 @@ impl ReductionResult for ReductionNMTSToILP { } /// Extract solution: for each x_i find the y_j it is paired with. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut assignment = vec![0usize; self.m]; - for (var_idx, triple) in self.triples.iter().enumerate() { - if target_solution[var_idx] == 1 { - assignment[triple.i] = triple.j; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let mut assignment = vec![0usize; self.m]; + for (var_idx, triple) in self.triples.iter().enumerate() { + if target_solution[var_idx] == 1 { + assignment[triple.i] = triple.j; + } } - } - assignment + assignment + }) } } diff --git a/src/rules/openshopscheduling_ilp.rs b/src/rules/openshopscheduling_ilp.rs index 7487c1b95..b12c18fc9 100644 --- a/src/rules/openshopscheduling_ilp.rs +++ b/src/rules/openshopscheduling_ilp.rs @@ -88,24 +88,29 @@ impl ReductionResult for ReductionOSSToILP { /// Extract per-machine job orderings from the ILP start times, then /// convert to the config format (direct permutation indices per machine). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_jobs; - let m = self.num_machines; - - // Read start times s_{j,i} for each (j, i) - let start = |j: usize, i: usize| -> usize { - let idx = self.num_order_vars + j * m + i; - target_solution.get(idx).copied().unwrap_or(0) - }; - - // For each machine, sort jobs by their start time on that machine - let mut config = Vec::with_capacity(n * m); - for i in 0..m { - let mut jobs: Vec = (0..n).collect(); - jobs.sort_by_key(|&j| (start(j, i), j)); - config.extend(jobs); - } - config + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_jobs; + let m = self.num_machines; + + // Read start times s_{j,i} for each (j, i) + let start = |j: usize, i: usize| -> usize { + let idx = self.num_order_vars + j * m + i; + target_solution.get(idx).copied().unwrap_or(0) + }; + + // For each machine, sort jobs by their start time on that machine + let mut config = Vec::with_capacity(n * m); + for i in 0..m { + let mut jobs: Vec = (0..n).collect(); + jobs.sort_by_key(|&j| (start(j, i), j)); + config.extend(jobs); + } + config + }) } } diff --git a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 62b4ef512..443b1df09 100644 --- a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -45,36 +45,45 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - match &self.construction { - // No edges: any arrangement has total length 0 <= k, so emit the - // identity arrangement f(v) = v over all source vertices. - ConstructionKind::EdgelessYes { num_vertices } => (0..*num_vertices).collect(), - // Genuine NO: there is no valid arrangement; return a sentinel - // (identity) so the source decision evaluates correctly (NO). - ConstructionKind::FixedNo { num_vertices } => (0..*num_vertices).collect(), - ConstructionKind::Incidence { num_vertices } => { - // The C1MA witness is a column permutation: `config[position] = col`. - // Columns correspond to vertices, so this places vertex `col` at - // `position`. The OLA arrangement is `f(vertex) = position`, i.e. - // the inverse permutation. - let n = *num_vertices; - if target_solution.len() != n { - return (0..n).collect(); - } - let mut arrangement = vec![0usize; n]; - let mut seen = vec![false; n]; - for (position, &vertex) in target_solution.iter().enumerate() { - if vertex >= n || seen[vertex] { - // Not a valid permutation; fall back to identity. - return (0..n).collect(); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + match &self.construction { + // No edges: any arrangement has total length 0 <= k, so emit the + // identity arrangement f(v) = v over all source vertices. + ConstructionKind::EdgelessYes { num_vertices } => (0..*num_vertices).collect(), + // Genuine NO: the identity arrangement is the mathematically defined + // source-side representative and evaluates to NO. + ConstructionKind::FixedNo { num_vertices } => (0..*num_vertices).collect(), + ConstructionKind::Incidence { num_vertices } => { + // The C1MA witness is a column permutation: `config[position] = col`. + // Columns correspond to vertices, so this places vertex `col` at + // `position`. The OLA arrangement is `f(vertex) = position`, i.e. + // the inverse permutation. + let n = *num_vertices; + if target_solution.len() != n { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected a permutation of {n} columns, got {} entries", + target_solution.len() + ))); + } + let mut arrangement = vec![0usize; n]; + let mut seen = vec![false; n]; + for (position, &vertex) in target_solution.iter().enumerate() { + if vertex >= n || seen[vertex] { + return Err(crate::rules::ExtractionError::invalid( + "target column order is not a permutation", + )); + } + seen[vertex] = true; + arrangement[vertex] = position; } - seen[vertex] = true; - arrangement[vertex] = position; + arrangement } - arrangement } - } + }) } } diff --git a/src/rules/optimallineararrangement_ilp.rs b/src/rules/optimallineararrangement_ilp.rs index b84b4f7d7..afb80feac 100644 --- a/src/rules/optimallineararrangement_ilp.rs +++ b/src/rules/optimallineararrangement_ilp.rs @@ -34,15 +34,20 @@ impl ReductionResult for ReductionOLAToILP { } /// Extract: for each vertex v, output its position p (the unique p with x_{v,p} = 1). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - (0..n) - .map(|v| { - (0..n) - .find(|&p| target_solution[v * n + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + (0..n) + .map(|v| { + (0..n) + .find(|&p| target_solution[v * n + p] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs b/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs index 1ff24c75c..96c280645 100644 --- a/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs +++ b/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs @@ -32,20 +32,26 @@ impl ReductionResult for ReductionOLAToSequencingToMinimizeWeightedCompletionTim &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let schedule = crate::models::misc::decode_lehmer(target_solution, self.target.num_tasks()) - .expect("target solution must be a valid Lehmer code"); - let mut arrangement = vec![0usize; self.num_vertices]; - let mut next_position = 0usize; - - for task in schedule { - if task < self.num_vertices { - arrangement[task] = next_position; - next_position += 1; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let schedule = + crate::models::misc::decode_lehmer(target_solution, self.target.num_tasks()) + .expect("target solution must be a valid Lehmer code"); + let mut arrangement = vec![0usize; self.num_vertices]; + let mut next_position = 0usize; + + for task in schedule { + if task < self.num_vertices { + arrangement[task] = next_position; + next_position += 1; + } } - } - arrangement + arrangement + }) } } @@ -106,7 +112,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/paintshop_ilp.rs b/src/rules/paintshop_ilp.rs index c43ea8dd3..146cf6979 100644 --- a/src/rules/paintshop_ilp.rs +++ b/src/rules/paintshop_ilp.rs @@ -24,8 +24,11 @@ impl ReductionResult for ReductionPaintShopToILP { } /// Extract first-occurrence color bits (x_i) from ILP solution. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_cars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_cars].to_vec()) } } @@ -130,7 +133,7 @@ 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 fcd1dd294..9cb719e51 100644 --- a/src/rules/paintshop_qubo.rs +++ b/src/rules/paintshop_qubo.rs @@ -28,8 +28,11 @@ 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(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 71494d2b6..aabef85bd 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -17,48 +17,12 @@ use crate::expr::Expr; use crate::growth::Growth; use crate::rules::cost::PathCostFn; -use crate::rules::registry::{EdgeCapabilities, ReduceFn, ReductionOverhead}; +use crate::rules::registry::{ReduceFn, ReductionOverhead}; use crate::rules::traits::DynReductionResult; use crate::types::ProblemSize; use std::any::Any; -use std::cell::Cell; use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::panic; use std::rc::Rc; -use std::sync::Once; - -thread_local! { - /// When set, the installed panic hook suppresses output on the current thread. - static SILENCE_PANIC: Cell = const { Cell::new(false) }; -} - -static HOOK_INIT: Once = Once::new(); - -/// Run `f`, catching any panic and returning `None`, without printing the panic to -/// stderr on this thread. -/// -/// During the measured search we deliberately execute candidate reductions to measure -/// their real output size. A reduction whose preconditions the current instance violates -/// panics (its macro-generated dispatch downcasts and unwraps); such an edge is simply -/// not a viable path, so we treat the panic as "edge infeasible" and prune it — the -/// design's guarantee that path selection never crashes. The thread-local silencer keeps -/// this expected, recovered panic from spamming stderr while leaving genuine panics on -/// other threads untouched. -pub(crate) fn catch_reduction(f: impl FnOnce() -> R) -> Option { - HOOK_INIT.call_once(|| { - let prev = panic::take_hook(); - panic::set_hook(Box::new(move |info| { - if SILENCE_PANIC.with(|s| s.get()) { - return; - } - prev(info); - })); - }); - SILENCE_PANIC.with(|s| s.set(true)); - let result = panic::catch_unwind(panic::AssertUnwindSafe(f)); - SILENCE_PANIC.with(|s| s.set(false)); - result.ok() -} /// Default post-construction total-size budget for the measured search (in "size units", /// i.e. the sum of all `ProblemSize` components). @@ -72,15 +36,12 @@ pub const DEFAULT_SIZE_BUDGET: usize = 10_000_000; /// /// It exposes exactly what a label needs to advance: the overhead formula (for symbolic /// and formula-based labels), the executable reduction function (for measured execution), -/// the edge capabilities, and the target node's identity (for measuring the constructed -/// target's size by name). +/// and the target node's identity (for measuring the constructed target's size by name). pub struct ReductionEdge<'g> { /// Overhead expressions mapping source size fields to target size fields. pub overhead: &'g ReductionOverhead, /// Type-erased witness reduction executor, if this edge supports witness/config mode. pub reduce_fn: Option, - /// Capability metadata for the edge. - pub capabilities: EdgeCapabilities, /// Target problem name (e.g. "ILP"). pub target_name: &'static str, /// Target problem variant. @@ -249,26 +210,20 @@ impl<'a> MeasuredLabel<'a> { /// Execute one reduction and retain the state only when its measured target is /// within the post-construction budget. pub(crate) fn extend(&self, edge: &ReductionEdge) -> Option { - // Execute the reduction and measure the real target size. Executing a - // reduction whose preconditions the current instance violates panics; such an - // edge is not a viable path, so a caught panic prunes it (returns `None`). The - // measurement (`compute_source_size`) probes every same-name size function, so - // mismatched-variant probes panic internally too — both are wrapped in one - // silenced `catch_reduction`. + // Execute the reduction and measure the real target size. The graph has already + // selected the exact source variant, so any panic is a reduction defect and must + // remain visible. let reduce_fn = edge.reduce_fn?; let current: &dyn Any = match &self.pos { MeasuredPos::Source(s) => *s, MeasuredPos::Reduced(step) => step.result.target_problem_any(), }; - let target_name = edge.target_name; - let (result, measured) = catch_reduction(|| { - let result: Rc = Rc::from(reduce_fn(current)); - let measured = crate::rules::ReductionGraph::compute_source_size( - target_name, - result.target_problem_any(), - ); - (result, measured) - })?; + let result: Rc = Rc::from(reduce_fn(current)); + let measured = crate::rules::ReductionGraph::compute_source_size( + edge.target_name, + edge.target_variant, + result.target_problem_any(), + ); if measured.total() > self.budget { return None; } diff --git a/src/rules/partiallyorderedknapsack_ilp.rs b/src/rules/partiallyorderedknapsack_ilp.rs index a8a4d4161..5fe35bed5 100644 --- a/src/rules/partiallyorderedknapsack_ilp.rs +++ b/src/rules/partiallyorderedknapsack_ilp.rs @@ -21,8 +21,11 @@ impl ReductionResult for ReductionPOKToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_binpacking.rs b/src/rules/partition_binpacking.rs index 4314c678d..715c8e926 100644 --- a/src/rules/partition_binpacking.rs +++ b/src/rules/partition_binpacking.rs @@ -30,15 +30,20 @@ impl ReductionResult for ReductionPartitionToBinPacking { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // 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. - // The first bin encountered maps to 0, the second to 1. - let first_bin = target_solution[0]; - target_solution - .iter() - .map(|&b| if b == first_bin { 0 } else { 1 }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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. + // The first bin encountered maps to 0, the second to 1. + let first_bin = target_solution[0]; + target_solution + .iter() + .map(|&b| if b == first_bin { 0 } else { 1 }) + .collect() + }) } } diff --git a/src/rules/partition_cosineproductintegration.rs b/src/rules/partition_cosineproductintegration.rs index bad735af9..b5c262481 100644 --- a/src/rules/partition_cosineproductintegration.rs +++ b/src/rules/partition_cosineproductintegration.rs @@ -28,8 +28,11 @@ impl ReductionResult for ReductionPartitionToCPI { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_integralflowwithmultipliers.rs b/src/rules/partition_integralflowwithmultipliers.rs index 74e5be98b..39cf06143 100644 --- a/src/rules/partition_integralflowwithmultipliers.rs +++ b/src/rules/partition_integralflowwithmultipliers.rs @@ -15,8 +15,7 @@ use crate::topology::DirectedGraph; #[derive(Debug, Clone)] pub struct ReductionPartitionToIntegralFlowWithMultipliers { target: IntegralFlowWithMultipliers, - source_n: usize, - item_arc_count: usize, + item_arc_count: Option, } impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { @@ -27,16 +26,26 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if self.item_arc_count == 0 { - return vec![0; self.source_n]; - } - - if target_solution.len() < self.item_arc_count { - return vec![0; self.source_n]; - } - - target_solution[..self.item_arc_count].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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", + ) + })?; + if target_solution.len() < item_arc_count { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected at least {} flow values, got {}", + item_arc_count, + target_solution.len() + ))); + } + + target_solution[..item_arc_count].to_vec() + }) } } @@ -57,8 +66,7 @@ impl ReduceTo for Partition { let graph = DirectedGraph::new(3, vec![(0, 1), (1, 2)]); return ReductionPartitionToIntegralFlowWithMultipliers { target: IntegralFlowWithMultipliers::new(graph, 0, 2, vec![1, 2, 1], vec![1, 1], 1), - source_n, - item_arc_count: 0, + item_arc_count: None, }; } @@ -97,8 +105,7 @@ impl ReduceTo for Partition { capacities, half_sum, ), - source_n, - item_arc_count: source_n, + item_arc_count: Some(source_n), } } } diff --git a/src/rules/partition_knapsack.rs b/src/rules/partition_knapsack.rs index 51d548a36..9bddbef27 100644 --- a/src/rules/partition_knapsack.rs +++ b/src/rules/partition_knapsack.rs @@ -18,8 +18,11 @@ impl ReductionResult for ReductionPartitionToKnapsack { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_multiprocessorscheduling.rs b/src/rules/partition_multiprocessorscheduling.rs index b47843dc9..f1e54a355 100644 --- a/src/rules/partition_multiprocessorscheduling.rs +++ b/src/rules/partition_multiprocessorscheduling.rs @@ -32,8 +32,11 @@ impl ReductionResult for ReductionPartitionToMPS { /// Solution extraction: identity mapping. /// Partition config (0/1 for subset) maps directly to processor assignment (0/1). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index 309bd441d..6bd8a5193 100644 --- a/src/rules/partition_openshopscheduling.rs +++ b/src/rules/partition_openshopscheduling.rs @@ -17,70 +17,77 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_elements = self.target.num_jobs().saturating_sub(1); - let mut source_config = vec![0; num_elements]; - let Some(orders) = self.target.decode_orders(target_solution) else { - return source_config; - }; - if num_elements == 0 { - return source_config; - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_elements = self.target.num_jobs().saturating_sub(1); + let mut source_config = vec![0; num_elements]; + let Some(orders) = self.target.decode_orders(target_solution) else { + return Err(crate::rules::ExtractionError::invalid( + "target configuration does not encode valid machine orders", + )); + }; + if num_elements == 0 { + return Ok(source_config); + } - let special_job = num_elements; - let half_sum = self.target.processing_times()[special_job][0]; - - // Find the middle machine and compute start times - let makespan_orders = &orders; - let n = self.target.num_jobs(); - let m = self.target.num_machines(); - - // Simulate to get start times - let mut machine_avail = vec![0usize; m]; - let mut job_avail = vec![0usize; n]; - let mut start_times = vec![vec![0usize; m]; n]; - - // Schedule by processing the orders - let mut cursor = vec![0usize; m]; - let total_ops = n * m; - for _ in 0..total_ops { - let mut best: Option<(usize, usize, usize)> = None; // (start, machine, job) - for (mi, order) in makespan_orders.iter().enumerate() { - if cursor[mi] < order.len() { - let job = order[cursor[mi]]; - let start = machine_avail[mi].max(job_avail[job]); - if best.is_none_or(|(bs, _, _)| start < bs) { - best = Some((start, mi, job)); + let special_job = num_elements; + let half_sum = self.target.processing_times()[special_job][0]; + + // Find the middle machine and compute start times + let makespan_orders = &orders; + let n = self.target.num_jobs(); + let m = self.target.num_machines(); + + // Simulate to get start times + let mut machine_avail = vec![0usize; m]; + let mut job_avail = vec![0usize; n]; + let mut start_times = vec![vec![0usize; m]; n]; + + // Schedule by processing the orders + let mut cursor = vec![0usize; m]; + let total_ops = n * m; + for _ in 0..total_ops { + let mut best: Option<(usize, usize, usize)> = None; // (start, machine, job) + for (mi, order) in makespan_orders.iter().enumerate() { + if cursor[mi] < order.len() { + let job = order[cursor[mi]]; + let start = machine_avail[mi].max(job_avail[job]); + if best.is_none_or(|(bs, _, _)| start < bs) { + best = Some((start, mi, job)); + } } } + let (start, mi, job) = best.expect("schedule incomplete"); + start_times[job][mi] = start; + let end = start + self.target.processing_times()[job][mi]; + machine_avail[mi] = end; + job_avail[job] = end; + cursor[mi] += 1; } - let (start, mi, job) = best.expect("schedule incomplete"); - start_times[job][mi] = start; - let end = start + self.target.processing_times()[job][mi]; - machine_avail[mi] = end; - job_avail[job] = end; - cursor[mi] += 1; - } - // 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) - .unwrap_or_else(|| { - let mut machines: Vec = (0..m).collect(); - machines.sort_by_key(|&machine| (start_times[special_job][machine], machine)); - machines[m / 2] - }); - let pivot = start_times[special_job][middle_machine]; - - for (job, slot) in source_config.iter_mut().enumerate() { - let completion = start_times[job][middle_machine] - + self.target.processing_times()[job][middle_machine]; - if completion <= pivot { - *slot = 1; + // 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) + .unwrap_or_else(|| { + let mut machines: Vec = (0..m).collect(); + machines.sort_by_key(|&machine| (start_times[special_job][machine], machine)); + machines[m / 2] + }); + let pivot = start_times[special_job][middle_machine]; + + for (job, slot) in source_config.iter_mut().enumerate() { + let completion = start_times[job][middle_machine] + + self.target.processing_times()[job][middle_machine]; + if completion <= pivot { + *slot = 1; + } } - } - source_config + source_config + }) } } diff --git a/src/rules/partition_productionplanning.rs b/src/rules/partition_productionplanning.rs index c4ddcd3d3..6a1f0c6fd 100644 --- a/src/rules/partition_productionplanning.rs +++ b/src/rules/partition_productionplanning.rs @@ -17,12 +17,17 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution - .iter() - .take(self.target.num_periods().saturating_sub(1)) - .map(|&production| usize::from(production > 0)) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + target_solution + .iter() + .take(self.target.num_periods().saturating_sub(1)) + .map(|&production| usize::from(production > 0)) + .collect() + }) } } diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index f47be5bc8..05c6409e6 100644 --- a/src/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/rules/partition_sequencingtominimizetardytaskweight.rs @@ -33,21 +33,26 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let schedule = self.decode_schedule(target_solution); - let mut source_config = vec![1; self.target.num_tasks()]; - let mut completion_time = 0u64; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let schedule = self.decode_schedule(target_solution); + let mut source_config = vec![1; self.target.num_tasks()]; + let mut completion_time = 0u64; - for task in schedule { - completion_time = completion_time - .checked_add(self.target.lengths()[task]) - .expect("completion time overflowed u64"); - if completion_time <= self.target.deadlines()[task] { - source_config[task] = 0; + for task in schedule { + completion_time = completion_time + .checked_add(self.target.lengths()[task]) + .expect("completion time overflowed u64"); + if completion_time <= self.target.deadlines()[task] { + source_config[task] = 0; + } } - } - source_config + source_config + }) } } diff --git a/src/rules/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs index a092d46a0..58148eb19 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -26,15 +26,20 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() == self.source_n { - // Normal case: same elements, same binary vector. - target_solution.to_vec() - } else { - // Odd-sum case: target is trivially infeasible (0 elements). - // Return all-zero config for the source (which also won't satisfy it). - vec![0; self.source_n] - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if target_solution.len() == self.source_n { + // Normal case: same elements, same binary vector. + target_solution.to_vec() + } else { + // Odd-sum case: target is trivially infeasible (0 elements). + // Return all-zero config for the source (which also won't satisfy it). + vec![0; self.source_n] + } + }) } } diff --git a/src/rules/partition_sumofsquarespartition.rs b/src/rules/partition_sumofsquarespartition.rs index f8fded1f9..a0f3f512e 100644 --- a/src/rules/partition_sumofsquarespartition.rs +++ b/src/rules/partition_sumofsquarespartition.rs @@ -47,12 +47,17 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { /// witness has a different length, so we return an all-zero source-sized /// vector; `Partition::evaluate` then yields `Or(false)`, which is the /// correct answer because a single positive element cannot be balanced. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() == self.source_n { - target_solution.to_vec() - } else { - vec![0; self.source_n] - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if target_solution.len() == self.source_n { + target_solution.to_vec() + } else { + vec![0; self.source_n] + } + }) } } diff --git a/src/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/rules/partitionintocliques_minimumcoveringbycliques.rs index d73b71533..6b4367928 100644 --- a/src/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -88,10 +88,6 @@ fn add_clique_edges(vertices: &[usize], edges: &mut Vec<(usize, usize)>) { } } -fn invalid_source_solution(num_source_vertices: usize, num_source_cliques: usize) -> Vec { - vec![num_source_cliques; num_source_vertices] -} - /// Result of reducing PartitionIntoCliques to MinimumCoveringByCliques. #[derive(Debug, Clone)] pub struct ReductionPartitionIntoCliquesToMinimumCoveringByCliques { @@ -108,58 +104,75 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.source_graph.num_vertices(); - let target_edges = self.target.graph().edges(); - if target_solution.len() != target_edges.len() { - return invalid_source_solution(n, self.source_num_cliques); - } - - 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) - } else { - None - }; - - if let Some(i) = matching_index { - matching_labels[i] = Some(label); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.source_graph.num_vertices(); + let target_edges = self.target.graph().edges(); + if target_solution.len() != target_edges.len() { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {} edge labels, got {}", + target_edges.len(), + target_solution.len() + ))); } - } - if matching_labels.iter().any(Option::is_none) { - return invalid_source_solution(n, self.source_num_cliques); - } + 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) + } 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.expect("checked above"); - let next = label_map.len(); - *label_map.entry(label).or_insert(next) - }) - .collect::>(); + if matching_labels.iter().any(Option::is_none) { + return Err(crate::rules::ExtractionError::invalid( + "target cover does not label every matching gadget edge", + )); + } - if label_map.len() > self.source_num_cliques { - return invalid_source_solution(n, self.source_num_cliques); - } + let mut label_map = BTreeMap::new(); + let extracted = matching_labels + .into_iter() + .map(|label| { + let label = label.expect("checked above"); + let next = label_map.len(); + *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 + ))); + } - let source_problem = - PartitionIntoCliques::new(self.source_graph.clone(), self.source_num_cliques); - if as crate::traits::Problem>::evaluate( - &source_problem, - &extracted, - ) - .0 - { - extracted - } else { - invalid_source_solution(n, self.source_num_cliques) - } + let source_problem = + PartitionIntoCliques::new(self.source_graph.clone(), self.source_num_cliques); + if as crate::traits::Problem>::evaluate( + &source_problem, + &extracted, + ) + .0 + { + extracted + } else { + return Err(crate::rules::ExtractionError::invalid( + "target cover maps to an invalid source clique partition", + )); + } + }) } } diff --git a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index df158820c..7e856e100 100644 --- a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -33,8 +33,11 @@ impl ReductionResult for ReductionPPL2ToBCSF { /// /// Both problems use the same vertex-to-group assignment encoding, /// so the solution mapping is identity. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partitionintopathsoflength2_ilp.rs b/src/rules/partitionintopathsoflength2_ilp.rs index 2e3c3ccc6..1c5540d36 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -43,18 +43,23 @@ impl ReductionResult for ReductionPIPL2ToILP { } /// Extract solution: for each vertex v, find the unique group g where x_{v,g} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_groups = self.num_groups; - (0..self.num_vertices) - .map(|v| { - (0..num_groups) - .find(|&g| { - let idx = v * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_groups = self.num_groups; + (0..self.num_vertices) + .map(|v| { + (0..num_groups) + .find(|&g| { + let idx = v * num_groups + g; + idx < target_solution.len() && target_solution[idx] == 1 + }) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/partitionintotriangles_ilp.rs b/src/rules/partitionintotriangles_ilp.rs index 18d32c5ca..dc83de3bc 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -37,18 +37,23 @@ impl ReductionResult for ReductionPITToILP { } /// Extract solution: for each vertex v, find the unique group g where x_{v,g} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_groups = self.num_groups; - (0..self.num_vertices) - .map(|v| { - (0..num_groups) - .find(|&g| { - let idx = v * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_groups = self.num_groups; + (0..self.num_vertices) + .map(|v| { + (0..num_groups) + .find(|&g| { + let idx = v * num_groups + g; + idx < target_solution.len() && target_solution[idx] == 1 + }) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/pathconstrainednetworkflow_ilp.rs b/src/rules/pathconstrainednetworkflow_ilp.rs index ce761eb79..30f787a49 100644 --- a/src/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/rules/pathconstrainednetworkflow_ilp.rs @@ -22,8 +22,11 @@ impl ReductionResult for ReductionPCNFToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/precedenceconstrainedscheduling_ilp.rs b/src/rules/precedenceconstrainedscheduling_ilp.rs index d464cc2a6..86fcb73d5 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -38,15 +38,20 @@ 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(&self, target_solution: &[usize]) -> Vec { - let d = self.deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let d = self.deadline; + (0..self.num_tasks) + .map(|j| { + (0..d) + .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/preemptivescheduling_ilp.rs b/src/rules/preemptivescheduling_ilp.rs index 3c068ec71..37a55560d 100644 --- a/src/rules/preemptivescheduling_ilp.rs +++ b/src/rules/preemptivescheduling_ilp.rs @@ -51,9 +51,14 @@ 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(&self, target_solution: &[usize]) -> Vec { - let nd = self.num_tasks * self.d_max; - target_solution[..nd.min(target_solution.len())].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let nd = self.num_tasks * self.d_max; + target_solution[..nd.min(target_solution.len())].to_vec() + }) } } diff --git a/src/rules/prizecollectingsteinerforest_steinertree.rs b/src/rules/prizecollectingsteinerforest_steinertree.rs index a298b86ac..fed6438a9 100644 --- a/src/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/rules/prizecollectingsteinerforest_steinertree.rs @@ -69,41 +69,46 @@ impl ReductionResult for ReductionPCSFToSteinerTree { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_source_vertices; - let m = self.num_source_edges; - let mut source_config = vec![0usize; n + m]; - - // Mark vertices included via their gadget include-edge `(v, t_v)`, - // and edges via the matching original edge. - for (target_idx, &selected) in target_solution.iter().enumerate() { - if selected != 1 { - continue; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_source_vertices; + let m = self.num_source_edges; + let mut source_config = vec![0usize; n + m]; + + // Mark vertices included via their gadget include-edge `(v, t_v)`, + // and edges via the matching original edge. + for (target_idx, &selected) in target_solution.iter().enumerate() { + if selected != 1 { + continue; + } + if let Some(v) = self.target_to_include_vertex[target_idx] { + source_config[v] = 1; + } else if let Some(src_edge) = self.target_to_source_edge[target_idx] { + source_config[n + src_edge] = 1; + } } - if let Some(v) = self.target_to_include_vertex[target_idx] { - source_config[v] = 1; - } else if let Some(src_edge) = self.target_to_source_edge[target_idx] { - source_config[n + src_edge] = 1; - } - } - // 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.get(target_idx).copied() != Some(1) { - continue; + // 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.get(target_idx).copied() != Some(1) { + continue; + } + if let Some(src_edge) = self.target_to_source_edge[target_idx] { + let (u, v) = self.source_edge_pair(src_edge); + source_config[u] = 1; + source_config[v] = 1; + } } - if let Some(src_edge) = self.target_to_source_edge[target_idx] { - let (u, v) = self.source_edge_pair(src_edge); - source_config[u] = 1; - source_config[v] = 1; - } - } - source_config + source_config + }) } } @@ -231,7 +236,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec SteinerTree example must have an optimal target tree"); - let source_config = reduction.extract_solution(&target_config); + let source_config = reduction.extract_solution(&target_config).unwrap(); crate::example_db::specs::assemble_rule_example( &source, target, diff --git a/src/rules/quadraticassignment_ilp.rs b/src/rules/quadraticassignment_ilp.rs index 62a3c9916..fc5f9bfcc 100644 --- a/src/rules/quadraticassignment_ilp.rs +++ b/src/rules/quadraticassignment_ilp.rs @@ -34,15 +34,20 @@ impl ReductionResult for ReductionQAPToILP { } /// Extract: for each facility i, output the unique location p with x_{i,p} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let loc = self.num_locations; - (0..self.num_facilities) - .map(|i| { - (0..loc) - .find(|&p| target_solution[i * loc + p] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let loc = self.num_locations; + (0..self.num_facilities) + .map(|i| { + (0..loc) + .find(|&p| target_solution[i * loc + p] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/qubo_ilp.rs b/src/rules/qubo_ilp.rs index 249df5886..75b1e7792 100644 --- a/src/rules/qubo_ilp.rs +++ b/src/rules/qubo_ilp.rs @@ -33,8 +33,11 @@ impl ReductionResult for ReductionQUBOToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_original].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_original].to_vec()) } } diff --git a/src/rules/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index 063eb3264..94ff40d3c 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -21,8 +21,11 @@ impl ReductionResult for ReductionRPCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/registersufficiency_ilp.rs b/src/rules/registersufficiency_ilp.rs index 0a625ea0a..788c3cba6 100644 --- a/src/rules/registersufficiency_ilp.rs +++ b/src/rules/registersufficiency_ilp.rs @@ -26,8 +26,11 @@ impl ReductionResult for ReductionRegisterSufficiencyToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_vertices].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 8048022da..387062baf 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -101,55 +101,19 @@ pub struct EdgeCapabilities { } impl EdgeCapabilities { - pub const fn none() -> Self { + pub(crate) const fn from_executors( + reduce_fn: Option, + reduce_aggregate_fn: Option, + turing: bool, + ) -> Self { Self { - witness: false, - aggregate: false, - turing: false, - } - } - - pub const fn witness_only() -> Self { - Self { - witness: true, - aggregate: false, - turing: false, - } - } - - pub const fn aggregate_only() -> Self { - Self { - witness: false, - aggregate: true, - turing: false, - } - } - - pub const fn both() -> Self { - Self { - witness: true, - aggregate: true, - turing: false, - } - } - - pub const fn turing() -> Self { - Self { - witness: false, - aggregate: false, - turing: true, + witness: reduce_fn.is_some(), + aggregate: reduce_aggregate_fn.is_some(), + turing, } } } -/// Defaults to `witness_only()` — the conservative choice for edges registered -/// via `#[reduction]`, which are witness/config reductions. -impl Default for EdgeCapabilities { - fn default() -> Self { - Self::witness_only() - } -} - /// A registered reduction entry for static inventory registration. /// Uses function pointers to lazily derive variant fields from `Problem::variant()`. pub struct ReductionEntry { @@ -174,8 +138,8 @@ pub struct ReductionEntry { /// `ReduceToAggregate::reduce_to_aggregate()`, and returns the result as a /// boxed `DynAggregateReductionResult`. pub reduce_aggregate_fn: Option, - /// Capability metadata for runtime path filtering. - pub capabilities: EdgeCapabilities, + /// Whether this is a Turing (multi-query) reduction. + pub turing: bool, /// Compiled overhead evaluation function. /// Takes a `&dyn Any` (must be `&SourceType`), calls getter methods directly, /// and returns the computed target problem size. @@ -202,6 +166,11 @@ impl ReductionEntry { (self.target_variant_fn)() } + /// 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) + } + /// Check if this reduction involves only the base (unweighted) variants. pub fn is_base_reduction(&self) -> bool { let source = self.source_variant(); @@ -229,7 +198,7 @@ impl std::fmt::Debug for ReductionEntry { .field("target_variant", &self.target_variant()) .field("overhead", &self.overhead()) .field("module_path", &self.module_path) - .field("capabilities", &self.capabilities) + .field("capabilities", &self.capabilities()) .finish() } } diff --git a/src/rules/resourceconstrainedscheduling_ilp.rs b/src/rules/resourceconstrainedscheduling_ilp.rs index 61e2037bb..2301d357b 100644 --- a/src/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/rules/resourceconstrainedscheduling_ilp.rs @@ -29,15 +29,20 @@ impl ReductionResult for ReductionRCSToILP { } /// Extract: for each task j, find the unique slot t with x_{j,t} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let d = self.deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let d = self.deadline; + (0..self.num_tasks) + .map(|j| { + (0..d) + .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs index 91f4d4a19..9ec80e2f4 100644 --- a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -36,14 +36,19 @@ 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(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - // target_solution is the parent array of the rooted tree on X = V - // Source config = [parent_array, identity_mapping] - let mut source_config = target_solution.to_vec(); - // Append identity mapping: f(v) = v for all v - source_config.extend(0..n); - source_config + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + // target_solution is the parent array of the rooted tree on X = V + // Source config = [parent_array, identity_mapping] + let mut source_config = target_solution.to_vec(); + // Append identity mapping: f(v) = v for all v + source_config.extend(0..n); + source_config + }) } } diff --git a/src/rules/rootedtreestorageassignment_ilp.rs b/src/rules/rootedtreestorageassignment_ilp.rs index ee137d38a..d60fc2c65 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -71,15 +71,20 @@ impl ReductionResult for ReductionRTSAToILP { } /// Decode parent array from one-hot parent indicators p_{v,u}. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - (0..n) - .map(|v| { - (0..n) - .find(|&u| target_solution[idx_p(n, v, u)] == 1) - .unwrap_or(v) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + (0..n) + .map(|v| { + (0..n) + .find(|&u| target_solution[idx_p(n, v, u)] == 1) + .unwrap_or(v) + }) + .collect() + }) } } @@ -423,7 +428,7 @@ 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 cc5ba5758..01785f301 100644 --- a/src/rules/ruralpostman_ilp.rs +++ b/src/rules/ruralpostman_ilp.rs @@ -26,9 +26,14 @@ impl ReductionResult for ReductionRPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Output the traversal multiplicities t_e - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Output the traversal multiplicities t_e + target_solution[..self.num_edges].to_vec() + }) } } diff --git a/src/rules/sat_circuitsat.rs b/src/rules/sat_circuitsat.rs index f0a2eb5a8..a2236d72c 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -26,11 +26,16 @@ impl ReductionResult for ReductionSATToCircuit { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.source_var_indices - .iter() - .map(|&idx| target_solution[idx]) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.source_var_indices + .iter() + .map(|&idx| target_solution[idx]) + .collect() + }) } } diff --git a/src/rules/sat_coloring.rs b/src/rules/sat_coloring.rs index 5426f57b2..be2273643 100644 --- a/src/rules/sat_coloring.rs +++ b/src/rules/sat_coloring.rs @@ -240,40 +240,45 @@ 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(&self, target_solution: &[usize]) -> Vec { - // First determine which color is TRUE, FALSE, and AUX - // Vertices 0, 1, 2 are TRUE, FALSE, AUX respectively - assert!( - target_solution.len() >= 3, - "Invalid solution: coloring must have at least 3 vertices" - ); - let true_color = target_solution[0]; - let false_color = target_solution[1]; - let aux_color = target_solution[2]; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // First determine which color is TRUE, FALSE, and AUX + // Vertices 0, 1, 2 are TRUE, FALSE, AUX respectively + assert!( + target_solution.len() >= 3, + "Invalid solution: coloring must have at least 3 vertices" + ); + let true_color = target_solution[0]; + let false_color = target_solution[1]; + let aux_color = target_solution[2]; - // Sanity checks - assert!( - true_color != false_color && true_color != aux_color, - "Invalid coloring solution: special vertices must have distinct colors" - ); + // Sanity checks + assert!( + true_color != false_color && true_color != aux_color, + "Invalid coloring solution: special vertices must have distinct colors" + ); - let mut assignment = vec![0usize; self.num_source_variables]; + let mut assignment = vec![0usize; self.num_source_variables]; - for (i, &pos_vertex) in self.pos_vertices.iter().enumerate() { - let vertex_color = target_solution[pos_vertex]; + 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 - assert!( - vertex_color != aux_color, - "Invalid coloring solution: variable vertex has auxiliary color" - ); + // Sanity check: variable vertices should not have AUX color + assert!( + vertex_color != aux_color, + "Invalid coloring solution: variable vertex has auxiliary color" + ); - // If positive literal has TRUE color, variable is true (1) - // Otherwise, variable is false (0) - assignment[i] = if vertex_color == true_color { 1 } else { 0 }; - } + // If positive literal has TRUE color, variable is true (1) + // Otherwise, variable is false (0) + assignment[i] = if vertex_color == true_color { 1 } else { 0 }; + } - assignment + assignment + }) } } diff --git a/src/rules/sat_ksat.rs b/src/rules/sat_ksat.rs index 39be989d5..ea73fa1a2 100644 --- a/src/rules/sat_ksat.rs +++ b/src/rules/sat_ksat.rs @@ -31,9 +31,14 @@ impl ReductionResult for ReductionSATToKSAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Only return the original variables, discarding ancillas - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // Only return the original variables, discarding ancillas + target_solution[..self.source_num_vars].to_vec() + }) } } @@ -162,9 +167,14 @@ impl ReductionResult for ReductionKSATToSAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Direct mapping - no transformation needed - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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 b49367747..6d8ba24e3 100644 --- a/src/rules/sat_maximumindependentset.rs +++ b/src/rules/sat_maximumindependentset.rs @@ -76,23 +76,28 @@ 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(&self, target_solution: &[usize]) -> Vec { - let mut assignment = vec![0usize; self.num_source_variables]; - let mut covered = vec![false; self.num_source_variables]; - - for (vertex_idx, &selected) in target_solution.iter().enumerate() { - if selected == 1 { - let literal = &self.literals[vertex_idx]; - // If the literal is positive (neg=false), variable should be true (1) - // If the literal is negated (neg=true), variable should be false (0) - assignment[literal.name] = if literal.neg { 0 } else { 1 }; - covered[literal.name] = true; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let mut assignment = vec![0usize; self.num_source_variables]; + let mut covered = vec![false; self.num_source_variables]; + + for (vertex_idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let literal = &self.literals[vertex_idx]; + // If the literal is positive (neg=false), variable should be true (1) + // If the literal is negated (neg=true), variable should be false (0) + assignment[literal.name] = if literal.neg { 0 } else { 1 }; + covered[literal.name] = true; + } } - } - // Variables not covered can be assigned any value (we use 0) - // They are already initialized to 0 - assignment + // Variables not covered can be assigned any value (we use 0) + // They are already initialized to 0 + assignment + }) } } diff --git a/src/rules/sat_minimumdominatingset.rs b/src/rules/sat_minimumdominatingset.rs index e5046ac42..dd3bccbf6 100644 --- a/src/rules/sat_minimumdominatingset.rs +++ b/src/rules/sat_minimumdominatingset.rs @@ -53,49 +53,55 @@ impl ReductionResult for ReductionSATToDS { /// - 3*i+1: negative literal NOT x_i (selecting means x_i = false) /// - 3*i+2: dummy vertex (selecting means x_i can be either) /// - /// If more than num_literals vertices are selected, the solution is invalid - /// and we return a default assignment. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let selected_count: usize = target_solution.iter().sum(); - - // If more vertices selected than variables, not a minimal dominating set - // corresponding to a satisfying assignment - if selected_count > self.num_literals { - // Return default assignment (all false) - return vec![0; self.num_literals]; - } - - let mut assignment = vec![0usize; self.num_literals]; - - for (i, &value) in target_solution.iter().enumerate() { - if value == 1 { - // Only consider variable gadget vertices (first 3*num_literals vertices) - if i >= 3 * self.num_literals { - continue; // Skip clause vertices - } + /// If more than num_literals vertices are selected, the target witness is invalid. + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let selected_count: usize = target_solution.iter().sum(); + + // If more vertices selected than variables, not a minimal dominating set + // corresponding to a satisfying assignment + if selected_count > self.num_literals { + return Err(crate::rules::ExtractionError::invalid(format!( + "selected {selected_count} dominating-set vertices for {} source variables", + self.num_literals + ))); + } - let var_index = i / 3; - let vertex_type = i % 3; + let mut assignment = vec![0usize; self.num_literals]; - match vertex_type { - 0 => { - // Positive literal selected: x_i = true - assignment[var_index] = 1; - } - 1 => { - // Negative literal selected: x_i = false - assignment[var_index] = 0; + for (i, &value) in target_solution.iter().enumerate() { + if value == 1 { + // Only consider variable gadget vertices (first 3*num_literals vertices) + if i >= 3 * self.num_literals { + continue; // Skip clause vertices } - 2 => { - // Dummy vertex selected: variable is unconstrained - // Default to false (already 0), but could be anything + + let var_index = i / 3; + let vertex_type = i % 3; + + match vertex_type { + 0 => { + // Positive literal selected: x_i = true + assignment[var_index] = 1; + } + 1 => { + // Negative literal selected: x_i = false + assignment[var_index] = 0; + } + 2 => { + // Dummy vertex selected: variable is unconstrained + // Default to false (already 0), but could be anything + } + _ => unreachable!(), } - _ => unreachable!(), } } - } - assignment + assignment + }) } } diff --git a/src/rules/satisfiability_integralflowhomologousarcs.rs b/src/rules/satisfiability_integralflowhomologousarcs.rs index e00849ec2..227a1c387 100644 --- a/src/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/rules/satisfiability_integralflowhomologousarcs.rs @@ -102,19 +102,24 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - self.variable_paths - .iter() - .map(|paths| { - usize::from( - target_solution - .get(paths.true_base_arc) - .copied() - .unwrap_or(0) - > 0, - ) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.variable_paths + .iter() + .map(|paths| { + usize::from( + target_solution + .get(paths.true_base_arc) + .copied() + .unwrap_or(0) + > 0, + ) + }) + .collect() + }) } } diff --git a/src/rules/satisfiability_maximum2satisfiability.rs b/src/rules/satisfiability_maximum2satisfiability.rs index c26d68458..d959c8e1b 100644 --- a/src/rules/satisfiability_maximum2satisfiability.rs +++ b/src/rules/satisfiability_maximum2satisfiability.rs @@ -19,8 +19,11 @@ impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.source_num_vars].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.source_num_vars].to_vec()) } } diff --git a/src/rules/satisfiability_naesatisfiability.rs b/src/rules/satisfiability_naesatisfiability.rs index b17a292b0..2fbe4a144 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -28,19 +28,24 @@ impl ReductionResult for ReductionSATToNAESAT { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { let n = self.source_num_vars; if target_solution.len() <= n { - return vec![0; n]; + return Err(crate::rules::ExtractionError::invalid(format!( + "expected at least {} values including the sentinel, got {}", + n + 1, + target_solution.len() + ))); } + // The sentinel variable is the last variable (index n). - let sentinel_value = target_solution[n]; - if sentinel_value == 0 { - // Sentinel is false: return first n variables as-is. - target_solution[..n].to_vec() + if target_solution[n] == 0 { + Ok(target_solution[..n].to_vec()) } else { - // Sentinel is true: return complement of first n variables. - target_solution[..n].iter().map(|&v| 1 - v).collect() + Ok(target_solution[..n].iter().map(|&v| 1 - v).collect()) } } } diff --git a/src/rules/satisfiability_nontautology.rs b/src/rules/satisfiability_nontautology.rs index be900a4a0..385891290 100644 --- a/src/rules/satisfiability_nontautology.rs +++ b/src/rules/satisfiability_nontautology.rs @@ -21,8 +21,11 @@ impl ReductionResult for ReductionSATToNonTautology { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs index 72cc2f27a..8ad396270 100644 --- a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs @@ -51,14 +51,19 @@ impl ReductionResult for ReductionSMWCTToILP { } /// Extract solution: for each task, find the processor with x_{t,p} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_tasks) - .map(|t| { - (0..self.num_processors) - .find(|&p| target_solution[self.x_var(t, p)] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_tasks) + .map(|t| { + (0..self.num_processors) + .find(|&p| target_solution[self.x_var(t, p)] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/schedulingwithindividualdeadlines_ilp.rs b/src/rules/schedulingwithindividualdeadlines_ilp.rs index 80f0745cd..850c52348 100644 --- a/src/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/rules/schedulingwithindividualdeadlines_ilp.rs @@ -38,15 +38,20 @@ 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(&self, target_solution: &[usize]) -> Vec { - let d = self.max_deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let d = self.max_deadline; + (0..self.num_tasks) + .map(|j| { + (0..d) + .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs index dd5f4ab7d..d6545b30a 100644 --- a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -31,10 +31,15 @@ impl ReductionResult for ReductionSTMMCCToILP { } /// Extract: decode position assignment → permutation → Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); - permutation_to_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_tasks; + let schedule = one_hot_decode(target_solution, n, n, 0); + permutation_to_lehmer(&schedule) + }) } } diff --git a/src/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/rules/sequencingtominimizetardytaskweight_ilp.rs index 801b0369c..5c4a88110 100644 --- a/src/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -25,12 +25,17 @@ impl ReductionResult for ReductionSTMTTWToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - 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) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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) + }) } } diff --git a/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index ba157bffe..655154fe2 100644 --- a/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -51,10 +51,15 @@ impl ReductionResult for ReductionSTMWCTToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let mut schedule: Vec = (0..self.num_tasks).collect(); - schedule.sort_by_key(|&task| (target_solution.get(task).copied().unwrap_or(0), task)); - Self::encode_schedule_as_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let mut schedule: Vec = (0..self.num_tasks).collect(); + schedule.sort_by_key(|&task| (target_solution.get(task).copied().unwrap_or(0), task)); + Self::encode_schedule_as_lehmer(&schedule) + }) } } diff --git a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs index aab00740d..e8e5bb1ee 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -49,12 +49,17 @@ impl ReductionResult for ReductionSTMWTToILP { } /// Extract: sort jobs by completion time C_j, convert to Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let c_offset = self.num_order_vars; - let mut jobs: Vec = (0..n).collect(); - jobs.sort_by_key(|&j| (target_solution.get(c_offset + j).copied().unwrap_or(0), j)); - Self::encode_schedule_as_lehmer(&jobs) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_tasks; + let c_offset = self.num_order_vars; + let mut jobs: Vec = (0..n).collect(); + jobs.sort_by_key(|&j| (target_solution.get(c_offset + j).copied().unwrap_or(0), j)); + Self::encode_schedule_as_lehmer(&jobs) + }) } } diff --git a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 711a5ea95..5c63a7e61 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -36,10 +36,15 @@ impl ReductionResult for ReductionSWDSTToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - 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) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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) + }) } } diff --git a/src/rules/sequencingwithinintervals_ilp.rs b/src/rules/sequencingwithinintervals_ilp.rs index 5d816ca44..8457f2444 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -43,15 +43,20 @@ 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(&self, target_solution: &[usize]) -> Vec { - self.task_layout - .iter() - .map(|&(base, count)| { - (0..count) - .find(|&k| target_solution.get(base + k).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + self.task_layout + .iter() + .map(|&(base, count)| { + (0..count) + .find(|&k| target_solution.get(base + k).copied().unwrap_or(0) == 1) + .unwrap_or(0) + }) + .collect() + }) } } @@ -139,7 +144,7 @@ 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 cbcbadce0..3dfca7126 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -46,22 +46,27 @@ impl ReductionResult for ReductionSWRTDToILP { /// Extract: read each task's start time, sort tasks by start time, /// encode as Lehmer code. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_tasks; - let horizon = self.time_horizon; - // For each task, find the start time - let mut start_times: Vec<(usize, usize)> = (0..n) - .map(|j| { - let start = (0..horizon) - .find(|&t| target_solution.get(j * horizon + t).copied().unwrap_or(0) == 1) - .unwrap_or(0); - (j, start) - }) - .collect(); - // Sort by start time (break ties by task index) - start_times.sort_by_key(|&(j, t)| (t, j)); - let schedule: Vec = start_times.iter().map(|&(j, _)| j).collect(); - Self::encode_schedule_as_lehmer(&schedule) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_tasks; + let horizon = self.time_horizon; + // For each task, find the start time + let mut start_times: Vec<(usize, usize)> = (0..n) + .map(|j| { + let start = (0..horizon) + .find(|&t| target_solution.get(j * horizon + t).copied().unwrap_or(0) == 1) + .unwrap_or(0); + (j, start) + }) + .collect(); + // Sort by start time (break ties by task index) + start_times.sort_by_key(|&(j, t)| (t, j)); + let schedule: Vec = start_times.iter().map(|&(j, _)| j).collect(); + Self::encode_schedule_as_lehmer(&schedule) + }) } } diff --git a/src/rules/setsplitting_betweenness.rs b/src/rules/setsplitting_betweenness.rs index 499a03e6d..280e6acc6 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -28,25 +28,30 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - assert!( - target_solution.len() > self.pole, - "Betweenness solution has {} positions but pole index is {}", - target_solution.len(), - self.pole - ); - assert!( - target_solution.len() >= self.source_universe_size, - "Betweenness solution has {} positions but source requires {} elements", - target_solution.len(), - self.source_universe_size - ); + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + assert!( + target_solution.len() > self.pole, + "Betweenness solution has {} positions but pole index is {}", + target_solution.len(), + self.pole + ); + assert!( + target_solution.len() >= self.source_universe_size, + "Betweenness solution has {} positions but source requires {} elements", + target_solution.len(), + self.source_universe_size + ); - let pole_position = target_solution[self.pole]; - target_solution[..self.source_universe_size] - .iter() - .map(|&position| usize::from(position > pole_position)) - .collect() + let pole_position = target_solution[self.pole]; + target_solution[..self.source_universe_size] + .iter() + .map(|&position| usize::from(position > pole_position)) + .collect() + }) } } diff --git a/src/rules/setsplitting_ilp.rs b/src/rules/setsplitting_ilp.rs index 2756b2898..67c737081 100644 --- a/src/rules/setsplitting_ilp.rs +++ b/src/rules/setsplitting_ilp.rs @@ -28,8 +28,11 @@ impl ReductionResult for ReductionSetSplittingToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/shortestcommonsupersequence_ilp.rs b/src/rules/shortestcommonsupersequence_ilp.rs index 59256028f..fe002c0b1 100644 --- a/src/rules/shortestcommonsupersequence_ilp.rs +++ b/src/rules/shortestcommonsupersequence_ilp.rs @@ -27,16 +27,21 @@ 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(&self, target_solution: &[usize]) -> Vec { - let b = self.max_length; - let k = self.alphabet_size + 1; // includes padding symbol - (0..b) - .map(|p| { - (0..k) - .find(|&a| target_solution[p * k + a] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let b = self.max_length; + let k = self.alphabet_size + 1; // includes padding symbol + (0..b) + .map(|p| { + (0..k) + .find(|&a| target_solution[p * k + a] == 1) + .unwrap_or(0) + }) + .collect() + }) } } @@ -154,7 +159,7 @@ 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 9a7b92c44..a45fea85e 100644 --- a/src/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/rules/shortestweightconstrainedpath_ilp.rs @@ -40,23 +40,28 @@ impl ReductionResult for ReductionSWCPToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - (0..self.num_edges) - .map(|edge_idx| { - usize::from( - target_solution - .get(Self::arc_var(edge_idx, 0)) - .copied() - .unwrap_or(0) - > 0 - || target_solution - .get(Self::arc_var(edge_idx, 1)) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + (0..self.num_edges) + .map(|edge_idx| { + usize::from( + target_solution + .get(Self::arc_var(edge_idx, 0)) .copied() .unwrap_or(0) - > 0, - ) - }) - .collect() + > 0 + || target_solution + .get(Self::arc_var(edge_idx, 1)) + .copied() + .unwrap_or(0) + > 0, + ) + }) + .collect() + }) } } diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index 3cf26a8c6..209378a1d 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -22,15 +22,20 @@ impl ReductionResult for ReductionSMCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // For each row r, output the unique zero-based shift g with x_{r,g} = 1 - (0..self.num_rows) - .map(|r| { - (0..self.bound_k) - .find(|&g| target_solution[r * self.bound_k + g] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + // For each row r, output the unique zero-based shift g with x_{r,g} = 1 + (0..self.num_rows) + .map(|r| { + (0..self.bound_k) + .find(|&g| target_solution[r * self.bound_k + g] == 1) + .unwrap_or(0) + }) + .collect() + }) } } @@ -123,7 +128,7 @@ 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 e3ed5a419..c237cf4cc 100644 --- a/src/rules/spinglass_maxcut.rs +++ b/src/rules/spinglass_maxcut.rs @@ -36,8 +36,11 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -112,21 +115,26 @@ where &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - match self.ancilla { - None => target_solution.to_vec(), - Some(anc) => { - // If ancilla is 1, flip all bits; then remove ancilla - let mut sol = target_solution.to_vec(); - if sol[anc] == 1 { - for x in sol.iter_mut() { - *x = 1 - *x; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + match self.ancilla { + None => target_solution.to_vec(), + Some(anc) => { + // If ancilla is 1, flip all bits; then remove ancilla + let mut sol = target_solution.to_vec(); + if sol[anc] == 1 { + for x in sol.iter_mut() { + *x = 1 - *x; + } } + sol.remove(anc); + sol } - sol.remove(anc); - sol } - } + }) } } diff --git a/src/rules/spinglass_qubo.rs b/src/rules/spinglass_qubo.rs index 41a670331..bf29ea5c0 100644 --- a/src/rules/spinglass_qubo.rs +++ b/src/rules/spinglass_qubo.rs @@ -26,8 +26,11 @@ impl ReductionResult for ReductionQUBOToSG { } /// Solution maps directly (same binary encoding). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -101,8 +104,11 @@ impl ReductionResult for ReductionSGToQUBO { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/stackercrane_ilp.rs b/src/rules/stackercrane_ilp.rs index 5dbbd3ad0..3937557fb 100644 --- a/src/rules/stackercrane_ilp.rs +++ b/src/rules/stackercrane_ilp.rs @@ -31,9 +31,14 @@ impl ReductionResult for ReductionSCToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // 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) + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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) + }) } } diff --git a/src/rules/steinertree_ilp.rs b/src/rules/steinertree_ilp.rs index f9c77eb30..496be693a 100644 --- a/src/rules/steinertree_ilp.rs +++ b/src/rules/steinertree_ilp.rs @@ -33,8 +33,11 @@ impl ReductionResult for ReductionSteinerTreeToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/steinertreeingraphs_ilp.rs b/src/rules/steinertreeingraphs_ilp.rs index def751b4b..67219a73a 100644 --- a/src/rules/steinertreeingraphs_ilp.rs +++ b/src/rules/steinertreeingraphs_ilp.rs @@ -33,8 +33,11 @@ impl ReductionResult for ReductionSTIGToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index 13b33de2a..a476702fb 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -54,50 +54,55 @@ impl ReductionResult for ReductionSTSCToILP { } /// Extract operation sequence from ILP solution. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.n; - let k = self.bound; - let noop_code = 2 * n; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.n; + let k = self.bound; + let noop_code = 2 * n; + + if n == 0 { + return Ok(vec![noop_code; k]); + } - if n == 0 { - return vec![noop_code; k]; - } + let nm1 = n.saturating_sub(1); + let mut ops = Vec::with_capacity(k); - let nm1 = n.saturating_sub(1); - let mut ops = Vec::with_capacity(k); - - for t in 1..=k { - // current length at step t-1 - let current_len = (0..n) - .filter(|&p| target_solution[idx_e(n, k, t - 1, p)] == 0) - .count(); - - if target_solution[idx_nu(n, k, t)] == 1 { - ops.push(noop_code); - } else { - let mut found = false; - for j in 0..n { - if target_solution[idx_d(n, k, t, j)] == 1 { - ops.push(j); - found = true; - break; - } - } - if !found { - for j in 0..nm1 { - if target_solution[idx_s(n, k, t, j)] == 1 { - ops.push(current_len + j); + for t in 1..=k { + // current length at step t-1 + let current_len = (0..n) + .filter(|&p| target_solution[idx_e(n, k, t - 1, p)] == 0) + .count(); + + if target_solution[idx_nu(n, k, t)] == 1 { + ops.push(noop_code); + } else { + let mut found = false; + for j in 0..n { + if target_solution[idx_d(n, k, t, j)] == 1 { + ops.push(j); found = true; break; } } if !found { - ops.push(noop_code); + for j in 0..nm1 { + if target_solution[idx_s(n, k, t, j)] == 1 { + ops.push(current_len + j); + found = true; + break; + } + } + if !found { + ops.push(noop_code); + } } } } - } - ops + ops + }) } } @@ -391,7 +396,7 @@ 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 3e95e7b63..81727c373 100644 --- a/src/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/rules/strongconnectivityaugmentation_ilp.rs @@ -23,8 +23,11 @@ impl ReductionResult for ReductionSCAToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..self.num_candidates].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..self.num_candidates].to_vec()) } } @@ -194,7 +197,7 @@ 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 6868197ea..5839bae85 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -34,15 +34,20 @@ impl ReductionResult for ReductionSubIsoToILP { } /// Extract: for each pattern vertex v, output the unique host vertex u with x_{v,u} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n_host = self.num_host_vertices; - (0..self.num_pattern_vertices) - .map(|v| { - (0..n_host) - .find(|&u| target_solution[v * n_host + u] == 1) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n_host = self.num_host_vertices; + (0..self.num_pattern_vertices) + .map(|v| { + (0..n_host) + .find(|&u| target_solution[v * n_host + u] == 1) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/subsetsum_closestvectorproblem.rs b/src/rules/subsetsum_closestvectorproblem.rs index 2d8b9994a..0799edee4 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -21,8 +21,11 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/subsetsum_integerexpressionmembership.rs b/src/rules/subsetsum_integerexpressionmembership.rs index dd3bef7d3..5244b4af1 100644 --- a/src/rules/subsetsum_integerexpressionmembership.rs +++ b/src/rules/subsetsum_integerexpressionmembership.rs @@ -17,10 +17,15 @@ impl ReductionResult for ReductionSubsetSumToIntegerExpressionMembership { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // 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. - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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. + target_solution.to_vec() + }) } } diff --git a/src/rules/subsetsum_integerknapsack.rs b/src/rules/subsetsum_integerknapsack.rs index c79e2e976..e0fb8bf1c 100644 --- a/src/rules/subsetsum_integerknapsack.rs +++ b/src/rules/subsetsum_integerknapsack.rs @@ -10,7 +10,7 @@ use crate::expr::Expr; use crate::models::misc::SubsetSum; use crate::models::set::IntegerKnapsack; -use crate::rules::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; +use crate::rules::{ReductionEntry, ReductionOverhead}; use crate::traits::Problem; use crate::types::ProblemSize; use num_bigint::BigUint; @@ -63,7 +63,7 @@ inventory::submit! { module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::none(), + turing: false, overhead_eval_fn: subset_sum_to_integer_knapsack_overhead, source_size_fn: subset_sum_source_size, } diff --git a/src/rules/subsetsum_partition.rs b/src/rules/subsetsum_partition.rs index 4bb333f87..baf58dbb3 100644 --- a/src/rules/subsetsum_partition.rs +++ b/src/rules/subsetsum_partition.rs @@ -30,26 +30,31 @@ impl ReductionResult for ReductionSubsetSumToPartition { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let source_bits = &target_solution[..self.source_len]; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let source_bits = &target_solution[..self.source_len]; - match self.padding_relation { - PaddingRelation::None => source_bits.to_vec(), - PaddingRelation::SameSide => { - let padding_is_selected = target_solution[self.source_len] == 1; - source_bits - .iter() - .map(|&bit| if padding_is_selected { bit } else { 1 - bit }) - .collect() + match self.padding_relation { + PaddingRelation::None => source_bits.to_vec(), + PaddingRelation::SameSide => { + let padding_is_selected = target_solution[self.source_len] == 1; + source_bits + .iter() + .map(|&bit| if padding_is_selected { bit } else { 1 - bit }) + .collect() + } + PaddingRelation::OppositeSide => { + let padding_is_selected = target_solution[self.source_len] == 1; + source_bits + .iter() + .map(|&bit| if padding_is_selected { 1 - bit } else { bit }) + .collect() + } } - PaddingRelation::OppositeSide => { - let padding_is_selected = target_solution[self.source_len] == 1; - source_bits - .iter() - .map(|&bit| if padding_is_selected { 1 - bit } else { bit }) - .collect() - } - } + }) } } diff --git a/src/rules/sumofsquarespartition_ilp.rs b/src/rules/sumofsquarespartition_ilp.rs index de6b8e02a..48f47f8f1 100644 --- a/src/rules/sumofsquarespartition_ilp.rs +++ b/src/rules/sumofsquarespartition_ilp.rs @@ -56,18 +56,23 @@ impl ReductionResult for ReductionSSPToILP { } /// Extract solution: for each element i, find the unique group g where x_{i,g} = 1. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let num_groups = self.num_groups; - (0..self.num_elements) - .map(|i| { - (0..num_groups) - .find(|&g| { - let idx = i * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let num_groups = self.num_groups; + (0..self.num_elements) + .map(|i| { + (0..num_groups) + .find(|&g| { + let idx = i * num_groups + g; + idx < target_solution.len() && target_solution[idx] == 1 + }) + .unwrap_or(0) + }) + .collect() + }) } } diff --git a/src/rules/test_helpers.rs b/src/rules/test_helpers.rs index 9fb71e316..ef7e066bc 100644 --- a/src/rules/test_helpers.rs +++ b/src/rules/test_helpers.rs @@ -104,7 +104,7 @@ pub(crate) fn assert_optimization_round_trip_from_optimization_target( verify_optimization_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "optimal", context, ); @@ -125,7 +125,7 @@ pub(crate) fn assert_optimization_round_trip_from_satisfaction_target( verify_optimization_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "satisfying", context, ); @@ -145,7 +145,7 @@ pub(crate) fn assert_optimization_round_trip_chain( verify_optimization_round_trip( source, target_solutions, - |target_solution| chain.extract_solution(target_solution), + |target_solution| chain.extract_solution(target_solution).unwrap(), "optimal", context, ); @@ -166,7 +166,7 @@ pub(crate) fn assert_satisfaction_round_trip_from_optimization_target( verify_satisfaction_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "optimal", context, ); @@ -187,7 +187,7 @@ pub(crate) fn assert_satisfaction_round_trip_from_satisfaction_target( verify_satisfaction_round_trip( source, target_solutions, - |target_solution| reduction.extract_solution(target_solution), + |target_solution| reduction.extract_solution(target_solution).unwrap(), "satisfying", context, ); @@ -206,7 +206,7 @@ where let ilp_solution = ILPSolver::new() .solve_dyn(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), bf_value); } @@ -293,8 +293,11 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -310,8 +313,11 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -327,8 +333,11 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -344,8 +353,11 @@ mod tests { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_ilp.rs b/src/rules/threedimensionalmatching_ilp.rs index 0310343e5..444838dc7 100644 --- a/src/rules/threedimensionalmatching_ilp.rs +++ b/src/rules/threedimensionalmatching_ilp.rs @@ -18,8 +18,11 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToILP { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index a4081f346..7a47d727f 100644 --- a/src/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -51,12 +51,17 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin /// which decodes to `S = ∅`. `ThreeDimensionalMatching::evaluate(∅)` /// then yields `Or(true)` iff `q == 0` (the correct answer for both /// sentinel sub-cases). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - if target_solution.len() == self.source_num_triples { - target_solution.to_vec() - } else { - vec![0; self.source_num_triples] - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + if target_solution.len() == self.source_num_triples { + target_solution.to_vec() + } else { + vec![0; self.source_num_triples] + } + }) } } diff --git a/src/rules/threedimensionalmatching_threematroidintersection.rs b/src/rules/threedimensionalmatching_threematroidintersection.rs index 2fcc9db0b..4a6438dc0 100644 --- a/src/rules/threedimensionalmatching_threematroidintersection.rs +++ b/src/rules/threedimensionalmatching_threematroidintersection.rs @@ -20,8 +20,11 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreeMatroidIntersec /// Each target ground-set element is exactly one source triple, so the /// witness vector is preserved unchanged. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index 4a43698b5..b3ff5f9a2 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -294,75 +294,80 @@ 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(&self, target_solution: &[usize]) -> Vec { - 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); - } + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + 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(); + 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 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)) + 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, } - TargetElement::Filler => has_filler = true, } - } - if has_filler || regulars.len() != 2 { - continue; - } + if has_filler || regulars.len() != 2 { + continue; + } - let Some((pair_index, kind)) = pairing else { - continue; - }; + let Some((pair_index, kind)) = pairing else { + continue; + }; - 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(); + 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(); - match kind { - PairingKind::U => { - if regular_pair == [pair_key.0, pair_key.1] { - usage.saw_u = true; + 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); } - } - PairingKind::UPrime => { - usage.uprime_regulars = Some(regular_pair); } } - } - let mut source_solution = vec![0; self.num_source_triples]; + let mut source_solution = vec![0; 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; - } + 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; - } + 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] = 1; + if let Some(source_triple) = self.decode_real_group(group) { + source_solution[source_triple] = 1; + } } - } - source_solution + source_solution + }) } } diff --git a/src/rules/threepartition_resourceconstrainedscheduling.rs b/src/rules/threepartition_resourceconstrainedscheduling.rs index 61895a45b..7cf07c2d5 100644 --- a/src/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/rules/threepartition_resourceconstrainedscheduling.rs @@ -38,8 +38,11 @@ impl ReductionResult for ReductionThreePartitionToRCS { /// Solution extraction: identity mapping. /// ThreePartition config (group index 0..m-1) maps directly to time slot assignment. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index 976c6ee5d..39e9227c5 100644 --- a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -48,30 +48,35 @@ impl ReductionResult for ReductionThreePartitionToSRTD { /// Decode the Lehmer code to a task permutation, simulate the schedule to /// find each task's start time, then assign each element task to its slot /// based on start_time / (B + 1). - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.target.num_tasks(); - // Decode Lehmer code to permutation - let schedule = crate::models::misc::decode_lehmer(target_solution, n) - .expect("target_solution must be a valid Lehmer code"); - - // Simulate the schedule to find start times - let mut current_time: u64 = 0; - let mut slot_assignment = vec![0usize; self.num_element_tasks]; - let slot_width = self.bound + 1; // B + 1 (slot width including the filler gap) - - for &task in &schedule { - let start = current_time.max(self.target.release_times()[task]); - let finish = start + self.target.lengths()[task]; - current_time = finish; - - // Only element tasks (indices 0..3m) contribute to the partition - if task < self.num_element_tasks { - let slot = (start / slot_width) as usize; - slot_assignment[task] = slot; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.target.num_tasks(); + // Decode Lehmer code to permutation + let schedule = crate::models::misc::decode_lehmer(target_solution, n) + .expect("target_solution must be a valid Lehmer code"); + + // Simulate the schedule to find start times + let mut current_time: u64 = 0; + let mut slot_assignment = vec![0usize; self.num_element_tasks]; + let slot_width = self.bound + 1; // B + 1 (slot width including the filler gap) + + for &task in &schedule { + let start = current_time.max(self.target.release_times()[task]); + let finish = start + self.target.lengths()[task]; + current_time = finish; + + // Only element tasks (indices 0..3m) contribute to the partition + if task < self.num_element_tasks { + let slot = (start / slot_width) as usize; + slot_assignment[task] = slot; + } } - } - slot_assignment + slot_assignment + }) } } diff --git a/src/rules/timetabledesign_ilp.rs b/src/rules/timetabledesign_ilp.rs index f235918e5..db2882ef4 100644 --- a/src/rules/timetabledesign_ilp.rs +++ b/src/rules/timetabledesign_ilp.rs @@ -28,8 +28,11 @@ impl ReductionResult for ReductionTDToILP { /// Extract: direct identity mapping — the ILP variable layout matches the /// source configuration layout exactly. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } diff --git a/src/rules/traits.rs b/src/rules/traits.rs index a46dc19ec..f6403f5e3 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -6,6 +6,38 @@ use serde::Serialize; use std::any::Any; use std::marker::PhantomData; +/// Failure to map a target witness back into the source configuration space. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ExtractionError { + #[error("{0}")] + InvalidTargetSolution(String), + #[error("{source_problem} -> {target_problem}: {message}")] + Reduction { + source_problem: &'static str, + target_problem: &'static str, + message: String, + }, +} + +impl ExtractionError { + pub fn invalid(message: impl Into) -> Self { + Self::InvalidTargetSolution(message.into()) + } + + fn for_reduction(self) -> Self { + match self { + Self::InvalidTargetSolution(message) => Self::Reduction { + source_problem: S::NAME, + target_problem: T::NAME, + message, + }, + error => error, + } + } +} + +pub type ExtractionResult = std::result::Result; + /// Result of reducing a source problem to a target problem. /// /// This trait encapsulates the target problem and provides methods @@ -26,7 +58,7 @@ pub trait ReductionResult { /// /// # Returns /// The corresponding solution in the source problem space - fn extract_solution(&self, target_solution: &[usize]) -> Vec; + fn extract_solution(&self, target_solution: &[usize]) -> ExtractionResult>; } /// Trait for problems that can be reduced to target type T. @@ -124,8 +156,8 @@ impl ReductionResult for ReductionAutoCast { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution(&self, target_solution: &[usize]) -> ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -152,7 +184,7 @@ 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: &[usize]) -> Vec; + fn extract_solution_dyn(&self, target_solution: &[usize]) -> ExtractionResult>; } impl DynReductionResult for R @@ -162,8 +194,9 @@ where fn target_problem_any(&self) -> &dyn Any { self.target_problem() as &dyn Any } - fn extract_solution_dyn(&self, target_solution: &[usize]) -> Vec { + fn extract_solution_dyn(&self, target_solution: &[usize]) -> ExtractionResult> { self.extract_solution(target_solution) + .map_err(|error| error.for_reduction::()) } } diff --git a/src/rules/travelingsalesman_ilp.rs b/src/rules/travelingsalesman_ilp.rs index e84d9d1f9..022b946f6 100644 --- a/src/rules/travelingsalesman_ilp.rs +++ b/src/rules/travelingsalesman_ilp.rs @@ -38,35 +38,40 @@ impl ReductionResult for ReductionTSPToILP { /// Extract solution: read tour permutation from x variables, /// then map to edge selection for the source problem. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - - // Read tour: for each position k, find vertex v with x_{v,k} = 1 - let mut tour = vec![0usize; n]; - for k in 0..n { - for v in 0..n { - if target_solution[self.x_index(v, k)] == 1 { - tour[k] = v; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + + // Read tour: for each position k, find vertex v with x_{v,k} = 1 + let mut tour = vec![0usize; n]; + for k in 0..n { + for v in 0..n { + if target_solution[self.x_index(v, k)] == 1 { + tour[k] = v; + break; + } } } - } - // Map tour to edge selection - let mut edge_selection = vec![0usize; self.source_edges.len()]; - for k in 0..n { - let u = tour[k]; - let v = tour[(k + 1) % n]; - // Find the edge index for (u, v) or (v, u) - for (idx, &(a, b)) in self.source_edges.iter().enumerate() { - if (a == u && b == v) || (a == v && b == u) { - edge_selection[idx] = 1; - break; + // Map tour to edge selection + let mut edge_selection = vec![0usize; self.source_edges.len()]; + for k in 0..n { + let u = tour[k]; + let v = tour[(k + 1) % n]; + // Find the edge index for (u, v) or (v, u) + for (idx, &(a, b)) in self.source_edges.iter().enumerate() { + if (a == u && b == v) || (a == v && b == u) { + edge_selection[idx] = 1; + break; + } } } - } - edge_selection + edge_selection + }) } } diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index 89b0312ac..d61795290 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -34,32 +34,37 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { /// /// 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(&self, target_solution: &[usize]) -> Vec { - let n = self.num_vertices; - - // For each position p, find the vertex v where x_{v,p} == 1 - let mut tour = vec![0usize; n]; - for p in 0..n { - for v in 0..n { - if target_solution[v * n + p] == 1 { - tour[p] = v; - break; + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let n = self.num_vertices; + + // For each position p, find the vertex v where x_{v,p} == 1 + let mut tour = vec![0usize; n]; + for p in 0..n { + for v in 0..n { + if target_solution[v * n + p] == 1 { + tour[p] = v; + break; + } } } - } - // Build edge-based config: for each consecutive pair in the tour, mark the edge - let mut config = vec![0usize; 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)); - if let Some(&idx) = self.edge_index.get(&key) { - config[idx] = 1; + // Build edge-based config: for each consecutive pair in the tour, mark the edge + let mut config = vec![0usize; 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)); + if let Some(&idx) = self.edge_index.get(&key) { + config[idx] = 1; + } } - } - config + config + }) } } diff --git a/src/rules/undirectedflowlowerbounds_ilp.rs b/src/rules/undirectedflowlowerbounds_ilp.rs index 7c666abca..00b9afe3b 100644 --- a/src/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/rules/undirectedflowlowerbounds_ilp.rs @@ -54,12 +54,17 @@ 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(&self, target_solution: &[usize]) -> Vec { - let e = self.num_edges; - target_solution[2 * e..3 * e] - .iter() - .map(|&z| 1 - z) - .collect() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok({ + let e = self.num_edges; + target_solution[2 * e..3 * e] + .iter() + .map(|&z| 1 - z) + .collect() + }) } } diff --git a/src/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/rules/undirectedtwocommodityintegralflow_ilp.rs index c5db4afa7..2521dcd13 100644 --- a/src/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -51,8 +51,11 @@ impl ReductionResult for ReductionU2CIFToILP { } /// Extract flow solution: first 4*|E| variables are the flow values. - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution[..4 * self.num_edges].to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution[..4 * self.num_edges].to_vec()) } } @@ -234,7 +237,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>( source, SolutionPair { diff --git a/src/solvers/ilp/solver.rs b/src/solvers/ilp/solver.rs index 81eacc124..778019f57 100644 --- a/src/solvers/ilp/solver.rs +++ b/src/solvers/ilp/solver.rs @@ -30,6 +30,9 @@ pub enum ILPSolveError { /// Type-erased dispatch received a value other than a supported ILP variant. #[error("the ILP backend supports only ILP and ILP")] UnsupportedProblemType, + /// A target witness could not be mapped back to the source problem. + #[error(transparent)] + Extraction(#[from] crate::rules::ExtractionError), } fn classify_backend_error(error: ResolutionError, time_limit: Option) -> ILPSolveError { @@ -241,7 +244,7 @@ impl ILPSolver { { let reduction = problem.reduce_to(); let ilp_solution = self.solve(reduction.target_problem())?; - Ok(reduction.extract_solution(&ilp_solution)) + Ok(reduction.extract_solution(&ilp_solution)?) } /// Solve a type-erased supported ILP variant directly. diff --git a/src/solvers/registry.rs b/src/solvers/registry.rs index e229700b9..e775f0d0e 100644 --- a/src/solvers/registry.rs +++ b/src/solvers/registry.rs @@ -139,9 +139,11 @@ impl CompiledIlpPipeline { .expect("non-empty fixed pipeline must produce a target") .target_problem_any(); let solution = solver.solve_dyn(target)?; - Ok(reductions.iter().rev().fold(solution, |current, step| { - step.extract_solution_dyn(¤t) - })) + let mut source_solution = solution; + for step in reductions.iter().rev() { + source_solution = step.extract_solution_dyn(&source_solution)?; + } + Ok(source_solution) } } @@ -273,7 +275,7 @@ fn build_registry( for entry in reductions .iter() .copied() - .filter(|entry| entry.capabilities.witness && entry.reduce_fn.is_some()) + .filter(|entry| entry.reduce_fn.is_some()) { reduction_index .entry((edge_key(entry, true), edge_key(entry, false))) diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 84b1c455d..053ec6f23 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -421,7 +421,7 @@ fn canonical_rule_examples_cover_exactly_authored_direct_reductions() { .into_iter() .filter(|entry| entry.source_name != entry.target_name) // Turing (multi-query) edges have no single-shot reduction to demonstrate - .filter(|entry| !entry.capabilities.turing) + .filter(|entry| !entry.turing) .map(|entry| { ( ProblemRef { @@ -688,7 +688,7 @@ fn rule_specs_solution_pairs_are_consistent() { // Round-trip: extract_solution(target_config) must produce a valid // source config with the same evaluation value (witness paths only) if let Some(ref chain) = chain { - let extracted = chain.extract_solution(&pair.target_config); + let extracted = chain.extract_solution(&pair.target_config).unwrap(); let extracted_val = source.evaluate_json(&extracted); assert_eq!( extracted_val, source_val, diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 6569f08c6..922a01540 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -179,6 +179,29 @@ fn natural_edge_supports_both_modes_public_api() { .is_some()); } +#[test] +fn value_changing_variant_cast_is_not_aggregate_capable() { + use crate::models::set::MaximumSetPacking; + + let graph = ReductionGraph::new(); + let src = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); + let dst = ReductionGraph::variant_to_map(&MaximumSetPacking::::variant()); + + assert!(graph + .find_cheapest_path_mode( + "MaximumSetPacking", + &src, + "MaximumSetPacking", + &dst, + ReductionMode::Aggregate, + &ProblemSize::new(vec![]), + &MinimizeSteps, + crate::rules::SearchMode::Exact, + ) + .value + .is_none()); +} + #[test] fn test_problem_size_propagation() { let graph = ReductionGraph::new(); @@ -1019,15 +1042,24 @@ fn test_find_paths_bounded_limits_depth() { #[test] fn test_find_paths_bounded_returns_shortest_when_truncated() { use crate::expr::Expr; - use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; + use crate::rules::registry::ReductionOverhead; use crate::rules::ReductionEdgeData; fn edge() -> ReductionEdgeData { + fn reduce(_source: &dyn std::any::Any) -> Box { + Box::new(crate::rules::ReductionAutoCast::< + crate::models::formula::Satisfiability, + crate::models::formula::Satisfiability, + >::new( + crate::models::formula::Satisfiability::new(0, vec![]) + )) + } + ReductionEdgeData { overhead: ReductionOverhead::new(vec![("n", Expr::Var("n"))]), - reduce_fn: None, + reduce_fn: Some(reduce), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, } } diff --git a/src/unit_tests/rules/acyclicpartition_ilp.rs b/src/unit_tests/rules/acyclicpartition_ilp.rs index de050bc5f..2f514fd1f 100644 --- a/src/unit_tests/rules/acyclicpartition_ilp.rs +++ b/src/unit_tests/rules/acyclicpartition_ilp.rs @@ -31,7 +31,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -55,7 +55,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs index ffb36daf3..b6c29be62 100644 --- a/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/unit_tests/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -54,7 +54,7 @@ fn test_extract_solution_identity() { let source = small_instance(); let reduction: ReductionBCBSToILP = ReduceTo::>::reduce_to(&source); let target_sol = vec![1, 1, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&target_sol); + let extracted = reduction.extract_solution(&target_sol).unwrap(); assert_eq!(extracted, vec![1, 1, 0, 1, 1, 0]); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/bicliquecover_bmf.rs b/src/unit_tests/rules/bicliquecover_bmf.rs index 30c912988..baf6713c2 100644 --- a/src/unit_tests/rules/bicliquecover_bmf.rs +++ b/src/unit_tests/rules/bicliquecover_bmf.rs @@ -49,7 +49,7 @@ fn test_bicliquecover_to_bmf_closed_loop_full_biclique() { let target_witness = BruteForce::new() .find_witness(target) .expect("target must be feasible"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); } @@ -64,7 +64,7 @@ fn test_bicliquecover_to_bmf_closed_loop_identity_rank2() { let target_witness = BruteForce::new() .find_witness(target) .expect("target must be feasible"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); } diff --git a/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs index fa21abed9..7edabfdaf 100644 --- a/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/biconnectivityaugmentation_ilp.rs @@ -29,7 +29,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -44,7 +44,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).0); } @@ -56,7 +56,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } @@ -74,7 +74,7 @@ fn test_already_biconnected() { let ilp_sol = solver .solve(ilp) .expect("already biconnected should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/binpacking_ilp.rs b/src/unit_tests/rules/binpacking_ilp.rs index 0573c82d9..e28c85335 100644 --- a/src/unit_tests/rules/binpacking_ilp.rs +++ b/src/unit_tests/rules/binpacking_ilp.rs @@ -34,7 +34,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(2))); @@ -52,7 +52,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -67,7 +67,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); @@ -82,7 +82,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -103,7 +103,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert!(problem.evaluate(&extracted).is_valid()); } diff --git a/src/unit_tests/rules/bmf_bicliquecover.rs b/src/unit_tests/rules/bmf_bicliquecover.rs index 216b79be6..cff85f3f8 100644 --- a/src/unit_tests/rules/bmf_bicliquecover.rs +++ b/src/unit_tests/rules/bmf_bicliquecover.rs @@ -29,7 +29,7 @@ fn test_bmf_to_bicliquecover_closed_loop_all_ones() { let target_witness = BruteForce::new() .find_witness(target) .expect("target has feasible biclique cover"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); assert!(problem.is_exact(&extracted)); @@ -46,7 +46,7 @@ fn test_bmf_to_bicliquecover_closed_loop_identity() { let target_witness = BruteForce::new() .find_witness(target) .expect("target has feasible biclique cover"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_source); assert!(problem.is_exact(&extracted)); diff --git a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs index 03aee897a..8aa9b35a4 100644 --- a/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/unit_tests/rules/bottlenecktravelingsalesman_ilp.rs @@ -32,7 +32,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( @@ -61,7 +61,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.is_valid()); @@ -76,7 +76,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let metric = problem.evaluate(&extracted); assert!(metric.is_valid()); } diff --git a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs index 19f94f6bf..6ba819a21 100644 --- a/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/unit_tests/rules/boundedcomponentspanningforest_ilp.rs @@ -30,7 +30,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -45,7 +45,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).0); } @@ -65,7 +65,7 @@ fn test_single_component() { let ilp_sol = solver .solve(ilp) .expect("single component should be solvable"); - let extracted = reduction.extract_solution(&ilp_sol); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/capacityassignment_ilp.rs b/src/unit_tests/rules/capacityassignment_ilp.rs index a5efbb8bc..be844d901 100644 --- a/src/unit_tests/rules/capacityassignment_ilp.rs +++ b/src/unit_tests/rules/capacityassignment_ilp.rs @@ -57,7 +57,7 @@ fn test_capacityassignment_to_ilp_closed_loop() { let reduction: ReductionCAToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!( ilp_value, bf_value, @@ -79,7 +79,7 @@ fn test_solution_extraction() { // 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); + 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); @@ -98,7 +98,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/circuit_ilp.rs b/src/unit_tests/rules/circuit_ilp.rs index 8ff85f961..6ded61762 100644 --- a/src/unit_tests/rules/circuit_ilp.rs +++ b/src/unit_tests/rules/circuit_ilp.rs @@ -119,6 +119,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/circuit_sat.rs b/src/unit_tests/rules/circuit_sat.rs index 0f8cab804..0cc3ae6c4 100644 --- a/src/unit_tests/rules/circuit_sat.rs +++ b/src/unit_tests/rules/circuit_sat.rs @@ -26,7 +26,7 @@ fn test_circuitsat_to_satisfiability_closed_loop() { let target_solution = solve_satisfaction_problem(reduction.target_problem()) .expect("issue example should yield a SAT witness"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), source.num_variables()); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/circuit_spinglass.rs b/src/unit_tests/rules/circuit_spinglass.rs index e648499a3..e4220872a 100644 --- a/src/unit_tests/rules/circuit_spinglass.rs +++ b/src/unit_tests/rules/circuit_spinglass.rs @@ -157,7 +157,7 @@ fn test_constant_true() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s)) + .map(|s| reduction.extract_solution(s).unwrap()) .collect(); // c should be 1 @@ -184,7 +184,7 @@ fn test_constant_false() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s)) + .map(|s| reduction.extract_solution(s).unwrap()) .collect(); // c should be 0 @@ -215,7 +215,7 @@ fn test_multi_input_and() { let extracted: Vec> = solutions .iter() - .map(|s| reduction.extract_solution(s)) + .map(|s| reduction.extract_solution(s).unwrap()) .collect(); // Variables sorted: c, x, y, z diff --git a/src/unit_tests/rules/closeststring_ilp.rs b/src/unit_tests/rules/closeststring_ilp.rs index 693626564..2c01604c7 100644 --- a/src/unit_tests/rules/closeststring_ilp.rs +++ b/src/unit_tests/rules/closeststring_ilp.rs @@ -57,7 +57,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let extracted_value = source.evaluate(&extracted); // The extracted center must be syntactically valid and match the BF optimum. @@ -87,11 +87,26 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(2))); } +#[test] +fn test_closeststring_to_ilp_rejects_missing_one_hot_symbol() { + let source = ClosestString::new(2, vec![vec![0, 1]]); + let reduction = ReduceTo::>::reduce_to(&source); + 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" + ); +} + #[test] fn test_closeststring_to_ilp_ternary_alphabet() { // q = 3, m = 2, three strings forcing a nonzero radius. The optimum @@ -118,7 +133,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 1, 1]); assert_eq!(source.evaluate(&extracted), Min(Some(0))); } diff --git a/src/unit_tests/rules/closestsubstring_ilp.rs b/src/unit_tests/rules/closestsubstring_ilp.rs index 22fc89e24..1bae41878 100644 --- a/src/unit_tests/rules/closestsubstring_ilp.rs +++ b/src/unit_tests/rules/closestsubstring_ilp.rs @@ -70,6 +70,21 @@ fn test_closestsubstring_to_ilp_structure() { } } +#[test] +fn test_closestsubstring_to_ilp_rejects_missing_one_hot_symbol() { + let source = issue_instance(); + let reduction = ReduceTo::>::reduce_to(&source); + 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" + ); +} + #[test] fn test_closestsubstring_to_ilp_closed_loop() { let source = issue_instance(); @@ -79,7 +94,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Extracted config must be syntactically valid (length ell + n = 6) and // match the brute-force optimum. @@ -112,7 +127,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let extracted_value = source.evaluate(&extracted); assert!(extracted_value.is_valid()); @@ -157,7 +172,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0, 0, 1, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(1))); } diff --git a/src/unit_tests/rules/closestvectorproblem_qubo.rs b/src/unit_tests/rules/closestvectorproblem_qubo.rs index 90938593d..2bd20fd6b 100644 --- a/src/unit_tests/rules/closestvectorproblem_qubo.rs +++ b/src/unit_tests/rules/closestvectorproblem_qubo.rs @@ -50,8 +50,14 @@ fn test_closestvectorproblem_to_qubo_example_matrix_coefficients() { fn test_extract_solution_ignores_duplicate_exact_range_encodings() { let reduction = ReduceTo::>::reduce_to(&canonical_cvp()); - assert_eq!(reduction.extract_solution(&[1, 1, 0, 1, 1, 0]), vec![3, 3]); - assert_eq!(reduction.extract_solution(&[0, 0, 1, 0, 0, 1]), vec![3, 3]); + assert_eq!( + reduction.extract_solution(&[1, 1, 0, 1, 1, 0]).unwrap(), + vec![3, 3] + ); + assert_eq!( + reduction.extract_solution(&[0, 0, 1, 0, 0, 1]).unwrap(), + vec![3, 3] + ); } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/rules/clustering_ilp.rs b/src/unit_tests/rules/clustering_ilp.rs index 2da5f263f..9de89081b 100644 --- a/src/unit_tests/rules/clustering_ilp.rs +++ b/src/unit_tests/rules/clustering_ilp.rs @@ -64,7 +64,9 @@ fn test_clustering_to_ilp_solution_extraction() { let problem = canonical_yes_instance(); let reduction: ReductionClusteringToILP = ReduceTo::>::reduce_to(&problem); - let extracted = reduction.extract_solution(&[1, 0, 1, 0, 0, 1, 0, 1]); + let extracted = reduction + .extract_solution(&[1, 0, 1, 0, 0, 1, 0, 1]) + .unwrap(); assert_eq!(extracted, vec![0, 0, 1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/coloring_ilp.rs b/src/unit_tests/rules/coloring_ilp.rs index 41eab4e0a..1e1fd481d 100644 --- a/src/unit_tests/rules/coloring_ilp.rs +++ b/src/unit_tests/rules/coloring_ilp.rs @@ -62,7 +62,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify the extracted solution is valid for the original problem assert!( @@ -87,7 +87,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify validity assert!( @@ -129,7 +129,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 2, 0]); @@ -162,7 +162,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); } @@ -179,7 +179,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); @@ -216,7 +216,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); @@ -252,7 +252,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); } @@ -266,7 +266,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); 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 daa61681a..6e6cf82bc 100644 --- a/src/unit_tests/rules/coloring_qubo.rs +++ b/src/unit_tests/rules/coloring_qubo.rs @@ -15,7 +15,7 @@ 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); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(kc.evaluate(&extracted)); } @@ -34,7 +34,7 @@ fn test_kcoloring_to_qubo_path() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(kc.evaluate(&extracted)); } @@ -54,7 +54,7 @@ fn test_kcoloring_to_qubo_reversed_edges() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(kc.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/consecutiveblockminimization_ilp.rs b/src/unit_tests/rules/consecutiveblockminimization_ilp.rs index 42a2be730..bf4c0d3c9 100644 --- a/src/unit_tests/rules/consecutiveblockminimization_ilp.rs +++ b/src/unit_tests/rules/consecutiveblockminimization_ilp.rs @@ -49,7 +49,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs b/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs index 0c7f7d62b..e9b841c4e 100644 --- a/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/unit_tests/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -31,7 +31,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // Also verify that brute-force on the source agrees @@ -56,7 +56,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs b/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs index 040020993..bbba2c718 100644 --- a/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/unit_tests/rules/consecutiveonessubmatrix_ilp.rs @@ -41,7 +41,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // Verify brute-force on source agrees @@ -70,7 +70,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -83,6 +83,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs index 157a03fa6..185defcf8 100644 --- a/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/unit_tests/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -56,7 +56,7 @@ fn test_cdft_to_ilp_solution_encoding_round_trip() { let problem = small_yes_instance(); let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = reduction.encode_source_solution(&small_yes_witness()); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, small_yes_witness()); } @@ -91,7 +91,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); } @@ -123,7 +123,7 @@ 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); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); assert!( problem.evaluate(&source_solution), "extracted source solution must satisfy the original CDFT instance" @@ -135,6 +135,6 @@ fn test_cdft_to_ilp_issue_instance_encoding_round_trip() { let problem = issue_instance(); let reduction: ReductionCDFTToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = reduction.encode_source_solution(&issue_witness()); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, issue_witness()); } diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs index c6418de96..93d768754 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -58,7 +58,7 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_yes_in for target_solution in target_solutions { assert_eq!(target.evaluate(&target_solution).unwrap(), 4); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -86,7 +86,7 @@ fn test_decisionminimumdominatingset_to_minimumsummulticenter_closed_loop_no_ins assert_eq!(target_value, 6); assert!(target_value > threshold); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Or(false)); } diff --git a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs index f87bf9e65..506524c70 100644 --- a/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/unit_tests/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -58,7 +58,7 @@ fn test_decisionminimumdominatingset_to_minmaxmulticenter_closed_loop() { ); for target_solution in target_solutions { - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index db7bf2c64..db7b3a7bc 100644 --- a/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/unit_tests/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -42,7 +42,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_closed_loop() { assert!(reduction.target_problem().evaluate(&target_witness).0); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(extracted, cover); assert!(source.evaluate(&extracted).0); } @@ -55,7 +55,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_ignores_isolated_vertic let target_witness = reduction.build_target_witness(&[1, 0, 0]); assert!(reduction.target_problem().evaluate(&target_witness).0); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(extracted.len(), 3); assert_eq!(extracted[2], 0); assert!(source.evaluate(&extracted).0); @@ -74,7 +74,7 @@ fn test_decisionminimumvertexcover_to_hamiltoniancircuit_fixed_yes_when_k_covers let witness = BruteForce::new() .find_witness(target) .expect("triangle should have a Hamiltonian circuit"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs index ac027fb8c..1dedb771b 100644 --- a/src/unit_tests/rules/directedhamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/directedhamiltonianpath_ilp.rs @@ -38,7 +38,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -71,7 +71,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), diff --git a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs index ec3d8e3eb..2f012e531 100644 --- a/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/directedtwocommodityintegralflow_ilp.rs @@ -81,7 +81,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -124,7 +124,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 16); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/eulerianpath_ilp.rs b/src/unit_tests/rules/eulerianpath_ilp.rs index 1c8b3fd57..f42e207f7 100644 --- a/src/unit_tests/rules/eulerianpath_ilp.rs +++ b/src/unit_tests/rules/eulerianpath_ilp.rs @@ -52,7 +52,7 @@ 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); + let extracted = reduction.extract_solution(&solution).unwrap(); assert_eq!(extracted.len(), 0); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -66,7 +66,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), source.num_arcs()); assert!( @@ -104,7 +104,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); 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 8c0d53d7c..4902ec795 100644 --- a/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -44,5 +44,8 @@ fn test_exactcoverby3sets_to_algebraicequationsovergf2_extract_solution_is_ident let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); let reduction = ReduceTo::::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); + assert_eq!( + reduction.extract_solution(&[1, 0, 1]).unwrap(), + vec![1, 0, 1] + ); } diff --git a/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index 241b00945..e25e3854d 100644 --- a/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/unit_tests/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -73,13 +73,13 @@ fn test_exactcoverby3sets_to_boundeddiameterspanningtree_extract_solution() { let mut target_config = vec![0; reduction.target_problem().num_edges()]; target_config[2] = 1; target_config[3] = 1; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 1]); // Only s_0 selected via root edge. let mut target_config = vec![0; reduction.target_problem().num_edges()]; target_config[2] = 1; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0]); } diff --git a/src/unit_tests/rules/exactcoverby3sets_ilp.rs b/src/unit_tests/rules/exactcoverby3sets_ilp.rs index cb33bceb8..c9c4247b6 100644 --- a/src/unit_tests/rules/exactcoverby3sets_ilp.rs +++ b/src/unit_tests/rules/exactcoverby3sets_ilp.rs @@ -27,7 +27,7 @@ fn test_exactcoverby3sets_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -36,7 +36,7 @@ fn test_solution_extraction() { let problem = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5]]); let reduction: ReductionX3CToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = vec![1, 1]; // select both triples - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs index 14c53bb1d..f38e59322 100644 --- a/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/unit_tests/rules/exactcoverby3sets_maximumsetpacking.rs @@ -61,7 +61,7 @@ fn test_exactcoverby3sets_to_maximumsetpacking_unsatisfiable() { assert_eq!(target.evaluate(&best), Max(Some(1))); // q = 2, but packing value is 1 < 2, so no exact cover exists - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } @@ -78,6 +78,6 @@ fn test_exactcoverby3sets_to_maximumsetpacking_optimal_value() { // Maximum packing: S0 + S1 = 2 disjoint sets = q assert_eq!(target.evaluate(&best), Max(Some(2))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs index 074f6ddc8..d37c06594 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumaxiomset.rs @@ -65,7 +65,7 @@ fn test_exactcoverby3sets_to_minimumaxiomset_no_instance_gap() { .expect("expected an optimal target witness"); assert_eq!(target.evaluate(&optimal), Min(Some(3))); - let extracted = reduction.extract_solution(&optimal); + let extracted = reduction.extract_solution(&optimal).unwrap(); assert!(!source.evaluate(&extracted)); } @@ -74,6 +74,8 @@ fn test_extract_solution_reads_only_set_sentence_axioms() { let source = issue_yes_instance(); let reduction = ReduceTo::::reduce_to(&source); - let extracted = reduction.extract_solution(&[1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1]); + let extracted = reduction + .extract_solution(&[1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1]) + .unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1]); } diff --git a/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index a974fbe9c..9cd3098d7 100644 --- a/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/unit_tests/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -76,7 +76,7 @@ fn test_exactcoverby3sets_to_minimumfaultdetectiontestset_no_instance_gap() { .expect("expected an optimal target witness"); assert_eq!(target.evaluate(&best), Min(Some(3))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } @@ -85,6 +85,9 @@ fn test_exactcoverby3sets_to_minimumfaultdetectiontestset_extract_solution_ident let source = issue_yes_instance(); let reduction = ReduceTo::::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[1, 1, 0]), vec![1, 1, 0]); + assert_eq!( + reduction.extract_solution(&[1, 1, 0]).unwrap(), + vec![1, 1, 0] + ); assert!(source.evaluate(&[1, 1, 0]).0); } diff --git a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs index a8e7ed6e3..4265bcb96 100644 --- a/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/unit_tests/rules/exactcoverby3sets_staffscheduling.rs @@ -56,7 +56,7 @@ fn test_exactcoverby3sets_to_staffscheduling_unique_cover() { let solutions = solver.find_all_witnesses(target); // Each satisfying target config should extract to selecting all 3 subsets for sol in &solutions { - let extracted = result.extract_solution(sol); + let extracted = result.extract_solution(sol).unwrap(); assert!( source.evaluate(&extracted).0, "Extracted solution must be valid" @@ -65,7 +65,7 @@ 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)) + .map(|s| result.extract_solution(s).unwrap()) .collect(); assert!( extracted_solutions.iter().all(|s| *s == vec![1, 1, 1]), @@ -81,7 +81,7 @@ 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); + let extracted = result.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 1, 0, 0]); // Verify the extracted solution is valid in the source @@ -89,7 +89,7 @@ fn test_exactcoverby3sets_to_staffscheduling_extract_solution() { // 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); + let extracted_empty = result.extract_solution(&empty_config).unwrap(); assert_eq!(extracted_empty, vec![0, 0, 0, 0]); } diff --git a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs index 6433f7dcd..4aba685b2 100644 --- a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs @@ -36,7 +36,10 @@ fn test_exactcoverby3sets_to_subsetproduct_extract_solution_is_identity() { let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); let reduction = ReduceTo::::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); + assert_eq!( + reduction.extract_solution(&[1, 0, 1]).unwrap(), + vec![1, 0, 1] + ); } #[test] diff --git a/src/unit_tests/rules/expectedretrievalcost_ilp.rs b/src/unit_tests/rules/expectedretrievalcost_ilp.rs index 03fb59663..002ecd21c 100644 --- a/src/unit_tests/rules/expectedretrievalcost_ilp.rs +++ b/src/unit_tests/rules/expectedretrievalcost_ilp.rs @@ -42,7 +42,7 @@ fn test_expectedretrievalcost_to_ilp_bf_vs_ilp() { let bf_cost = problem.expected_cost(&bf_witness).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_cost = problem.expected_cost(&extracted).unwrap(); // ILP cost should match BF optimal cost @@ -70,7 +70,7 @@ fn test_solution_extraction() { // 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1]); } @@ -83,7 +83,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!( matches!(value, Min(Some(_))), diff --git a/src/unit_tests/rules/factoring_circuit.rs b/src/unit_tests/rules/factoring_circuit.rs index 5cea21a14..da1389982 100644 --- a/src/unit_tests/rules/factoring_circuit.rs +++ b/src/unit_tests/rules/factoring_circuit.rs @@ -210,7 +210,7 @@ fn test_extract_solution() { } } - let factoring_sol = reduction.extract_solution(&sol); + let factoring_sol = reduction.extract_solution(&sol).unwrap(); assert_eq!( factoring_sol.len(), 4, diff --git a/src/unit_tests/rules/factoring_ilp.rs b/src/unit_tests/rules/factoring_ilp.rs index f33a717ec..3cffdfdba 100644 --- a/src/unit_tests/rules/factoring_ilp.rs +++ b/src/unit_tests/rules/factoring_ilp.rs @@ -50,7 +50,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify it's a valid factorization assert!(problem.is_valid_factorization(&extracted)); @@ -75,7 +75,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // 5. Verify: solution is valid and p × q = 15 assert!(problem.is_valid_factorization(&extracted)); @@ -92,7 +92,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -109,7 +109,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -126,7 +126,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -143,7 +143,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); @@ -173,7 +173,7 @@ 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); + let ilp_factors = reduction.extract_solution(&ilp_solution).unwrap(); // Get brute force solutions let bf = BruteForce::new(); @@ -207,7 +207,7 @@ fn test_solution_extraction() { // 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Should extract [p0, p1, q0, q1] = [0, 1, 1, 1] assert_eq!(extracted, vec![0, 1, 1, 1]); @@ -239,7 +239,7 @@ fn test_solve_reduced() { 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); + let solution = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&solution)); } @@ -253,7 +253,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_factorization(&extracted)); diff --git a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs index db3a43c5e..611417ca2 100644 --- a/src/unit_tests/rules/feasibleregisterassignment_ilp.rs +++ b/src/unit_tests/rules/feasibleregisterassignment_ilp.rs @@ -27,7 +27,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); let mut sorted = extracted.clone(); diff --git a/src/unit_tests/rules/flowshopscheduling_ilp.rs b/src/unit_tests/rules/flowshopscheduling_ilp.rs index 15dd3b795..3bc70457c 100644 --- a/src/unit_tests/rules/flowshopscheduling_ilp.rs +++ b/src/unit_tests/rules/flowshopscheduling_ilp.rs @@ -19,7 +19,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -46,7 +46,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -62,6 +62,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index 1914b9bd6..a71a7fc2c 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -9,7 +9,7 @@ use crate::models::misc::Knapsack; use crate::models::set::MaximumSetPacking; use crate::rules::cost::{Minimize, MinimizeSteps}; use crate::rules::graph::{classify_problem_category, ReductionMode, ReductionStep}; -use crate::rules::registry::{EdgeCapabilities, ReductionEntry}; +use crate::rules::registry::ReductionEntry; use crate::rules::traits::{AggregateReductionResult, ReductionResult}; use crate::topology::SimpleGraph; use crate::traits::Problem; @@ -165,8 +165,11 @@ impl ReductionResult for SourceToMiddleWitnessResult { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - target_solution.to_vec() + fn extract_solution( + &self, + target_solution: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_solution.to_vec()) } } @@ -281,7 +284,7 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { overhead: crate::rules::registry::ReductionOverhead::default(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); graph.add_edge( @@ -291,7 +294,7 @@ fn test_aggregate_reduction_chain_extracts_value_backwards() { overhead: crate::rules::registry::ReductionOverhead::default(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_middle_to_target_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); @@ -346,7 +349,7 @@ fn witness_path_search_rejects_aggregate_only_edge() { overhead: crate::rules::registry::ReductionOverhead::default(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); @@ -391,7 +394,7 @@ fn aggregate_path_search_rejects_witness_only_edge() { overhead: crate::rules::registry::ReductionOverhead::default(), reduce_fn: Some(reduce_source_to_middle_witness), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, }, ); @@ -424,7 +427,7 @@ fn aggregate_path_search_rejects_witness_only_edge() { } #[test] -fn natural_edge_supports_both_modes() { +fn witness_executor_does_not_imply_aggregate_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( @@ -436,7 +439,7 @@ fn natural_edge_supports_both_modes() { overhead: crate::rules::registry::ReductionOverhead::default(), reduce_fn: Some(reduce_natural_variant_witness), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::both(), + turing: false, }, ); @@ -466,11 +469,7 @@ fn natural_edge_supports_both_modes() { .value; assert!(witness_path.is_some()); - let aggregate_path = aggregate_path.expect("expected aggregate path"); - let chain = graph - .reduce_aggregate_along_path(&aggregate_path, &NaturalVariantProblem as &dyn Any) - .expect("expected aggregate chain"); - assert_eq!(chain.extract_value_dyn(json!(7)), json!(7)); + assert!(aggregate_path.is_none()); } #[test] @@ -485,7 +484,7 @@ fn reduce_aggregate_along_path_rejects_single_step_path() { overhead: crate::rules::registry::ReductionOverhead::default(), reduce_fn: None, reduce_aggregate_fn: Some(reduce_source_to_middle_aggregate), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, }, ); let single_step_path = ReductionPath { @@ -512,7 +511,7 @@ fn reduce_aggregate_returns_none_for_witness_only_edge() { overhead: crate::rules::registry::ReductionOverhead::default(), reduce_fn: Some(reduce_source_to_middle_witness), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, }, ); let path = ReductionPath { @@ -1451,7 +1450,7 @@ fn test_reduction_chain_direct() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); let metric = problem.evaluate(&source_solution); assert!(metric.is_valid()); } @@ -1488,7 +1487,7 @@ fn test_reduction_chain_multi_step() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); let metric = problem.evaluate(&source_solution); assert!(metric.is_valid()); } @@ -1542,7 +1541,7 @@ fn test_reduction_chain_with_variant_casts() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); let metric = mis.evaluate(&source_solution); assert!(metric.is_valid()); @@ -1587,7 +1586,7 @@ fn test_reduction_chain_with_variant_casts() { let target: &MaximumIndependentSet = ksat_chain.target_problem(); let target_solution = solver.find_witness(target).unwrap(); - let original_solution = ksat_chain.extract_solution(&target_solution); + let original_solution = ksat_chain.extract_solution(&target_solution).unwrap(); // Verify the extracted solution satisfies the original 3-SAT formula assert!(ksat.evaluate(&original_solution)); @@ -1705,12 +1704,14 @@ fn test_variant_complexity() { } #[test] -fn test_compute_source_size() { +fn test_compute_source_size_uses_exact_variant_executor() { let problem = MaximumIndependentSet::::new( SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), vec![1, 1, 1, 1], ); - let size = ReductionGraph::compute_source_size("MaximumIndependentSet", &problem); + let variant = + ReductionGraph::variant_to_map(&MaximumIndependentSet::::variant()); + let size = ReductionGraph::compute_source_size("MaximumIndependentSet", &variant, &problem); assert_eq!(size.get("num_vertices"), Some(4)); assert_eq!(size.get("num_edges"), Some(3)); } @@ -1718,7 +1719,8 @@ fn test_compute_source_size() { #[test] fn test_compute_source_size_unknown_problem() { let problem = 42u32; - let size = ReductionGraph::compute_source_size("NonExistentProblem", &problem); + let size = + ReductionGraph::compute_source_size("NonExistentProblem", &BTreeMap::new(), &problem); assert!(size.components.is_empty()); } diff --git a/src/unit_tests/rules/graphpartitioning_ilp.rs b/src/unit_tests/rules/graphpartitioning_ilp.rs index cf27d091d..a302ec545 100644 --- a/src/unit_tests/rules/graphpartitioning_ilp.rs +++ b/src/unit_tests/rules/graphpartitioning_ilp.rs @@ -87,7 +87,7 @@ fn test_graphpartitioning_to_ilp_closed_loop() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(3))); @@ -116,7 +116,7 @@ fn test_solution_extraction() { let reduction: ReductionGraphPartitioningToILP = ReduceTo::>::reduce_to(&problem); 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); diff --git a/src/unit_tests/rules/graphpartitioning_maxcut.rs b/src/unit_tests/rules/graphpartitioning_maxcut.rs index ca9acf458..2018cf4a6 100644 --- a/src/unit_tests/rules/graphpartitioning_maxcut.rs +++ b/src/unit_tests/rules/graphpartitioning_maxcut.rs @@ -53,7 +53,7 @@ fn test_graphpartitioning_to_maxcut_extract_solution_identity() { let target_solution = super::ISSUE_EXAMPLE_WITNESS.to_vec(); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), target_solution ); } diff --git a/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index b5ba18142..222e23aa7 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -63,7 +63,7 @@ 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![1, 0, 1, 1, 0, 1]; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted.len(), 4); assert!( diff --git a/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index be81f53d8..0f2367358 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -73,7 +73,7 @@ fn test_hamiltoniancircuit_to_bottlenecktravelingsalesman_extract_solution_cycle .map(|(u, v)| usize::from(cycle_edges.contains(&(u, v)) || cycle_edges.contains(&(v, u)))) .collect(); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // Bottleneck should be 1 (all selected edges are original cycle edges) assert_eq!(target.evaluate(&target_solution), Min(Some(1))); diff --git a/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs b/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs index e9b9ca02a..40a6faaf5 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -57,7 +57,7 @@ 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); + let extracted = reduction.extract_solution(&hp_config).unwrap(); assert_eq!(extracted.len(), 4); assert!( @@ -73,7 +73,7 @@ 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); + let extracted = reduction.extract_solution(&hp_config).unwrap(); 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 821d9c4bb..ee45e6ee9 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_longestcircuit.rs @@ -70,7 +70,7 @@ fn test_hamiltoniancircuit_to_longestcircuit_extract_solution() { // All edges selected forms a Hamiltonian circuit on the cycle graph let target_solution = vec![1, 1, 1, 1]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(target.evaluate(&target_solution), Max(Some(4))); assert_eq!(extracted.len(), 4); diff --git a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs index 97c0ddc52..d4cd9f6c2 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_quadraticassignment.rs @@ -103,7 +103,7 @@ fn test_hamiltoniancircuit_to_quadraticassignment_extract_solution() { // 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); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!( source.evaluate(&extracted).0, @@ -137,8 +137,8 @@ fn test_prism_graph_hc_via_qap_ilp_roundtrip() { let ilp_sol = ILPSolver::new() .solve(r2.target_problem()) .expect("ILP should be feasible"); - let qap_sol = r2.extract_solution(&ilp_sol); - let hc_sol = r1.extract_solution(&qap_sol); + let qap_sol = r2.extract_solution(&ilp_sol).unwrap(); + let hc_sol = r1.extract_solution(&qap_sol).unwrap(); assert!( hc.evaluate(&hc_sol).0, diff --git a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs index d65a02617..f6be3b713 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_ruralpostman.rs @@ -127,7 +127,7 @@ fn test_hamiltoniancircuit_to_ruralpostman_extract_solution() { .find_witness(target) .expect("should find a solution"); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert_eq!( extracted.len(), 3, diff --git a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs index 264cbbb3e..924ca6b46 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_stackercrane.rs @@ -92,7 +92,7 @@ fn test_hamiltoniancircuit_to_stackercrane_extract_solution() { // 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); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!( source.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index 77d41ac83..6bed4e9fc 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -86,7 +86,7 @@ fn test_hamiltoniancircuit_to_strongconnectivityaugmentation_extract_solution() assert!(target.is_valid_solution(&target_config)); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted.len(), 4); assert!( source.evaluate(&extracted).is_valid(), diff --git a/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs b/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs index de6300cbc..626d15531 100644 --- a/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/unit_tests/rules/hamiltoniancircuit_travelingsalesman.rs @@ -65,7 +65,7 @@ fn test_hamiltoniancircuit_to_travelingsalesman_extract_solution_cycle() { .map(|(u, v)| usize::from(cycle_edges.contains(&(u, v)) || cycle_edges.contains(&(v, u)))) .collect(); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(target.evaluate(&target_solution), Min(Some(4))); assert_eq!(extracted.len(), 4); diff --git a/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index 610a9b1d8..bb8527f4e 100644 --- a/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -51,7 +51,7 @@ fn test_hamiltonianpath_to_degreeconstrainedspanningtree_extract_solution_recons &[(0, 1), (1, 2), (2, 3)], ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 2, 3]); assert!(source.evaluate(&extracted)); diff --git a/src/unit_tests/rules/hamiltonianpath_ilp.rs b/src/unit_tests/rules/hamiltonianpath_ilp.rs index 03fd75d18..44d4628a7 100644 --- a/src/unit_tests/rules/hamiltonianpath_ilp.rs +++ b/src/unit_tests/rules/hamiltonianpath_ilp.rs @@ -33,7 +33,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -58,7 +58,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -90,6 +90,6 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs index 77ec0ab99..02381d6f1 100644 --- a/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -83,7 +83,7 @@ fn test_hamiltonianpath_to_isomorphicspanningtree_complete_graph() { let target_solution = solve_satisfaction_problem(result.target_problem()) .expect("K4 should have an IST solution"); - let extracted = result.extract_solution(&target_solution); + let extracted = result.extract_solution(&target_solution).unwrap(); // Extracted solution should be a valid Hamiltonian path assert!( source.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs index c81bb7a5b..e6e906dbc 100644 --- a/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs +++ b/src/unit_tests/rules/highlyconnecteddeletion_ilp.rs @@ -76,7 +76,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // Edges in input order: (0,1), (0,2), (1,2) all inside the triangle (kept); // (2,3) crosses clusters and is deleted. @@ -85,6 +85,21 @@ fn test_highlyconnecteddeletion_to_ilp_extract_solution_decode() { assert!(source.is_valid_solution(&extracted)); } +#[test] +fn test_highlyconnecteddeletion_to_ilp_rejects_unassigned_vertex() { + let source = issue_instance(); + let reduction = ReduceTo::>::reduce_to(&source); + 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" + ); +} + #[test] fn test_highlyconnecteddeletion_to_ilp_disconnected_no_cluster() { // Two disjoint K3's stitched by a single bridge edge. The bridge is the diff --git a/src/unit_tests/rules/ilp_bool_ilp_i32.rs b/src/unit_tests/rules/ilp_bool_ilp_i32.rs index 1e824f1fa..e4ee38ebd 100644 --- a/src/unit_tests/rules/ilp_bool_ilp_i32.rs +++ b/src/unit_tests/rules/ilp_bool_ilp_i32.rs @@ -34,7 +34,7 @@ fn test_ilp_bool_to_ilp_i32_closed_loop() { assert_eq!(target.dims(), vec![(i32::MAX as usize) + 1; 3]); // Extract solution back to source and verify optimality - let source_solution = result.extract_solution(&source_best); + let source_solution = result.extract_solution(&source_best).unwrap(); assert_eq!(source.evaluate(&source_solution), source_obj); } diff --git a/src/unit_tests/rules/ilp_i32_ilp_bool.rs b/src/unit_tests/rules/ilp_i32_ilp_bool.rs index d18367f03..7a8dbae93 100644 --- a/src/unit_tests/rules/ilp_i32_ilp_bool.rs +++ b/src/unit_tests/rules/ilp_i32_ilp_bool.rs @@ -10,7 +10,7 @@ fn solve_via_bool(source: &ILP) -> Option<(Vec, f64)> { let target = reduction.target_problem(); let solver = BruteForce::new(); let witness = solver.find_witness(target)?; - let source_config = reduction.extract_solution(&witness); + let source_config = reduction.extract_solution(&witness).unwrap(); let values: Vec = source_config.iter().map(|&c| c as i64).collect(); let obj = source.evaluate_objective(&values); Some((source_config, obj)) diff --git a/src/unit_tests/rules/ilp_qubo.rs b/src/unit_tests/rules/ilp_qubo.rs index 071d28743..314310727 100644 --- a/src/unit_tests/rules/ilp_qubo.rs +++ b/src/unit_tests/rules/ilp_qubo.rs @@ -24,13 +24,13 @@ fn test_ilp_to_qubo_closed_loop() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } // Optimal should be [1, 0, 1] - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best, vec![1, 0, 1]); } @@ -52,12 +52,12 @@ fn test_ilp_to_qubo_minimize() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best, vec![1, 0, 0]); } @@ -85,7 +85,7 @@ fn test_ilp_to_qubo_equality() { assert_eq!(qubo_solutions.len(), 3); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); @@ -116,13 +116,13 @@ fn test_ilp_to_qubo_ge_with_slack() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } // Optimal: exactly one variable = 1 - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best.iter().sum::(), 1); } @@ -150,13 +150,13 @@ fn test_ilp_to_qubo_le_with_slack() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let values: Vec = extracted.iter().map(|&x| x as i64).collect(); assert!(ilp.is_feasible(&values)); } // Optimal: exactly 2 of 3 variables = 1 (3 solutions) - let best = reduction.extract_solution(&qubo_solutions[0]); + let best = reduction.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(best.iter().sum::(), 2); } diff --git a/src/unit_tests/rules/integerknapsack_ilp.rs b/src/unit_tests/rules/integerknapsack_ilp.rs index 2fb5f1f35..e3f200b02 100644 --- a/src/unit_tests/rules/integerknapsack_ilp.rs +++ b/src/unit_tests/rules/integerknapsack_ilp.rs @@ -16,7 +16,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 2]); } @@ -58,7 +58,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); 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 12dde8a1a..007a50b16 100644 --- a/src/unit_tests/rules/integralflowbundles_ilp.rs +++ b/src/unit_tests/rules/integralflowbundles_ilp.rs @@ -75,7 +75,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted)); } @@ -85,7 +85,7 @@ fn test_integral_flow_bundles_to_ilp_extract_solution_is_identity() { let problem = yes_instance(); let reduction: ReductionIFBToILP = ReduceTo::>::reduce_to(&problem); assert_eq!( - reduction.extract_solution(&satisfying_config()), + reduction.extract_solution(&satisfying_config()).unwrap(), satisfying_config() ); } diff --git a/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs b/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs index 4ddd58699..dc7f6d088 100644 --- a/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs +++ b/src/unit_tests/rules/integralflowhomologousarcs_ilp.rs @@ -26,7 +26,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs b/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs index 35c1b0cb8..b1c677ea7 100644 --- a/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs +++ b/src/unit_tests/rules/integralflowwithmultipliers_ilp.rs @@ -25,7 +25,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/isomorphicspanningtree_ilp.rs b/src/unit_tests/rules/isomorphicspanningtree_ilp.rs index 47f8a2293..4aa9f8552 100644 --- a/src/unit_tests/rules/isomorphicspanningtree_ilp.rs +++ b/src/unit_tests/rules/isomorphicspanningtree_ilp.rs @@ -52,7 +52,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -67,7 +67,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 3); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs index 074fd4007..b4ef61e6e 100644 --- a/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/unit_tests/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -44,7 +44,7 @@ fn test_kclique_to_bcbs_complete_graph() { let bf = BruteForce::new(); let witness = bf.find_witness(target).expect("K4 should contain K3"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); // Exactly 3 vertices should be selected assert_eq!(extracted.iter().sum::(), 3); @@ -91,7 +91,7 @@ fn test_kclique_to_bcbs_k_equals_2() { let witness = bf .find_witness(target) .expect("graph has edges, so 2-clique exists"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 2); } @@ -110,7 +110,7 @@ fn test_kclique_to_bcbs_k_equals_1() { let bf = BruteForce::new(); let witness = bf.find_witness(target).expect("should find a 1-clique"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 1); } diff --git a/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs b/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs index ddfcbb7a6..b4e84f82a 100644 --- a/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs +++ b/src/unit_tests/rules/kclique_conjunctivebooleanquery.rs @@ -68,7 +68,7 @@ fn test_solution_extraction() { let cbq_witness = bf .find_witness(reduction.target_problem()) .expect("CBQ should be satisfiable"); - let extracted = reduction.extract_solution(&cbq_witness); + let extracted = reduction.extract_solution(&cbq_witness).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // All 3 vertices should be selected assert_eq!(extracted.iter().sum::(), 3); @@ -90,6 +90,6 @@ fn test_trivial_k1() { let witness = bf .find_witness(reduction.target_problem()) .expect("k=1 should be feasible"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/kclique_ilp.rs b/src/unit_tests/rules/kclique_ilp.rs index 6c8628c0a..9bd522a48 100644 --- a/src/unit_tests/rules/kclique_ilp.rs +++ b/src/unit_tests/rules/kclique_ilp.rs @@ -32,7 +32,7 @@ fn test_kclique_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -45,7 +45,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); // Should select at least k=3 vertices (ILP may return a larger valid clique) assert!(extracted.iter().sum::() >= 3); diff --git a/src/unit_tests/rules/kclique_subgraphisomorphism.rs b/src/unit_tests/rules/kclique_subgraphisomorphism.rs index d2abaf38c..083913efa 100644 --- a/src/unit_tests/rules/kclique_subgraphisomorphism.rs +++ b/src/unit_tests/rules/kclique_subgraphisomorphism.rs @@ -47,7 +47,7 @@ fn test_kclique_to_subgraphisomorphism_complete_graph() { // Solve the target and extract back to source let bf = BruteForce::new(); let witness = bf.find_witness(target).expect("K4 should contain K3"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); // Exactly 3 vertices should be selected assert_eq!(extracted.iter().sum::(), 3); @@ -90,7 +90,7 @@ fn test_kclique_to_subgraphisomorphism_k_equals_1() { let witness = bf .find_witness(target) .expect("should find a single vertex"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 1); } @@ -110,7 +110,7 @@ fn test_kclique_to_subgraphisomorphism_k_equals_2() { let witness = bf .find_witness(target) .expect("graph has edges, so K2 exists"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted.iter().sum::(), 2); } diff --git a/src/unit_tests/rules/kcoloring_bicliquecover.rs b/src/unit_tests/rules/kcoloring_bicliquecover.rs index c39d9726d..96458930b 100644 --- a/src/unit_tests/rules/kcoloring_bicliquecover.rs +++ b/src/unit_tests/rules/kcoloring_bicliquecover.rs @@ -25,7 +25,7 @@ fn test_kcoloring_to_bicliquecover_closed_loop_trivial() { let witness = BruteForce::new() .find_witness(target) .expect("trivial target must be feasible"); - let coloring = reduction.extract_solution(&witness); + let coloring = reduction.extract_solution(&witness).unwrap(); assert_eq!(coloring.len(), 1); assert!(source.is_valid_solution(&coloring)); // The source brute force agrees. @@ -97,7 +97,7 @@ 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); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.is_valid_solution(&extracted)); } @@ -115,7 +115,7 @@ 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); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.is_valid_solution(&extracted)); } @@ -180,7 +180,7 @@ 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); + let extracted = reduction.extract_solution(&witness).unwrap(); assert!(source.is_valid_solution(&extracted)); // K_3 forces 3 distinct colors. let mut seen = std::collections::BTreeSet::new(); @@ -238,6 +238,6 @@ fn test_kcoloring_to_bicliquecover_extract_trivial_layout() { assert_eq!(cell(&witness, 0, 1, k), 1); assert_eq!(cell(&witness, 2, 1, k), 1); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(extracted, vec![0]); } diff --git a/src/unit_tests/rules/kcoloring_clustering.rs b/src/unit_tests/rules/kcoloring_clustering.rs index 3c003c411..3a51814b3 100644 --- a/src/unit_tests/rules/kcoloring_clustering.rs +++ b/src/unit_tests/rules/kcoloring_clustering.rs @@ -42,7 +42,7 @@ fn test_kcoloring_to_clustering_extract_solution_identity() { let reduction = ReduceTo::::reduce_to(&source); let config = vec![0, 1, 0]; - assert_eq!(reduction.extract_solution(&config), config); + assert_eq!(reduction.extract_solution(&config).unwrap(), config); } #[test] @@ -64,6 +64,9 @@ fn test_kcoloring_to_clustering_empty_graph() { assert_eq!(target.num_elements(), 1); assert_eq!(target.num_clusters(), 3); assert_eq!(target.diameter_bound(), 0); - assert_eq!(reduction.extract_solution(&[2]), Vec::::new()); + assert_eq!( + reduction.extract_solution(&[2]).unwrap(), + 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 c64337df2..046baba99 100644 --- a/src/unit_tests/rules/kcoloring_partitionintocliques.rs +++ b/src/unit_tests/rules/kcoloring_partitionintocliques.rs @@ -39,7 +39,7 @@ fn test_kcoloring_to_partitionintocliques_extract_solution_identity() { let reduction = ReduceTo::>::reduce_to(&source); let config = vec![0, 1, 0]; - assert_eq!(reduction.extract_solution(&config), config); + assert_eq!(reduction.extract_solution(&config).unwrap(), config); } #[test] diff --git a/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs index 39012b081..0b7a4263a 100644 --- a/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/unit_tests/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -100,7 +100,7 @@ fn test_kcoloring_to_tdcs_extract_solution_valid() { let target_solutions = solver.find_all_witnesses(reduction.target_problem()); for target_sol in &target_solutions { - let source_sol = reduction.extract_solution(target_sol); + let source_sol = reduction.extract_solution(target_sol).unwrap(); assert_eq!(source_sol.len(), 3); // Verify it is a valid coloring assert!( diff --git a/src/unit_tests/rules/knapsack_ilp.rs b/src/unit_tests/rules/knapsack_ilp.rs index 35aa277a7..a6a0295d4 100644 --- a/src/unit_tests/rules/knapsack_ilp.rs +++ b/src/unit_tests/rules/knapsack_ilp.rs @@ -18,7 +18,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 1, 0]); } @@ -33,7 +33,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = knapsack.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -68,7 +68,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0]); } @@ -88,7 +88,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, Vec::::new()); } diff --git a/src/unit_tests/rules/knapsack_qubo.rs b/src/unit_tests/rules/knapsack_qubo.rs index 568cdab1f..89eebb0f9 100644 --- a/src/unit_tests/rules/knapsack_qubo.rs +++ b/src/unit_tests/rules/knapsack_qubo.rs @@ -28,7 +28,7 @@ fn test_knapsack_to_qubo_single_item() { let solver = BruteForce::new(); let best_target = solver.find_all_witnesses(qubo); - let extracted = reduction.extract_solution(&best_target[0]); + let extracted = reduction.extract_solution(&best_target[0]).unwrap(); assert_eq!(extracted, vec![1]); } @@ -42,7 +42,7 @@ fn test_knapsack_to_qubo_infeasible_rejected() { let best_target = solver.find_all_witnesses(qubo); for sol in &best_target { - let source_sol = reduction.extract_solution(sol); + let source_sol = reduction.extract_solution(sol).unwrap(); let eval = knapsack.evaluate(&source_sol); assert!( eval.is_valid(), @@ -61,7 +61,7 @@ fn test_knapsack_to_qubo_empty() { let solver = BruteForce::new(); let best_target = solver.find_all_witnesses(qubo); - let extracted = reduction.extract_solution(&best_target[0]); + let extracted = reduction.extract_solution(&best_target[0]).unwrap(); assert_eq!(extracted, vec![0, 0]); } diff --git a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs index b2b98c15e..157e3ccf5 100644 --- a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs +++ b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs @@ -20,7 +20,7 @@ fn test_ksatisfiability_to_acyclicpartition_closed_loop() { assert!(!solutions.is_empty()); for solution in solutions { - let extracted = reduction.extract_solution(&solution); + let extracted = reduction.extract_solution(&solution).unwrap(); assert!(source.evaluate(&extracted).0); } } diff --git a/src/unit_tests/rules/ksatisfiability_bicliquecover.rs b/src/unit_tests/rules/ksatisfiability_bicliquecover.rs index ad3ea0504..ac94f55cc 100644 --- a/src/unit_tests/rules/ksatisfiability_bicliquecover.rs +++ b/src/unit_tests/rules/ksatisfiability_bicliquecover.rs @@ -137,7 +137,7 @@ fn test_ksatisfiability_to_bicliquecover_extract_solution_reads_b1() { set(&mut witness, 0, 0); // Leave h_1^u (vertex 1) unset → f_1 = false in B_1. - let assignment = reduction.extract_solution(&witness); + let assignment = reduction.extract_solution(&witness).unwrap(); assert_eq!(assignment.len(), 1); assert_eq!(assignment[0], 1, "expected source x_1 = true from B_1"); @@ -145,6 +145,22 @@ fn test_ksatisfiability_to_bicliquecover_extract_solution_reads_b1() { assert_eq!(n, 2); } +#[test] +fn test_ksatisfiability_to_bicliquecover_rejects_missing_b1() { + let source = KSatisfiability::::new(1, vec![CNFClause::new(vec![1, 1, 1])]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + let target_solution = vec![0; target.num_vertices() * target.k()]; + + assert_eq!( + reduction + .extract_solution(&target_solution) + .unwrap_err() + .to_string(), + "target configuration has no important-edge biclique B_1" + ); +} + /// If `B_1` is shadowed by a free-edge biclique that touches `Y`, the /// extractor must skip it and proceed to the next candidate. We test /// this by setting up two bicliques that both contain `s_11^u` and @@ -182,7 +198,7 @@ fn test_ksatisfiability_to_bicliquecover_extract_skips_y_touching_bicliques() { // h_1^u is unified vertex 1. set(&mut witness, 1, 1); - let assignment = reduction.extract_solution(&witness); + let assignment = reduction.extract_solution(&witness).unwrap(); assert_eq!(assignment.len(), 1); assert_eq!( assignment[0], 0, @@ -211,7 +227,7 @@ fn test_ksatisfiability_to_bicliquecover_closed_loop_smallest() { "forward witness must be a valid biclique cover" ); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!( extracted[0], 1, diff --git a/src/unit_tests/rules/ksatisfiability_cyclicordering.rs b/src/unit_tests/rules/ksatisfiability_cyclicordering.rs index 2d6509dd0..6894aa52c 100644 --- a/src/unit_tests/rules/ksatisfiability_cyclicordering.rs +++ b/src/unit_tests/rules/ksatisfiability_cyclicordering.rs @@ -141,7 +141,7 @@ fn test_ksatisfiability_to_cyclicordering_single_clause_reference_vector() { let target_solution = solve_cyclic_ordering(target).expect("single-clause gadget should be solvable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); assert!(source.evaluate(&extracted).0); } @@ -178,7 +178,10 @@ fn test_ksatisfiability_to_cyclicordering_extract_solution_from_reference_witnes let target_solution = vec![0, 11, 1, 9, 12, 10, 6, 13, 7, 2, 3, 4, 8, 5]; assert!(reduction.target_problem().evaluate(&target_solution).0); - assert_eq!(reduction.extract_solution(&target_solution), vec![1, 1, 1]); + assert_eq!( + reduction.extract_solution(&target_solution).unwrap(), + vec![1, 1, 1] + ); } #[test] @@ -251,7 +254,7 @@ fn test_ksatisfiability_to_cyclicordering_closed_loop() { "target solution must evaluate as satisfying" ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!( source.evaluate(&extracted).0, "extracted source config must satisfy the source" diff --git a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs index 70a1e5b95..40d9c9858 100644 --- a/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs +++ b/src/unit_tests/rules/ksatisfiability_decisionminimumvertexcover.rs @@ -75,5 +75,5 @@ fn test_ksatisfiability_to_decisionminimumvertexcover_extract_solution() { reduction.target_problem().evaluate(&cover), crate::types::Or(true) ); - assert_eq!(reduction.extract_solution(&cover), vec![0, 0, 1]); + assert_eq!(reduction.extract_solution(&cover).unwrap(), vec![0, 0, 1]); } diff --git a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs index a03765d13..461f2fc37 100644 --- a/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/unit_tests/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -48,7 +48,7 @@ fn solve_target_via_ilp( ) -> Option> { let reduction = ReduceTo::>::reduce_to(problem); let ilp_solution = ILPSolver::new().solve(reduction.target_problem()).ok()?; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); problem.evaluate(&extracted).0.then_some(extracted) } @@ -95,7 +95,7 @@ fn test_ksatisfiability_to_directedtwocommodityintegralflow_extract_solution_fro let assignment = vec![1, 1, 0]; let flow = reduction.encode_assignment(&assignment); assert!(reduction.target_problem().evaluate(&flow).0); - assert_eq!(reduction.extract_solution(&flow), assignment); + assert_eq!(reduction.extract_solution(&flow).unwrap(), assignment); } #[cfg(feature = "ilp-solver")] @@ -110,7 +110,7 @@ fn test_ksatisfiability_to_directedtwocommodityintegralflow_closed_loop() { assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs index d792c61e0..c0ecdcb43 100644 --- a/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/unit_tests/rules/ksatisfiability_feasibleregisterassignment.rs @@ -68,7 +68,7 @@ fn test_ksatisfiability_to_feasible_register_assignment_extract_solution() { let mut realization: Vec = (0..reduction.target_problem().num_vertices()).collect(); realization.swap(s_pos_idx(1), s_neg_idx(2, 1)); - let extracted = reduction.extract_solution(&realization); + let extracted = reduction.extract_solution(&realization).unwrap(); assert_eq!(extracted, vec![1, 0]); } @@ -82,10 +82,10 @@ 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); + let fra_solution = fra_to_ilp.extract_solution(&ilp_solution).unwrap(); assert_eq!(reduction.target_problem().evaluate(&fra_solution), Or(true)); - let extracted = reduction.extract_solution(&fra_solution); + let extracted = reduction.extract_solution(&fra_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/ksatisfiability_kclique.rs b/src/unit_tests/rules/ksatisfiability_kclique.rs index c9886f1c1..29452f7e2 100644 --- a/src/unit_tests/rules/ksatisfiability_kclique.rs +++ b/src/unit_tests/rules/ksatisfiability_kclique.rs @@ -29,7 +29,7 @@ fn test_ksatisfiability_to_kclique_closed_loop() { // Every KClique solution must map back to a satisfying 3-SAT assignment for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -79,7 +79,7 @@ fn test_ksatisfiability_to_kclique_single_clause() { // Each solution maps to a satisfying assignment let mut sat_assignments = std::collections::HashSet::new(); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); sat_assignments.insert(extracted); } @@ -142,7 +142,7 @@ fn test_ksatisfiability_to_kclique_three_clauses() { // Verify all solutions map back correctly for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -170,7 +170,7 @@ fn test_ksatisfiability_to_kclique_extract_solution_example() { let specific_config = vec![0, 0, 1, 1, 0, 0]; assert!(target.evaluate(&specific_config)); - let extracted = reduction.extract_solution(&specific_config); + let extracted = reduction.extract_solution(&specific_config).unwrap(); // Vertex 2 = clause 0, pos 2 → literal 3 (x3) → x3=T → assignment[2]=1 // Vertex 3 = clause 1, pos 0 → literal -1 (¬x1) → x1=F → assignment[0]=0 // Unset variables default to 0. diff --git a/src/unit_tests/rules/ksatisfiability_kernel.rs b/src/unit_tests/rules/ksatisfiability_kernel.rs index 38776937e..89404a4e1 100644 --- a/src/unit_tests/rules/ksatisfiability_kernel.rs +++ b/src/unit_tests/rules/ksatisfiability_kernel.rs @@ -70,7 +70,7 @@ fn test_ksatisfiability_to_kernel_extract_solution_reads_variable_gadgets() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 0, 1, 0, 0, 0]), + reduction.extract_solution(&[1, 0, 0, 1, 0, 0, 0]).unwrap(), vec![1, 0] ); } diff --git a/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs b/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs index b1687007b..6f3278fc9 100644 --- a/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs +++ b/src/unit_tests/rules/ksatisfiability_minimumvertexcover.rs @@ -105,7 +105,7 @@ fn test_ksatisfiability_to_minimumvertexcover_extract_solution() { // Verify this is a valid vertex cover assert!(reduction.target_problem().is_valid_solution(&vc_config)); - let extracted = reduction.extract_solution(&vc_config); + let extracted = reduction.extract_solution(&vc_config).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); // x1=F, x2=F, x3=T assert!(ksat.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs b/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs index 6dfdfc691..b716fc0be 100644 --- a/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/unit_tests/rules/ksatisfiability_monochromatictriangle.rs @@ -58,7 +58,7 @@ fn test_ksatisfiability_to_monochromatic_triangle_complement_extraction() { "the supplied target coloring must avoid monochromatic triangles" ); - let extracted = reduction.extract_solution(&target_coloring); + let extracted = reduction.extract_solution(&target_coloring).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); assert!(source.evaluate(&extracted)); } @@ -79,10 +79,10 @@ fn test_ksatisfiability_to_monochromatic_triangle_closed_loop() { let ilp_solution = ILPSolver::new() .solve(mono_to_ilp.target_problem()) .expect("reduced MonochromaticTriangle instance should be feasible"); - let mono_solution = mono_to_ilp.extract_solution(&ilp_solution); + let mono_solution = mono_to_ilp.extract_solution(&ilp_solution).unwrap(); assert!(reduction.target_problem().evaluate(&mono_solution)); - let extracted = reduction.extract_solution(&mono_solution); + let extracted = reduction.extract_solution(&mono_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs b/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs index e35d7b788..dc86b3ab1 100644 --- a/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs +++ b/src/unit_tests/rules/ksatisfiability_oneinthreesatisfiability.rs @@ -97,7 +97,7 @@ fn test_ksatisfiability_to_oneinthreesatisfiability_extract_solution() { let target_solution = vec![0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0]; assert!(target.evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs index b27b46739..84b372d95 100644 --- a/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/unit_tests/rules/ksatisfiability_preemptivescheduling.rs @@ -35,7 +35,7 @@ fn solve_threshold_schedule_via_ilp( ); let pcs_to_ilp = ReduceTo::>::reduce_to(&pcs); let ilp_solution = ILPSolver::new().solve(pcs_to_ilp.target_problem()).ok()?; - let slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution); + let slot_assignment = pcs_to_ilp.extract_solution(&ilp_solution).unwrap(); let mut config = vec![0usize; target.num_tasks() * target.d_max()]; for (task, &slot) in slot_assignment.iter().enumerate() { @@ -68,7 +68,7 @@ fn test_ksatisfiability_to_preemptivescheduling_extract_solution_from_constructe assert_eq!(reduction.target_problem().evaluate(&schedule), Min(Some(4))); - let extracted = reduction.extract_solution(&schedule); + let extracted = reduction.extract_solution(&schedule).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } @@ -87,7 +87,7 @@ fn test_ksatisfiability_to_preemptivescheduling_multi_variable_round_trip() { let schedule = construct_schedule_from_assignment(result.target_problem(), &[1, 1, 0], &source) .expect("satisfying assignment should yield a witness schedule"); - let extracted = result.extract_solution(&schedule); + let extracted = result.extract_solution(&schedule).unwrap(); assert_eq!(extracted, vec![1, 1, 0]); assert!(source.evaluate(&extracted).0); } @@ -107,7 +107,7 @@ fn test_ksatisfiability_to_preemptivescheduling_closed_loop() { Min(Some(reduction.threshold())) ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs index aa8cc986a..fe01aa0c9 100644 --- a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs @@ -56,7 +56,7 @@ fn test_ksatisfiability_to_quadraticcongruences_yes_vector_matches_reference() { .expect("reference witness must fit target encoding"); assert_eq!(target.evaluate(&target_config), crate::types::Or(true)); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); assert_eq!(source.evaluate(&extracted), crate::types::Or(true)); } @@ -93,7 +93,7 @@ fn test_ksatisfiability_to_quadraticcongruences_extracts_assignment_from_constru let target_config = witness_config_for_assignment(&source, &[1, 0, 0, 0]) .expect("assignment should lift to a target witness"); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 0]); assert_eq!(source.evaluate(&extracted), crate::types::Or(true)); assert_eq!( @@ -128,7 +128,7 @@ fn test_ksatisfiability_to_quadraticcongruences_closed_loop() { ); // Verify round-trip: extracting the source solution recovers the original assignment. - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); assert_eq!( source.evaluate(&extracted), diff --git a/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs index 70045195f..c9f3825f2 100644 --- a/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/unit_tests/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -25,7 +25,7 @@ fn test_ksatisfiability_to_quadraticdiophantineequations_closed_loop() { Or(true) ); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -41,7 +41,7 @@ fn test_ksatisfiability_to_quadraticdiophantineequations_yes_vector_matches_refe assert_eq!(target.evaluate(&target_config), Or(true)); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); assert_eq!(source.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/ksatisfiability_qubo.rs b/src/unit_tests/rules/ksatisfiability_qubo.rs index 9b64eccee..0054fdc42 100644 --- a/src/unit_tests/rules/ksatisfiability_qubo.rs +++ b/src/unit_tests/rules/ksatisfiability_qubo.rs @@ -25,7 +25,7 @@ fn test_ksatisfiability_to_qubo_closed_loop() { // Verify all solutions satisfy all clauses for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } } @@ -41,7 +41,7 @@ fn test_ksatisfiability_to_qubo_simple() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } } @@ -86,7 +86,7 @@ fn test_ksatisfiability_to_qubo_reversed_vars() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } } @@ -130,7 +130,7 @@ fn test_k3satisfiability_to_qubo_closed_loop() { // Verify all extracted solutions maximize satisfied clauses for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 5); let assignment: Vec = extracted.iter().map(|&v| v == 1).collect(); let satisfied = ksat.count_satisfied(&assignment); @@ -153,7 +153,7 @@ fn test_k3satisfiability_to_qubo_single_clause() { // All solutions should satisfy the single clause for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -172,7 +172,7 @@ fn test_k3satisfiability_to_qubo_all_negated() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } // 7 out of 8 assignments satisfy (¬x1 ∨ ¬x2 ∨ ¬x3) diff --git a/src/unit_tests/rules/ksatisfiability_registersufficiency.rs b/src/unit_tests/rules/ksatisfiability_registersufficiency.rs index fdf330cfc..ce458c34e 100644 --- a/src/unit_tests/rules/ksatisfiability_registersufficiency.rs +++ b/src/unit_tests/rules/ksatisfiability_registersufficiency.rs @@ -88,8 +88,9 @@ fn test_ksatisfiability_to_register_sufficiency_extract_solution_uses_w_snapshot } } - let extracted = - reduction.extract_solution(&positions_from_order(&order, target.num_vertices())); + let extracted = reduction + .extract_solution(&positions_from_order(&order, target.num_vertices())) + .unwrap(); assert_eq!(extracted, vec![1]); } @@ -107,7 +108,7 @@ fn test_ksatisfiability_to_register_sufficiency_closed_loop_via_exact_solver() { Or(true) ); - let extracted = reduction.extract_solution(®ister_schedule); + let extracted = reduction.extract_solution(®ister_schedule).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); assert_eq!(extracted, vec![1]); } diff --git a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs index c2613cdc6..f7608624a 100644 --- a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs @@ -23,7 +23,7 @@ fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { let target_solution = solver .find_witness(target) .expect("target should be satisfiable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted)); } @@ -76,7 +76,7 @@ fn test_ksatisfiability_to_simultaneous_incongruences_tautological_clause_is_red let target_solution = solver .find_witness(reduction.target_problem()) .expect("target should remain satisfiable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_subsetsum.rs b/src/unit_tests/rules/ksatisfiability_subsetsum.rs index 30ffca725..aef7f4d48 100644 --- a/src/unit_tests/rules/ksatisfiability_subsetsum.rs +++ b/src/unit_tests/rules/ksatisfiability_subsetsum.rs @@ -30,7 +30,7 @@ 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); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), 3); assert!(ksat.evaluate(&extracted)); } @@ -73,7 +73,7 @@ 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); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); sat_assignments.insert(extracted); } @@ -122,7 +122,7 @@ 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); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); sat_assignments.insert(extracted); } @@ -156,7 +156,7 @@ fn test_ksatisfiability_to_subsetsum_extract_solution_example() { ]; assert!(target.evaluate(&specific_config)); - let extracted = reduction.extract_solution(&specific_config); + let extracted = reduction.extract_solution(&specific_config).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); // x1=T, x2=T, x3=T assert!(ksat.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs index 83fb3e168..7ca581fc9 100644 --- a/src/unit_tests/rules/ksatisfiability_timetabledesign.rs +++ b/src/unit_tests/rules/ksatisfiability_timetabledesign.rs @@ -53,7 +53,7 @@ fn test_ksatisfiability_to_timetabledesign_extract_solution_from_constructed_tim assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).0); } @@ -66,7 +66,7 @@ fn test_ksatisfiability_to_timetabledesign_multi_variable_round_trip() { construct_timetable_from_assignment(reduction.target_problem(), &[1, 1, 0], &source) .expect("a satisfying 3SAT assignment should lift to a timetable witness"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 0]); assert!(source.evaluate(&extracted).0); } @@ -83,7 +83,7 @@ fn test_ksatisfiability_to_timetabledesign_closed_loop() { assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/longestcircuit_ilp.rs b/src/unit_tests/rules/longestcircuit_ilp.rs index b0c20b4ad..1ac9602d5 100644 --- a/src/unit_tests/rules/longestcircuit_ilp.rs +++ b/src/unit_tests/rules/longestcircuit_ilp.rs @@ -52,7 +52,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0.is_some(), "ILP solution should be a valid circuit" @@ -86,7 +86,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/longestcommonsubsequence_ilp.rs b/src/unit_tests/rules/longestcommonsubsequence_ilp.rs index 62ecfcc7a..814703c29 100644 --- a/src/unit_tests/rules/longestcommonsubsequence_ilp.rs +++ b/src/unit_tests/rules/longestcommonsubsequence_ilp.rs @@ -16,7 +16,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), problem.max_length()); let value = problem.evaluate(&extracted); @@ -33,7 +33,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(matches!(ilp_value, Max(Some(_)))); @@ -53,7 +53,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), problem.max_length()); let value = problem.evaluate(&extracted); @@ -69,7 +69,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); let brute_force = BruteForce::new(); @@ -88,7 +88,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); 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 16d3ddb91..4ac0305cc 100644 --- a/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/unit_tests/rules/longestcommonsubsequence_maximumindependentset.rs @@ -116,7 +116,7 @@ fn test_lcs_to_mis_extract_solution() { let witness = solver .find_witness(reduction.target_problem()) .expect("should have a solution"); - let source_sol = reduction.extract_solution(&witness); + let source_sol = reduction.extract_solution(&witness).unwrap(); // The extracted solution should be valid for the source let value = lcs.evaluate(&source_sol); diff --git a/src/unit_tests/rules/longestpath_ilp.rs b/src/unit_tests/rules/longestpath_ilp.rs index 288d5d172..bd7f64c86 100644 --- a/src/unit_tests/rules/longestpath_ilp.rs +++ b/src/unit_tests/rules/longestpath_ilp.rs @@ -69,7 +69,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.is_valid_solution(&extracted)); assert_eq!(problem.evaluate(&extracted), best_value); @@ -82,7 +82,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); assert_eq!(problem.evaluate(&extracted), Max(Some(5))); @@ -101,7 +101,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0]); assert_eq!(problem.evaluate(&extracted), Max(Some(0))); diff --git a/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs b/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs index 0e2f3c6ac..37c6ac72e 100644 --- a/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs +++ b/src/unit_tests/rules/maxcut_minimumcutintoboundedsets.rs @@ -117,7 +117,7 @@ fn test_maxcut_to_minimumcutintoboundedsets_extract_solution_size() { // Target has 8 vertices, extract should return 3 let dummy_target_sol = vec![0, 1, 0, 1, 0, 1, 0, 1]; - let extracted = reduction.extract_solution(&dummy_target_sol); + let extracted = reduction.extract_solution(&dummy_target_sol).unwrap(); assert_eq!(extracted.len(), 3); } diff --git a/src/unit_tests/rules/maxcut_minimummatrixcover.rs b/src/unit_tests/rules/maxcut_minimummatrixcover.rs index fb722d1a5..a0944aa25 100644 --- a/src/unit_tests/rules/maxcut_minimummatrixcover.rs +++ b/src/unit_tests/rules/maxcut_minimummatrixcover.rs @@ -182,7 +182,7 @@ fn test_extract_solution_is_identity() { MaxCut::::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1, 1]); let reduction = ReduceTo::::reduce_to(&source); let target_sol = vec![1, 0, 1]; - assert_eq!(reduction.extract_solution(&target_sol), target_sol); + assert_eq!(reduction.extract_solution(&target_sol).unwrap(), target_sol); } #[test] diff --git a/src/unit_tests/rules/maximalis_ilp.rs b/src/unit_tests/rules/maximalis_ilp.rs index 4a977ab58..740b4c75c 100644 --- a/src/unit_tests/rules/maximalis_ilp.rs +++ b/src/unit_tests/rules/maximalis_ilp.rs @@ -30,7 +30,7 @@ fn test_maximalis_to_ilp_bf_vs_ilp() { let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -45,7 +45,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); } diff --git a/src/unit_tests/rules/maximum2satisfiability_ilp.rs b/src/unit_tests/rules/maximum2satisfiability_ilp.rs index 52bdf45ac..ea7c4edb4 100644 --- a/src/unit_tests/rules/maximum2satisfiability_ilp.rs +++ b/src/unit_tests/rules/maximum2satisfiability_ilp.rs @@ -34,7 +34,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Optimal: 6 satisfied clauses let value = problem.evaluate(&extracted); assert_eq!(value, crate::types::Max(Some(6))); @@ -51,7 +51,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -106,7 +106,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); // 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 16863af58..5bbbaba45 100644 --- a/src/unit_tests/rules/maximum2satisfiability_maxcut.rs +++ b/src/unit_tests/rules/maximum2satisfiability_maxcut.rs @@ -64,7 +64,7 @@ fn test_maximum2satisfiability_to_maxcut_issue_affine_relation_on_all_partitions let target_solution: Vec = (0..target.num_vertices()) .map(|bit| (mask >> bit) & 1) .collect(); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); let satisfied = source.evaluate(&source_solution).unwrap() as i32; let cut_weight = target.evaluate(&target_solution).unwrap(); @@ -81,10 +81,16 @@ fn test_maximum2satisfiability_to_maxcut_extract_solution_uses_reference_vertex( let source = make_issue_instance(); let reduction = ReduceTo::>::reduce_to(&source); - assert_eq!(reduction.extract_solution(&[0, 1, 0, 0]), vec![0, 1, 1]); - assert_eq!(reduction.extract_solution(&[1, 0, 1, 1]), vec![0, 1, 1]); assert_eq!( - source.evaluate(&reduction.extract_solution(&[1, 0, 1, 1])), + reduction.extract_solution(&[0, 1, 0, 0]).unwrap(), + vec![0, 1, 1] + ); + assert_eq!( + reduction.extract_solution(&[1, 0, 1, 1]).unwrap(), + vec![0, 1, 1] + ); + assert_eq!( + source.evaluate(&reduction.extract_solution(&[1, 0, 1, 1]).unwrap()), Max(Some(5)) ); } diff --git a/src/unit_tests/rules/maximumclique_ilp.rs b/src/unit_tests/rules/maximumclique_ilp.rs index 21d24c1ae..753c3f36d 100644 --- a/src/unit_tests/rules/maximumclique_ilp.rs +++ b/src/unit_tests/rules/maximumclique_ilp.rs @@ -120,7 +120,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Both should find optimal size = 3 (all vertices form a clique) let ilp_size = clique_size(&problem, &extracted); @@ -151,7 +151,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = clique_size(&problem, &extracted); assert_eq!(bf_size, 2); @@ -177,7 +177,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = clique_size(&problem, &extracted); assert_eq!(bf_obj, 101); @@ -195,7 +195,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 0, 0]); // Verify this is a valid clique (0 and 1 are adjacent) @@ -229,7 +229,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Only one vertex should be selected assert_eq!(extracted.iter().sum::(), 1); @@ -253,7 +253,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // All vertices should be selected assert_eq!(extracted, vec![1, 1, 1, 1]); @@ -275,7 +275,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(is_valid_clique(&problem, &extracted)); assert_eq!(clique_size(&problem, &extracted), 2); @@ -301,7 +301,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); 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 39ede01a4..1cf85e386 100644 --- a/src/unit_tests/rules/maximumclique_maximumindependentset.rs +++ b/src/unit_tests/rules/maximumclique_maximumindependentset.rs @@ -52,7 +52,7 @@ fn test_maximumclique_to_maximumindependentset_triangle() { .any(|s| s.iter().sum::() == 3)); // Extract solution: should be the full clique {0,1,2} - let source_sol = reduction.extract_solution(&target_solutions[0]); + let source_sol = reduction.extract_solution(&target_solutions[0]).unwrap(); assert_eq!(source.evaluate(&source_sol).unwrap(), 3); } diff --git a/src/unit_tests/rules/maximumcokplex_ilp.rs b/src/unit_tests/rules/maximumcokplex_ilp.rs index 18e0c8dc9..c5dcb8180 100644 --- a/src/unit_tests/rules/maximumcokplex_ilp.rs +++ b/src/unit_tests/rules/maximumcokplex_ilp.rs @@ -72,7 +72,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Max(Some(2))); assert_eq!(extracted.iter().sum::(), 2); @@ -84,7 +84,7 @@ fn test_maximumcokplex_to_ilp_extract_solution_identity() { let source = issue_instance(); let reduction: ReductionCoKPlexToILP = ReduceTo::>::reduce_to(&source); let target_solution = vec![1, 0, 1, 0, 1]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, target_solution); assert_eq!(source.evaluate(&extracted), Max(Some(12))); diff --git a/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs b/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs index c606ef50c..f52769275 100644 --- a/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/unit_tests/rules/maximumcommonedgesubgraph_ilp.rs @@ -62,7 +62,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(2))); @@ -91,7 +91,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(1))); @@ -115,7 +115,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(0))); } @@ -132,7 +132,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(1))); diff --git a/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs b/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs index 0636b736e..ba34e2044 100644 --- a/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs +++ b/src/unit_tests/rules/maximumcontactmapoverlap_ilp.rs @@ -47,7 +47,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // The optimal alignment preserves both contacts of G_1. assert!(source.is_valid_solution(&extracted)); @@ -71,7 +71,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(0))); } @@ -97,7 +97,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.is_valid_solution(&extracted)); assert_eq!(source.evaluate(&extracted), Max(Some(1))); @@ -123,7 +123,7 @@ fn test_maximumcontactmapoverlap_to_ilp_extract_solution_partial() { let mut target_sol = vec![0usize; reduction.target_problem().num_vars]; target_sol[1] = 1; target_sol[n2 + 2] = 1; - let extracted = reduction.extract_solution(&target_sol); + let extracted = reduction.extract_solution(&target_sol).unwrap(); // 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 1c8cb7e4f..a72d354f7 100644 --- a/src/unit_tests/rules/maximumdomaticnumber_ilp.rs +++ b/src/unit_tests/rules/maximumdomaticnumber_ilp.rs @@ -20,7 +20,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); // Both should find domatic number = 2 @@ -72,7 +72,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Max(Some(3))); @@ -87,7 +87,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Max(Some(1))); @@ -105,7 +105,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); 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 b6dccc002..84fb42bcf 100644 --- a/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs +++ b/src/unit_tests/rules/maximumedgeweightedkclique_ilp.rs @@ -48,7 +48,7 @@ fn test_maximumedgeweightedkclique_to_ilp_extract_solution_identity() { let source = issue_instance(); let reduction = ReduceTo::>::reduce_to(&source); let target_solution = vec![1, 1, 1, 0, 1, 1, 1, 0, 0]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1, 0]); assert_eq!(source.evaluate(&extracted), Max(Some(8))); } diff --git a/src/unit_tests/rules/maximumindependentset_gridgraph.rs b/src/unit_tests/rules/maximumindependentset_gridgraph.rs index 1c19183d8..6e088d282 100644 --- a/src/unit_tests/rules/maximumindependentset_gridgraph.rs +++ b/src/unit_tests/rules/maximumindependentset_gridgraph.rs @@ -87,7 +87,7 @@ fn test_mis_simple_one_to_kings_one_closed_loop() { let grid_solutions = solver.find_all_witnesses(target); assert!(!grid_solutions.is_empty()); - let original_solution = result.extract_solution(&grid_solutions[0]); + let original_solution = result.extract_solution(&grid_solutions[0]).unwrap(); assert_eq!(original_solution.len(), 5); let size: usize = original_solution.iter().sum(); assert_eq!(size, 3, "Max IS in path of 5 should be 3"); diff --git a/src/unit_tests/rules/maximumindependentset_ilp.rs b/src/unit_tests/rules/maximumindependentset_ilp.rs index f2af01601..615f16dd0 100644 --- a/src/unit_tests/rules/maximumindependentset_ilp.rs +++ b/src/unit_tests/rules/maximumindependentset_ilp.rs @@ -66,7 +66,7 @@ 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 = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); let ilp_size: usize = extracted.iter().sum(); assert_eq!(ilp_size, 2); @@ -82,7 +82,7 @@ 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); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(100))); assert_eq!(extracted, vec![0, 1, 0]); @@ -98,6 +98,6 @@ fn test_maximumindependentset_to_ilp_bf_vs_ilp() { let ilp: &ILP = chain.target_problem(); let bf_value = BruteForce::new().solve(&problem); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } diff --git a/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs b/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs index 7ee27a1ac..62aec0bff 100644 --- a/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs +++ b/src/unit_tests/rules/maximumindependentset_integralflowbundles.rs @@ -23,7 +23,7 @@ fn test_maximumindependentset_to_integralflowbundles_closed_loop() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid(), "Extracted config should be a valid IS"); } @@ -48,7 +48,7 @@ fn test_maximumindependentset_to_integralflowbundles_triangle() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); } @@ -73,7 +73,7 @@ fn test_maximumindependentset_to_integralflowbundles_cycle5() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); } @@ -95,7 +95,7 @@ fn test_maximumindependentset_to_integralflowbundles_empty_graph() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); } @@ -117,7 +117,7 @@ fn test_maximumindependentset_to_integralflowbundles_single_vertex() { let witnesses = solver.find_all_witnesses(target); assert!(!witnesses.is_empty()); for w in &witnesses { - let source_config = reduction.extract_solution(w); + let source_config = reduction.extract_solution(w).unwrap(); let value = source.evaluate(&source_config); assert!(value.is_valid()); assert_eq!(value.unwrap(), 1); diff --git a/src/unit_tests/rules/maximumindependentset_maximumclique.rs b/src/unit_tests/rules/maximumindependentset_maximumclique.rs index 57e94117c..fd3e0a85e 100644 --- a/src/unit_tests/rules/maximumindependentset_maximumclique.rs +++ b/src/unit_tests/rules/maximumindependentset_maximumclique.rs @@ -44,7 +44,7 @@ fn test_maximumindependentset_to_maximumclique_weighted() { let solver = BruteForce::new(); let best = solver.find_all_witnesses(target); for sol in &best { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = source.evaluate(&extracted); assert!(metric.is_valid()); } diff --git a/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs b/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs index 880265a7d..548517931 100644 --- a/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs +++ b/src/unit_tests/rules/maximumindependentset_maximumsetpacking.rs @@ -180,7 +180,7 @@ fn test_maximumindependentset_one_to_maximumsetpacking_closed_loop() { let sp_solutions = solver.find_all_witnesses(sp_problem); assert!(!sp_solutions.is_empty()); - let original_solution = reduction.extract_solution(&sp_solutions[0]); + let original_solution = reduction.extract_solution(&sp_solutions[0]).unwrap(); assert_eq!(original_solution.len(), 3); let size: usize = original_solution.iter().sum(); assert_eq!(size, 2, "Max IS in path of 3 should be 2"); @@ -200,7 +200,7 @@ fn test_maximumsetpacking_one_to_maximumindependentset_closed_loop() { let is_solutions = solver.find_all_witnesses(is_problem); assert!(!is_solutions.is_empty()); - let original_solution = reduction.extract_solution(&is_solutions[0]); + let original_solution = reduction.extract_solution(&is_solutions[0]).unwrap(); assert_eq!(original_solution.len(), 3); let size: usize = original_solution.iter().sum(); assert_eq!( diff --git a/src/unit_tests/rules/maximumindependentset_qubo.rs b/src/unit_tests/rules/maximumindependentset_qubo.rs index 2d8ecfd9b..c5af2caad 100644 --- a/src/unit_tests/rules/maximumindependentset_qubo.rs +++ b/src/unit_tests/rules/maximumindependentset_qubo.rs @@ -55,7 +55,7 @@ fn test_maximumindependentset_to_qubo_via_path_closed_loop() { let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = chain.extract_solution(sol); + let extracted = chain.extract_solution(sol).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); } @@ -72,7 +72,7 @@ fn test_maximumindependentset_to_qubo_via_path_weighted() { let qubo_solution = solver .find_witness(qubo) .expect("QUBO should be solvable via path"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(100))); assert_eq!(extracted, vec![0, 1, 0]); @@ -88,7 +88,7 @@ fn test_maximumindependentset_to_qubo_via_path_empty_graph() { let solver = BruteForce::new(); let qubo_solution = solver.find_witness(qubo).expect("QUBO should be solvable"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Max(Some(3))); diff --git a/src/unit_tests/rules/maximumindependentset_triangular.rs b/src/unit_tests/rules/maximumindependentset_triangular.rs index 428e02ef5..2dcd51533 100644 --- a/src/unit_tests/rules/maximumindependentset_triangular.rs +++ b/src/unit_tests/rules/maximumindependentset_triangular.rs @@ -54,7 +54,7 @@ fn test_mis_simple_one_to_triangular_closed_loop() { // Map a trivial zero solution back to verify dimensions let zero_config = vec![0; target.graph().num_vertices()]; - let original_solution = result.extract_solution(&zero_config); + let original_solution = result.extract_solution(&zero_config).unwrap(); assert_eq!(original_solution.len(), 3); } diff --git a/src/unit_tests/rules/maximumleafspanningtree_ilp.rs b/src/unit_tests/rules/maximumleafspanningtree_ilp.rs index 159ce297e..5f740bddf 100644 --- a/src/unit_tests/rules/maximumleafspanningtree_ilp.rs +++ b/src/unit_tests/rules/maximumleafspanningtree_ilp.rs @@ -56,7 +56,7 @@ fn test_maximumleafspanningtree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // All brute-force optimal solutions have the same value let bf_value = problem.evaluate(&best_source[0]); @@ -76,7 +76,7 @@ fn test_maximumleafspanningtree_to_ilp_canonical_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&best_source[0]), Max(Some(4))); assert_eq!(problem.evaluate(&extracted), Max(Some(4))); @@ -96,7 +96,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { target_solution[2] = 1; // edge (2,3) assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), vec![1, 1, 1, 0] ); } @@ -109,7 +109,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(4))); assert!(problem.is_valid_solution(&extracted)); } @@ -131,7 +131,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(2))); } @@ -144,7 +144,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Max(Some(3))); assert!(problem.is_valid_solution(&extracted)); } @@ -164,7 +164,7 @@ fn test_maximumleafspanningtree_to_ilp_complete_graph() { ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), 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 f88feec2b..94b9c9bd7 100644 --- a/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs +++ b/src/unit_tests/rules/maximumlikelihoodranking_ilp.rs @@ -49,7 +49,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -66,7 +66,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify the extracted config is a valid permutation let n = problem.num_items(); @@ -92,7 +92,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); @@ -114,7 +114,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); } diff --git a/src/unit_tests/rules/maximummatching_ilp.rs b/src/unit_tests/rules/maximummatching_ilp.rs index 4ca49d5b0..8c93230a1 100644 --- a/src/unit_tests/rules/maximummatching_ilp.rs +++ b/src/unit_tests/rules/maximummatching_ilp.rs @@ -59,7 +59,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Both should find optimal size = 1 (one edge) let bf_size = problem.evaluate(&bf_solutions[0]); @@ -91,7 +91,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Max(Some(2))); @@ -118,7 +118,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Max(Some(100))); @@ -136,7 +136,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); // Verify this is a valid matching (edges 0-1 and 2-3 are disjoint) @@ -188,7 +188,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Max(Some(2))); // Perfect matching has 2 edges @@ -209,7 +209,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Max(Some(1))); @@ -228,7 +228,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Max(Some(2))); diff --git a/src/unit_tests/rules/maximummatching_maximumsetpacking.rs b/src/unit_tests/rules/maximummatching_maximumsetpacking.rs index b72d0ee3e..3bc0fd31b 100644 --- a/src/unit_tests/rules/maximummatching_maximumsetpacking.rs +++ b/src/unit_tests/rules/maximummatching_maximumsetpacking.rs @@ -56,7 +56,7 @@ fn test_matching_to_setpacking_solution_extraction() { // Test solution extraction is 1:1 let sp_solution = vec![1, 0, 1]; - let matching_solution = reduction.extract_solution(&sp_solution); + let matching_solution = reduction.extract_solution(&sp_solution).unwrap(); assert_eq!(matching_solution, vec![1, 0, 1]); // 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 7932ba4d1..6cf2f2a62 100644 --- a/src/unit_tests/rules/maximumsetpacking_casts.rs +++ b/src/unit_tests/rules/maximumsetpacking_casts.rs @@ -15,7 +15,7 @@ fn test_maximumsetpacking_one_to_i32_cast_closed_loop() { let solver = BruteForce::new(); let target_solution = solver.find_witness(sp_i32).unwrap(); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); let metric = sp_one.evaluate(&source_solution); assert!(metric.is_valid()); @@ -32,7 +32,7 @@ fn test_maximumsetpacking_i32_to_f64_cast_closed_loop() { let solver = BruteForce::new(); let target_solution = solver.find_witness(sp_f64).unwrap(); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); let metric = sp_i32.evaluate(&source_solution); assert!(metric.is_valid()); diff --git a/src/unit_tests/rules/maximumsetpacking_ilp.rs b/src/unit_tests/rules/maximumsetpacking_ilp.rs index bffe90ca4..2deafc02a 100644 --- a/src/unit_tests/rules/maximumsetpacking_ilp.rs +++ b/src/unit_tests/rules/maximumsetpacking_ilp.rs @@ -49,7 +49,7 @@ fn test_maximumsetpacking_to_ilp_closed_loop() { let bf_solutions = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let bf_size: usize = bf_solutions[0].iter().sum(); let ilp_size: usize = extracted.iter().sum(); @@ -78,7 +78,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Max(Some(6))); @@ -93,7 +93,7 @@ fn test_solution_extraction() { let reduction: ReductionSPToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = vec![1, 0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 1, 0]); assert!(problem.evaluate(&extracted).is_valid()); } @@ -108,7 +108,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1, 1]); assert!(problem.evaluate(&extracted).is_valid()); diff --git a/src/unit_tests/rules/maximumsetpacking_qubo.rs b/src/unit_tests/rules/maximumsetpacking_qubo.rs index dfad90aa2..fca0b1fc3 100644 --- a/src/unit_tests/rules/maximumsetpacking_qubo.rs +++ b/src/unit_tests/rules/maximumsetpacking_qubo.rs @@ -15,7 +15,7 @@ fn test_setpacking_to_qubo_closed_loop() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(sp.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); } @@ -32,7 +32,7 @@ fn test_setpacking_to_qubo_disjoint() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(sp.evaluate(&extracted).is_valid()); // All 3 sets should be selected assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 3); @@ -50,7 +50,7 @@ fn test_setpacking_to_qubo_all_overlap() { let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(sp.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 1); } diff --git a/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs b/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs index 2b03ca508..4d5179e97 100644 --- a/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/unit_tests/rules/minimumcapacitatedspanningtree_ilp.rs @@ -64,7 +64,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let bf_value = problem.evaluate(&best_source[0]); let ilp_value = problem.evaluate(&extracted); @@ -83,7 +83,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_canonical_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&best_source[0]), Min(Some(5))); assert_eq!(problem.evaluate(&extracted), Min(Some(5))); @@ -103,7 +103,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { target_solution[3] = 1; // edge (1,3) assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), vec![1, 1, 0, 1, 0] ); } @@ -131,7 +131,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_star_tree() { ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); assert!(problem.is_valid_solution(&extracted)); } @@ -151,7 +151,7 @@ fn test_minimumcapacitatedspanningtree_to_ilp_path_graph() { ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(6))); assert!(problem.is_valid_solution(&extracted)); } diff --git a/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs b/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs index 716580b80..2e42978ec 100644 --- a/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs +++ b/src/unit_tests/rules/minimumcostmaximumflow_minimumcostcirculation.rs @@ -79,7 +79,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_bottleneck() { // value 1 and cost 1 (the cheaper 1->3 path). let solver = BruteForce::new(); let target_witness = solver.find_witness(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 1); assert_eq!(source.total_cost(&extracted), 1); } @@ -113,7 +113,7 @@ 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.find_witness(target).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 1); assert_eq!(source.total_cost(&extracted), 1); } @@ -161,7 +161,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_zero_capacity_arc() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 1); // Zero-capacity arc must be 0 in the extracted flow. assert_eq!(extracted[2], 0); @@ -187,7 +187,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_value_priority_over_cos let solver = BruteForce::new(); let target_witness = solver.find_witness(reduction.target_problem()).unwrap(); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.flow_value(&extracted), 2); assert_eq!(source.total_cost(&extracted), 20); @@ -208,7 +208,7 @@ fn test_minimumcostmaximumflow_to_minimumcostcirculation_extract_solution_length for (i, v) in padded.iter_mut().enumerate().take(m) { *v = i % 2; } - let extracted = reduction.extract_solution(&padded); + let extracted = reduction.extract_solution(&padded).unwrap(); 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 d0b65d386..0665f29a8 100644 --- a/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs +++ b/src/unit_tests/rules/minimumcoveringbycliques_ilp.rs @@ -31,7 +31,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Min(Some(2))); assert_eq!(source.evaluate(&extracted), bf_value); @@ -46,7 +46,10 @@ 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::::new()); + assert_eq!( + reduction.extract_solution(&[]).unwrap(), + Vec::::new() + ); assert_eq!(source.evaluate(&[]), Min(Some(0))); } diff --git a/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index 03f6e8987..8c88f827c 100644 --- a/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/unit_tests/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -39,7 +39,7 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_issue_example_ assert_eq!(target.evaluate(&target_solution), Min(Some(2))); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1]); assert_eq!(source.evaluate(&extracted), Min(Some(2))); @@ -54,9 +54,13 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_invalid_target assert_eq!(target.evaluate(&invalid_target_solution), Min(None)); - let extracted = reduction.extract_solution(&invalid_target_solution); - - assert_eq!(source.evaluate(&extracted), 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] @@ -66,6 +70,9 @@ fn test_minimumcoveringbycliques_to_minimumintersectiongraphbasis_empty_graph() let target = reduction.target_problem(); assert_eq!(target.evaluate(&[]), Min(Some(0))); - assert_eq!(reduction.extract_solution(&[]), Vec::::new()); + assert_eq!( + reduction.extract_solution(&[]).unwrap(), + Vec::::new() + ); assert_eq!(source.evaluate(&[]), Min(Some(0))); } diff --git a/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs b/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs index 1dbe73c45..97f99a7a3 100644 --- a/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs +++ b/src/unit_tests/rules/minimumcutintoboundedsets_ilp.rs @@ -42,7 +42,7 @@ fn test_extract_solution() { let source = small_instance(); let reduction: ReductionMinCutBSToILP = ReduceTo::>::reduce_to(&source); let target_sol = vec![0, 0, 1, 1, 0, 1, 0]; - let extracted = reduction.extract_solution(&target_sol); + let extracted = reduction.extract_solution(&target_sol).unwrap(); assert_eq!(extracted, vec![0, 0, 1, 1]); assert!(source.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs index 711a805bf..20510142a 100644 --- a/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/unit_tests/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -43,7 +43,10 @@ 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]), vec![1]); + assert_eq!( + reduction.extract_solution(&qubo_solutions[0]).unwrap(), + vec![1] + ); assert!(matches!(source.evaluate(&[1]), Min(Some(v)) if v.abs() < EPS)); } @@ -62,7 +65,7 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_single_sample_per_link() assert_eq!(reduction.target_problem().num_vars(), 3); assert_eq!(qubo_solutions, vec![vec![1, 1, 1]]); assert_eq!( - reduction.extract_solution(&qubo_solutions[0]), + reduction.extract_solution(&qubo_solutions[0]).unwrap(), vec![0, 0, 0] ); assert!(matches!(source.evaluate(&[0, 0, 0]), Min(Some(v)) if v.abs() < EPS)); @@ -83,7 +86,7 @@ fn test_minimumdiscreteplanarinversekinematics_to_qubo_empty_allowed_pairs() { assert_eq!(solver.solve(&source), Min(None)); assert!(!qubo_solutions.is_empty(), "QUBO solver found no solutions"); for target_solution in qubo_solutions { - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Min(None)); } } diff --git a/src/unit_tests/rules/minimumdominatingset_ilp.rs b/src/unit_tests/rules/minimumdominatingset_ilp.rs index 42c4f4031..63a0e34a9 100644 --- a/src/unit_tests/rules/minimumdominatingset_ilp.rs +++ b/src/unit_tests/rules/minimumdominatingset_ilp.rs @@ -65,7 +65,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); // Both should find optimal size = 1 (just the center) @@ -98,7 +98,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Min(Some(2))); @@ -126,7 +126,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(3))); @@ -144,7 +144,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 1, 0]); // Verify this is a valid DS (0 dominates 0,1 and 2 dominates 2,3) @@ -173,7 +173,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Vertex 2 must be selected (isolated) assert_eq!(extracted[2], 1); @@ -193,7 +193,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -208,7 +208,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1]); @@ -234,7 +234,7 @@ fn test_cycle_graph() { let bf_size = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); 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 03d7ad096..d6363ca3e 100644 --- a/src/unit_tests/rules/minimumedgecostflow_ilp.rs +++ b/src/unit_tests/rules/minimumedgecostflow_ilp.rs @@ -75,7 +75,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(ilp_value, bf_value); @@ -95,7 +95,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } @@ -133,7 +133,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 6); assert_eq!(extracted, vec![0, 1, 2, 0, 1, 2]); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); diff --git a/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs b/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs index 48720b069..d8abdd6d7 100644 --- a/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/unit_tests/rules/minimumexternalmacrodatacompression_ilp.rs @@ -14,7 +14,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid(), "Extracted solution should be valid"); assert_eq!(value, Min(Some(2))); @@ -33,7 +33,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid(), "Extracted solution should be valid"); assert_eq!(value, Min(Some(12))); @@ -78,7 +78,7 @@ fn test_emdc_to_ilp_empty() { assert!(ilp.constraints.is_empty()); // For empty ILP, the solution is empty - let extracted = reduction.extract_solution(&[]); + let extracted = reduction.extract_solution(&[]).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Min(Some(0))); } @@ -102,7 +102,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); assert_eq!(value, Min(Some(1))); @@ -121,7 +121,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); 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 f5f3d06a8..89d79951a 100644 --- a/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs +++ b/src/unit_tests/rules/minimumfaultdetectiontestset_ilp.rs @@ -59,7 +59,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 1]); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); @@ -95,7 +95,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); diff --git a/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs b/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs index 4c2d060aa..d08b9b96b 100644 --- a/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs +++ b/src/unit_tests/rules/minimumfeedbackarcset_ilp.rs @@ -38,7 +38,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); // Both should find optimal value = 1 @@ -55,7 +55,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); // Verify this is a valid FAS (removing arc 2->0 breaks the 3-cycle) @@ -79,7 +79,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Min(Some(0)), "DAG needs no arc removal"); diff --git a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs index c021a9dda..4a95eeebd 100644 --- a/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ b/src/unit_tests/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs @@ -105,7 +105,7 @@ fn test_solution_extraction_marks_backward_arcs() { let source = issue_example_source(); let reduction: ReductionFASToMLR = ReduceTo::::reduce_to(&source); - let source_config = reduction.extract_solution(&[0, 1, 2, 3, 4]); + let source_config = reduction.extract_solution(&[0, 1, 2, 3, 4]).unwrap(); assert_eq!(source_config, vec![0, 0, 1, 0, 0, 1, 0]); } diff --git a/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs b/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs index 74f12365d..8b63f0a65 100644 --- a/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs +++ b/src/unit_tests/rules/minimumfeedbackvertexset_ilp.rs @@ -37,7 +37,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); // Both should find optimal size = 1 @@ -86,7 +86,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let size = problem.evaluate(&extracted); assert_eq!(size, Min(Some(3)), "FVS should be 3"); @@ -102,7 +102,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let size = problem.evaluate(&extracted); assert_eq!(size, Min(Some(0)), "DAG needs no removal"); @@ -123,7 +123,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); @@ -149,7 +149,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Should remove vertex 1 (cheapest) assert_eq!(extracted[1], 1, "Should remove vertex 1 (cheapest)"); @@ -171,7 +171,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Min(Some(2))); @@ -187,7 +187,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); // Verify this is a valid FVS (removing vertex 0 breaks the 3-cycle) diff --git a/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs b/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs index 577295a8c..fc5223560 100644 --- a/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs +++ b/src/unit_tests/rules/minimumgraphbandwidth_ilp.rs @@ -32,7 +32,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( ilp_value.0.is_some(), @@ -57,7 +57,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!( value, diff --git a/src/unit_tests/rules/minimumhittingset_ilp.rs b/src/unit_tests/rules/minimumhittingset_ilp.rs index fff92587b..cbf912452 100644 --- a/src/unit_tests/rules/minimumhittingset_ilp.rs +++ b/src/unit_tests/rules/minimumhittingset_ilp.rs @@ -22,7 +22,7 @@ fn test_minimumhittingset_to_ilp_bf_vs_ilp() { let bf_solutions = bf.find_all_witnesses(&problem); let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); assert!(ilp_value.is_valid()); @@ -33,7 +33,7 @@ fn test_solution_extraction() { let problem = MinimumHittingSet::new(3, vec![vec![0, 1], vec![1, 2]]); let reduction: ReductionHSToILP = ReduceTo::>::reduce_to(&problem); let ilp_solution = vec![0, 1, 0]; - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert!(problem.evaluate(&extracted).is_valid()); } diff --git a/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs index 39a642450..7619aad65 100644 --- a/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/unit_tests/rules/minimuminternalmacrodatacompression_ilp.rs @@ -15,7 +15,7 @@ fn test_imdc_to_ilp_closed_loop_simple() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let val = source.evaluate(&source_config); assert!(val.0.is_some()); assert_eq!(val.0.unwrap(), 2); @@ -31,7 +31,7 @@ fn test_imdc_to_ilp_closed_loop_repeated() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let val = source.evaluate(&source_config); assert!(val.0.is_some()); assert_eq!(val.0.unwrap(), 4); @@ -48,7 +48,7 @@ fn test_imdc_to_ilp_closed_loop_low_pointer_cost() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let val = source.evaluate(&source_config); assert!(val.0.is_some()); // Verify against brute force @@ -62,7 +62,7 @@ fn test_imdc_to_ilp_empty_string() { let reduction = ReduceTo::>::reduce_to(&source); let target = reduction.target_problem(); assert_eq!(target.num_variables(), 0); - let source_config = reduction.extract_solution(&[]); + let source_config = reduction.extract_solution(&[]).unwrap(); assert_eq!(source.evaluate(&source_config), Min(Some(0))); } @@ -76,7 +76,7 @@ fn test_imdc_to_ilp_single_char() { let solver = BruteForce::new(); let target_witness = solver.find_witness(target).expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.evaluate(&source_config), Min(Some(1))); } @@ -108,7 +108,7 @@ fn test_imdc_to_ilp_vs_brute_force() { let target_witness = BruteForce::new() .find_witness(target) .expect("ILP should be feasible"); - let source_config = reduction.extract_solution(&target_witness); + let source_config = reduction.extract_solution(&target_witness).unwrap(); let ilp_val = source.evaluate(&source_config); assert_eq!( diff --git a/src/unit_tests/rules/minimummatrixcover_ilp.rs b/src/unit_tests/rules/minimummatrixcover_ilp.rs index 420f2102e..3eb81b9eb 100644 --- a/src/unit_tests/rules/minimummatrixcover_ilp.rs +++ b/src/unit_tests/rules/minimummatrixcover_ilp.rs @@ -25,7 +25,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert_eq!(value, Min(Some(-20))); } @@ -66,7 +66,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -80,7 +80,7 @@ fn test_minimum_matrix_cover_to_ilp_2x2() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); // Optimal: different signs → value = -(3+2) = -5 assert_eq!(value, Min(Some(-5))); @@ -102,7 +102,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(5))); } @@ -116,7 +116,7 @@ fn test_minimum_matrix_cover_to_ilp_diagonal_matrix() { let ilp_solution = ILPSolver::new() .solve(reduction.target_problem()) .expect("diagonal ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // All configs give value 2+3+1 = 6 assert_eq!(problem.evaluate(&extracted), Min(Some(6))); } @@ -132,7 +132,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); 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 278244d39..711eb491a 100644 --- a/src/unit_tests/rules/minimummaximalmatching_ilp.rs +++ b/src/unit_tests/rules/minimummaximalmatching_ilp.rs @@ -34,7 +34,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, Min(Some(1))); @@ -55,7 +55,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); } @@ -70,7 +70,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert!(problem.evaluate(&extracted).is_valid()); diff --git a/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs b/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs index baf7ca2c1..5e8d3f231 100644 --- a/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs +++ b/src/unit_tests/rules/minimummaximalmatching_maximumachromaticnumber.rs @@ -46,7 +46,7 @@ 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); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!( source.evaluate(&extracted), Min(Some(1)), @@ -81,7 +81,7 @@ 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); + let extracted = reduction.extract_solution(&coloring).unwrap(); assert_eq!(extracted, vec![0, 1, 0, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(1))); } @@ -101,14 +101,14 @@ 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); + let extracted_a = reduction.extract_solution(&coloring_a).unwrap(); assert_eq!(extracted_a, vec![1, 0, 1, 0]); assert_eq!(source.evaluate(&extracted_a), 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); + let extracted_b = reduction.extract_solution(&coloring_b).unwrap(); assert_eq!(extracted_b, vec![0, 0, 1, 1]); assert_eq!(source.evaluate(&extracted_b), Min(Some(2))); } diff --git a/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs b/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs index ccc6598e9..a6d9fdf6d 100644 --- a/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs +++ b/src/unit_tests/rules/minimummaximalmatching_minimummatrixdomination.rs @@ -50,7 +50,7 @@ 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); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!( source.evaluate(&extracted), Min(Some(2)), @@ -100,7 +100,7 @@ fn test_extract_solution_returns_maximal_matching() { let target_witness = solver .find_witness(target) .expect("matrix domination has an optimum"); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); // The result must be a valid maximal matching of the source graph and // realize mm(B) = 2. @@ -163,7 +163,7 @@ fn test_extract_solution_yg_transform_on_non_matching_eds() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&target_witness), Min(Some(2))); - let extracted = reduction.extract_solution(&target_witness); + let extracted = reduction.extract_solution(&target_witness).unwrap(); // 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 c19068eae..45b4c1326 100644 --- a/src/unit_tests/rules/minimummetricdimension_ilp.rs +++ b/src/unit_tests/rules/minimummetricdimension_ilp.rs @@ -22,7 +22,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); // Both should find optimal size = 2 @@ -80,7 +80,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); @@ -103,7 +103,7 @@ fn test_minimummetricdimension_to_ilp_complete_graph() { let bf_size = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_size = problem.evaluate(&extracted); assert_eq!(bf_size, Min(Some(3))); @@ -117,7 +117,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0]); // Verify this is a valid resolving set @@ -136,7 +136,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(problem.evaluate(&extracted), Min(Some(2))); diff --git a/src/unit_tests/rules/minimummultiwaycut_ilp.rs b/src/unit_tests/rules/minimummultiwaycut_ilp.rs index b5a6e5046..7530a3c0f 100644 --- a/src/unit_tests/rules/minimummultiwaycut_ilp.rs +++ b/src/unit_tests/rules/minimummultiwaycut_ilp.rs @@ -42,7 +42,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); // Optimal cut cost is 8 @@ -63,7 +63,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let obj = problem.evaluate(&extracted); assert_eq!(obj, Min(Some(6))); @@ -81,7 +81,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let obj = problem.evaluate(&extracted); assert_eq!(obj, Min(Some(1))); @@ -118,7 +118,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 1, 1, 0]); let obj = problem.evaluate(&extracted); diff --git a/src/unit_tests/rules/minimummultiwaycut_qubo.rs b/src/unit_tests/rules/minimummultiwaycut_qubo.rs index 4b42b97c7..0f130208e 100644 --- a/src/unit_tests/rules/minimummultiwaycut_qubo.rs +++ b/src/unit_tests/rules/minimummultiwaycut_qubo.rs @@ -19,7 +19,7 @@ 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); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = source.evaluate(&extracted); assert_eq!(metric, Min(Some(8))); } @@ -41,7 +41,7 @@ fn test_minimummultiwaycut_to_qubo_small() { // All solutions should extract to valid cuts for sol in &qubo_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = source.evaluate(&extracted); // With 2 terminals and path 0-1-2, minimum cut is 1 (cut either edge) assert_eq!(metric, Min(Some(1))); diff --git a/src/unit_tests/rules/minimumsetcovering_ilp.rs b/src/unit_tests/rules/minimumsetcovering_ilp.rs index 4e154c1a6..aad3616a0 100644 --- a/src/unit_tests/rules/minimumsetcovering_ilp.rs +++ b/src/unit_tests/rules/minimumsetcovering_ilp.rs @@ -56,7 +56,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Both should find optimal size = 2 let bf_size: usize = bf_solutions[0].iter().sum(); @@ -92,7 +92,7 @@ fn test_ilp_solution_equals_brute_force_weighted() { let bf_obj = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_obj = problem.evaluate(&extracted); assert_eq!(bf_obj, Min(Some(6))); @@ -109,7 +109,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); // Verify this is a valid set cover @@ -137,7 +137,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // First set alone covers everything with weight 1 assert_eq!(extracted, vec![1, 0, 0, 0]); @@ -156,7 +156,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Need both sets to cover all elements assert_eq!(extracted, vec![1, 1]); diff --git a/src/unit_tests/rules/minimumsummulticenter_ilp.rs b/src/unit_tests/rules/minimumsummulticenter_ilp.rs index 2f9047f6f..f95493000 100644 --- a/src/unit_tests/rules/minimumsummulticenter_ilp.rs +++ b/src/unit_tests/rules/minimumsummulticenter_ilp.rs @@ -48,7 +48,7 @@ fn test_minimumsummulticenter_to_ilp_bf_vs_ilp() { let bf_cost = problem.evaluate(&bf_witness).unwrap(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted.len(), 3, @@ -84,7 +84,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted, bf_witness, @@ -112,7 +112,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert_eq!(problem.evaluate(&extracted).unwrap(), 2); } @@ -130,7 +130,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(extracted, vec![1]); assert_eq!(problem.evaluate(&extracted).unwrap(), 0); diff --git a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs index ba211afee..d40fdedb0 100644 --- a/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs +++ b/src/unit_tests/rules/minimumtardinesssequencing_ilp.rs @@ -31,7 +31,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -46,7 +46,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); } @@ -58,7 +58,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); assert_eq!(value.0, Some(2)); @@ -95,7 +95,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); 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 ecf5c4322..a5e3f7dcc 100644 --- a/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/unit_tests/rules/minimumvertexcover_comparativecontainment.rs @@ -89,7 +89,7 @@ fn test_minimumvertexcover_to_comparativecontainment_extracts_cover() { let witness = BruteForce::new() .find_witness(reduction.target_problem()) .expect("triangle with K=2 should be satisfiable"); - let extracted = reduction.extract_solution(&witness); + let extracted = reduction.extract_solution(&witness).unwrap(); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).0); } @@ -110,7 +110,7 @@ fn test_minimumvertexcover_to_comparativecontainment_trivial_yes_k_equals_n() { assert!(target.evaluate(&[]).0); // Extracted source configuration must be a valid cover with size <= K. - let extracted = reduction.extract_solution(&[]); + let extracted = reduction.extract_solution(&[]).unwrap(); assert_eq!(extracted.len(), 3); assert!(source.evaluate(&extracted).0); } @@ -123,7 +123,7 @@ fn test_minimumvertexcover_to_comparativecontainment_trivial_yes_k_greater_than_ let target = reduction.target_problem(); assert_eq!(target.universe_size(), 0); - let extracted = reduction.extract_solution(&[]); + let extracted = reduction.extract_solution(&[]).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs index afb8d3af6..38bb414c0 100644 --- a/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/unit_tests/rules/minimumvertexcover_ensemblecomputation.rs @@ -40,7 +40,7 @@ fn test_minimumvertexcover_to_ensemblecomputation_closed_loop() { // Every extracted solution must be a valid vertex cover let witnesses = solver.find_all_witnesses(target); for witness in &witnesses { - let source_config = reduction.extract_solution(witness); + let source_config = reduction.extract_solution(witness).unwrap(); assert_eq!(source_config.len(), 2); assert!( is_valid_cover(&graph, &source_config), @@ -100,7 +100,7 @@ fn test_extract_solution_correctness() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&config), Min(Some(2))); - let cover = reduction.extract_solution(&config); + let cover = reduction.extract_solution(&config).unwrap(); assert_eq!(cover, vec![1, 1]); assert!(is_valid_cover(&graph, &cover)); } @@ -117,7 +117,7 @@ fn test_extract_from_non_normalized_witness() { let target = reduction.target_problem(); assert_eq!(target.evaluate(&config), Min(Some(2))); - let cover = reduction.extract_solution(&config); + let cover = reduction.extract_solution(&config).unwrap(); assert_eq!(cover, vec![1, 1]); assert!(is_valid_cover(&graph, &cover)); } diff --git a/src/unit_tests/rules/minimumvertexcover_ilp.rs b/src/unit_tests/rules/minimumvertexcover_ilp.rs index 9f2810255..63426eeac 100644 --- a/src/unit_tests/rules/minimumvertexcover_ilp.rs +++ b/src/unit_tests/rules/minimumvertexcover_ilp.rs @@ -63,7 +63,7 @@ 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 = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); let ilp_size: usize = extracted.iter().sum(); assert_eq!(ilp_size, 2); @@ -79,7 +79,7 @@ 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); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert_eq!(extracted, vec![0, 1, 0]); @@ -96,6 +96,6 @@ fn test_minimumvertexcover_to_ilp_bf_vs_ilp() { let bf_solutions = BruteForce::new().find_all_witnesses(&problem); let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = chain.extract_solution(&ilp_solution); + let extracted = chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs index 5b6673a24..a397399cc 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -108,7 +108,7 @@ fn test_solution_extraction() { // Target has 9 arcs; first 3 are internal. Extract should take first 3. let target_config = vec![1, 1, 0, 0, 0, 0, 0, 0, 0]; - let source_config = reduction.extract_solution(&target_config); + let source_config = reduction.extract_solution(&target_config).unwrap(); assert_eq!(source_config, vec![1, 1, 0]); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs index 4ae5fd263..c9155c35a 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumfeedbackvertexset.rs @@ -77,7 +77,7 @@ fn test_identity_solution_extraction() { ReduceTo::>::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 1, 0, 1]), + reduction.extract_solution(&[1, 0, 1, 0, 1]).unwrap(), vec![1, 0, 1, 0, 1] ); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs index 9e7a7b6a3..99c749b7a 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumhittingset.rs @@ -123,6 +123,6 @@ fn test_vc_to_hs_solution_extraction() { let reduction = ReduceTo::::reduce_to(&vc_problem); let target_solution = vec![0, 1, 0]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); } diff --git a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs index 641468712..1cf25a481 100644 --- a/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/unit_tests/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -81,7 +81,10 @@ fn test_weighted_vertices_are_charged_on_sink_arcs() { assert_eq!(source.evaluate(&[0, 1, 0]), Min(Some(1))); assert_eq!(target.evaluate(&target_solution), Min(Some(5))); assert_eq!(target.arc_weights(), &[1, 1, 1, 1, 1, 1, 4, 1, 3]); - assert_eq!(reduction.extract_solution(&target_solution), vec![0, 1, 0]); + assert_eq!( + reduction.extract_solution(&target_solution).unwrap(), + vec![0, 1, 0] + ); } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/rules/minimumvertexcover_qubo.rs b/src/unit_tests/rules/minimumvertexcover_qubo.rs index c610e3ced..412603802 100644 --- a/src/unit_tests/rules/minimumvertexcover_qubo.rs +++ b/src/unit_tests/rules/minimumvertexcover_qubo.rs @@ -60,7 +60,7 @@ fn test_minimumvertexcover_to_qubo_via_path_closed_loop() { let solver = BruteForce::new(); let qubo_solutions = solver.find_all_witnesses(qubo); for sol in &qubo_solutions { - let extracted = chain.extract_solution(sol); + let extracted = chain.extract_solution(sol).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 2); } @@ -77,7 +77,7 @@ fn test_minimumvertexcover_to_qubo_via_path_weighted() { let qubo_solution = solver .find_witness(qubo) .expect("QUBO should be solvable via path"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert_eq!(extracted, vec![0, 1, 0]); @@ -96,7 +96,7 @@ fn test_minimumvertexcover_to_qubo_via_path_star_graph() { let solver = BruteForce::new(); let qubo_solution = solver.find_witness(qubo).expect("QUBO should be solvable"); - let extracted = chain.extract_solution(&qubo_solution); + let extracted = chain.extract_solution(&qubo_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); assert_eq!(extracted.iter().filter(|&&x| x == 1).count(), 1); diff --git a/src/unit_tests/rules/minimumweightdecoding_ilp.rs b/src/unit_tests/rules/minimumweightdecoding_ilp.rs index 3f5dd0c8e..fd3702ea4 100644 --- a/src/unit_tests/rules/minimumweightdecoding_ilp.rs +++ b/src/unit_tests/rules/minimumweightdecoding_ilp.rs @@ -62,7 +62,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(ilp_value, bf_value); @@ -82,7 +82,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), bf_value); } @@ -114,7 +114,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 4); assert_eq!(extracted, vec![0, 0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); diff --git a/src/unit_tests/rules/minmaxmulticenter_ilp.rs b/src/unit_tests/rules/minmaxmulticenter_ilp.rs index 0bf3b8787..506c1cb2d 100644 --- a/src/unit_tests/rules/minmaxmulticenter_ilp.rs +++ b/src/unit_tests/rules/minmaxmulticenter_ilp.rs @@ -51,7 +51,7 @@ fn test_minmaxmulticenter_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Min(Some(1))); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted.len(), 3, @@ -80,7 +80,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(1))); } @@ -104,7 +104,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(100))); } @@ -119,7 +119,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); } diff --git a/src/unit_tests/rules/mixedchinesepostman_ilp.rs b/src/unit_tests/rules/mixedchinesepostman_ilp.rs index cda1e1b40..d9307dd8e 100644 --- a/src/unit_tests/rules/mixedchinesepostman_ilp.rs +++ b/src/unit_tests/rules/mixedchinesepostman_ilp.rs @@ -22,7 +22,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted).0.is_some()); } @@ -42,7 +42,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = source.evaluate(&extracted); assert_eq!( @@ -66,7 +66,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = source.evaluate(&extracted); assert_eq!( diff --git a/src/unit_tests/rules/monochromatictriangle_ilp.rs b/src/unit_tests/rules/monochromatictriangle_ilp.rs index 7e7cc9119..e078f8621 100644 --- a/src/unit_tests/rules/monochromatictriangle_ilp.rs +++ b/src/unit_tests/rules/monochromatictriangle_ilp.rs @@ -46,7 +46,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, ilp_solution); assert!(problem.evaluate(&extracted)); @@ -75,7 +75,7 @@ fn test_monochromatic_triangle_to_ilp_extract_solution_identity() { let reduction = ReduceTo::>::reduce_to(&problem); let coloring = vec![0, 0, 1, 1, 0, 1]; - let extracted = reduction.extract_solution(&coloring); + let extracted = reduction.extract_solution(&coloring).unwrap(); assert_eq!(extracted, coloring); assert!(problem.evaluate(&extracted)); diff --git a/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs b/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs index 7c4cbfcd1..2223b08ea 100644 --- a/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs +++ b/src/unit_tests/rules/multiplecopyfileallocation_ilp.rs @@ -46,7 +46,7 @@ fn test_multiplecopyfileallocation_to_ilp_bf_vs_ilp() { assert!(problem.evaluate(&bf_witness).0.is_some()); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( extracted.len(), 3, @@ -73,7 +73,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(7))); } @@ -91,7 +91,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted), Min(Some(3))); } diff --git a/src/unit_tests/rules/multiprocessorscheduling_ilp.rs b/src/unit_tests/rules/multiprocessorscheduling_ilp.rs index 311d7dedf..58c9f7a5f 100644 --- a/src/unit_tests/rules/multiprocessorscheduling_ilp.rs +++ b/src/unit_tests/rules/multiprocessorscheduling_ilp.rs @@ -45,7 +45,7 @@ fn test_multiprocessorscheduling_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -62,7 +62,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 0]); // loads: proc 0 = 1+3=4 ≤ 5, proc 1 = 2 ≤ 5 assert_eq!(problem.evaluate(&extracted), Or(true)); @@ -82,6 +82,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/naesatisfiability_ilp.rs b/src/unit_tests/rules/naesatisfiability_ilp.rs index d4e7504ae..1cf6da83c 100644 --- a/src/unit_tests/rules/naesatisfiability_ilp.rs +++ b/src/unit_tests/rules/naesatisfiability_ilp.rs @@ -44,7 +44,7 @@ fn test_naesatisfiability_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -98,7 +98,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), diff --git a/src/unit_tests/rules/naesatisfiability_maxcut.rs b/src/unit_tests/rules/naesatisfiability_maxcut.rs index 265df4574..4e833f639 100644 --- a/src/unit_tests/rules/naesatisfiability_maxcut.rs +++ b/src/unit_tests/rules/naesatisfiability_maxcut.rs @@ -106,7 +106,7 @@ fn test_naesatisfiability_to_maxcut_extract_solution() { // 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![1, 0, 0, 1, 1, 0]; - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); // x1=T, x2=F, x3=T // Verify this is a valid NAE-SAT solution diff --git a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs index 412748a98..8a2b26709 100644 --- a/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/unit_tests/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -246,7 +246,7 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_constructed_witness_r assert!(source.evaluate(&source_solution)); assert!(reduction.target_problem().evaluate(&target_solution)); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), source_solution ); } @@ -264,7 +264,7 @@ fn test_naesatisfiability_to_partitionintoperfectmatchings_two_literal_clause_no assert_eq!(target.num_matchings(), 2); assert!(target.evaluate(&target_solution)); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), source_solution ); } diff --git a/src/unit_tests/rules/naesatisfiability_setsplitting.rs b/src/unit_tests/rules/naesatisfiability_setsplitting.rs index a52ea2206..0e3d91895 100644 --- a/src/unit_tests/rules/naesatisfiability_setsplitting.rs +++ b/src/unit_tests/rules/naesatisfiability_setsplitting.rs @@ -53,7 +53,7 @@ fn test_naesatisfiability_to_setsplitting_extract_solution_uses_positive_literal let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 1, 0, 1, 0]), + reduction.extract_solution(&[1, 0, 1, 0, 1, 0]).unwrap(), vec![1, 0, 1] ); } @@ -65,7 +65,7 @@ fn test_naesatisfiability_to_setsplitting_target_witness_extracts_to_satisfying_ let solver = BruteForce::new(); let target_solution = solver.find_witness(reduction.target_problem()).unwrap(); - let source_solution = reduction.extract_solution(&target_solution); + let source_solution = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&source_solution)); } diff --git a/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 898e22396..3e76c851d 100644 --- a/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/unit_tests/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -41,7 +41,7 @@ fn test_n3dm_to_nmts_extracts_target_witness_into_source_witness() { assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![2, 0, 1, 0, 2, 1]); assert!(source.evaluate(&extracted).0); } @@ -54,7 +54,7 @@ fn test_n3dm_to_nmts_handles_repeated_targets() { assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted.len(), 4); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs index f0318d543..2d1318f6f 100644 --- a/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/unit_tests/rules/numericalmatchingwithtargetsums_ilp.rs @@ -20,7 +20,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -78,7 +78,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0]); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -98,7 +98,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/openshopscheduling_ilp.rs b/src/unit_tests/rules/openshopscheduling_ilp.rs index cd5528432..d9016c62a 100644 --- a/src/unit_tests/rules/openshopscheduling_ilp.rs +++ b/src/unit_tests/rules/openshopscheduling_ilp.rs @@ -60,7 +60,7 @@ fn test_openshopscheduling_to_ilp_closed_loop_small() { .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -78,7 +78,7 @@ fn test_openshopscheduling_to_ilp_closed_loop_medium() { .solve(reduction.target_problem()) .expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -103,7 +103,7 @@ fn test_openshopscheduling_to_ilp_extract_solution_respects_start_times() { // => M1: job 1 starts at 0, job 0 starts at 1 → order [1, 0] // => M2: job 0 starts at 0, job 1 starts at 2 → order [0, 1] let target_solution = vec![0, 1, 1, 0, 0, 2, 0, 1, 3]; - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // M1: J1 at t=0, J0 at t=1 → order [1, 0] // M2: J0 at t=0, J1 at t=2 → order [0, 1] assert_eq!(extracted[0..2], [1, 0], "M1 order should be [1, 0]"); @@ -122,7 +122,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!(value.0.is_some()); assert_eq!(value, Min(Some(7))); @@ -136,7 +136,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); 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 168d6c825..0dd22474b 100644 --- a/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -57,7 +57,7 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_closed_loo assert_eq!(target.evaluate(&target_witness), Or(true)); // Reconstructed source arrangement must be a valid arrangement of length <= k. - let arrangement = reduction.extract_solution(&target_witness); + let arrangement = reduction.extract_solution(&target_witness).unwrap(); assert_eq!(source.evaluate(&arrangement), Or(true)); } @@ -95,7 +95,7 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_edgeless_s assert_eq!(target.evaluate(&witness), Or(true)); // Reconstructed source arrangement covers all 3 vertices and is YES. - let arrangement = reduction.extract_solution(&witness); + let arrangement = reduction.extract_solution(&witness).unwrap(); assert_eq!(arrangement.len(), 3); assert_eq!(source.evaluate(&arrangement), Or(true)); } @@ -132,18 +132,21 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_negative_b #[test] fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_extract_invalid() { - // A non-permutation target solution falls back to the identity arrangement. let source = decision_ola(example_graph(), 11); let reduction = ReduceTo::::reduce_to(&source); - // Wrong length. assert_eq!( - reduction.extract_solution(&[0, 1, 2]), - vec![0, 1, 2, 3, 4, 5] + reduction + .extract_solution(&[0, 1, 2]) + .unwrap_err() + .to_string(), + "expected a permutation of 6 columns, got 3 entries" ); - // Repeated column. assert_eq!( - reduction.extract_solution(&[0, 0, 1, 2, 3, 4]), - vec![0, 1, 2, 3, 4, 5] + reduction + .extract_solution(&[0, 0, 1, 2, 3, 4]) + .unwrap_err() + .to_string(), + "target column order is not a permutation" ); } diff --git a/src/unit_tests/rules/optimallineararrangement_ilp.rs b/src/unit_tests/rules/optimallineararrangement_ilp.rs index 661b50569..50cb2a547 100644 --- a/src/unit_tests/rules/optimallineararrangement_ilp.rs +++ b/src/unit_tests/rules/optimallineararrangement_ilp.rs @@ -31,7 +31,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0.is_some(), "ILP solution should produce a valid arrangement" @@ -59,7 +59,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } @@ -71,7 +71,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs b/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs index f26fdcd03..9e83885fd 100644 --- a/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs +++ b/src/unit_tests/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs @@ -89,7 +89,7 @@ fn test_optimallineararrangement_to_sequencingtominimizeweightedcompletiontime_e let (source, reduction) = reduce_path(4); let schedule = vec![3, 2, 6, 1, 5, 0, 4]; let target_solution = permutation_to_lehmer(&schedule); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![3, 2, 1, 0]); assert_eq!(source.evaluate(&extracted), Min(Some(3))); diff --git a/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs b/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs index b0faca24e..561e4b4a8 100644 --- a/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs +++ b/src/unit_tests/rules/optimumcommunicationspanningtree_ilp.rs @@ -80,7 +80,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -99,7 +99,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -115,7 +115,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Should be a valid config with m=3 entries assert_eq!(extracted.len(), 3); diff --git a/src/unit_tests/rules/paintshop_ilp.rs b/src/unit_tests/rules/paintshop_ilp.rs index b728e0d61..34bfbfc7e 100644 --- a/src/unit_tests/rules/paintshop_ilp.rs +++ b/src/unit_tests/rules/paintshop_ilp.rs @@ -41,7 +41,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -56,7 +56,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); // Either 0 or 1 is valid; coloring is [x, 1-x], switches = 1 assert!(problem.evaluate(&extracted).is_valid()); diff --git a/src/unit_tests/rules/paintshop_qubo.rs b/src/unit_tests/rules/paintshop_qubo.rs index 385d60dad..39c51d6b7 100644 --- a/src/unit_tests/rules/paintshop_qubo.rs +++ b/src/unit_tests/rules/paintshop_qubo.rs @@ -47,7 +47,7 @@ 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); + let source_sol = reduction.extract_solution(sol).unwrap(); let switches = source.count_switches(&source_sol); // Optimal is 2 switches assert_eq!(switches, 2, "Expected 2 switches for optimal solution"); diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index dad812588..51d5c0229 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -13,7 +13,7 @@ use crate::models::formula::{CNFClause, Satisfiability}; use crate::models::graph::HamiltonianCircuit; use crate::rules::cost::CustomCost; use crate::rules::pareto::{GrowthLabel, PathLabel, ReductionEdge}; -use crate::rules::registry::{EdgeCapabilities, ReductionOverhead}; +use crate::rules::registry::ReductionOverhead; use crate::rules::traits::DynReductionResult; use crate::rules::{ReductionAutoCast, ReductionGraph, ReductionMode}; use crate::topology::SimpleGraph; @@ -118,7 +118,7 @@ fn measured_edge( )]), reduce_fn: Some(reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, } } @@ -228,13 +228,14 @@ fn test_measured_any_target_uses_one_request_limit_tracker() { #[test] fn test_measured_search_keeps_equal_size_structure_dependent_instances() { - let graph = ReductionGraph::from_test_edges( + let ilp_variant = ReductionGraph::variant_to_map(&ILP::::variant()); + let graph = ReductionGraph::from_test_variant_edges( &[ - "MeasuredSource", - "MeasuredBranchA", - "MeasuredBranchB", - "Satisfiability", - "ILP", + ("MeasuredSource", BTreeMap::new()), + ("MeasuredBranchA", BTreeMap::new()), + ("MeasuredBranchB", BTreeMap::new()), + ("Satisfiability", BTreeMap::new()), + ("ILP", ilp_variant.clone()), ], &[ ( @@ -267,11 +268,15 @@ fn test_measured_search_keeps_equal_size_structure_dependent_instances() { let empty = BTreeMap::new(); let source = MeasuredSource; + let ilp = ILP::::new(1, vec![], vec![], ObjectiveSense::Minimize); + let ilp_size = ReductionGraph::compute_source_size("ILP", &ilp_variant, &ilp); + assert_eq!(ilp_size.total(), 1, "measured ILP size: {ilp_size:?}"); + let bad_sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); let good_sat = Satisfiability::new(1, vec![CNFClause::new(vec![-1])]); assert_eq!( - ReductionGraph::compute_source_size("Satisfiability", &bad_sat), - ReductionGraph::compute_source_size("Satisfiability", &good_sat), + ReductionGraph::compute_source_size("Satisfiability", &empty, &bad_sat), + ReductionGraph::compute_source_size("Satisfiability", &empty, &good_sat), "the two structurally different hub instances must have identical measured sizes", ); @@ -280,7 +285,7 @@ fn test_measured_search_keeps_equal_size_structure_dependent_instances() { "MeasuredSource", &empty, "ILP", - &empty, + &ilp_variant, ReductionMode::Witness, &source, 1_000, @@ -298,8 +303,12 @@ fn test_measured_search_keeps_equal_size_structure_dependent_instances() { #[test] fn test_asymptotic_overhead_is_not_a_concrete_budget_guard() { - let graph = ReductionGraph::from_test_edges( - &["MeasuredSource", "ILP"], + let ilp_variant = ReductionGraph::variant_to_map(&ILP::::variant()); + let graph = ReductionGraph::from_test_variant_edges( + &[ + ("MeasuredSource", BTreeMap::new()), + ("ILP", ilp_variant.clone()), + ], &[( "MeasuredSource", "ILP", @@ -314,7 +323,7 @@ fn test_asymptotic_overhead_is_not_a_concrete_budget_guard() { "MeasuredSource", &empty, "ILP", - &empty, + &ilp_variant, ReductionMode::Witness, &source, 1, @@ -374,9 +383,9 @@ impl PathLabel for DiamondLabel { fn diamond_edge(c: f64, s: Expr) -> ReductionEdgeData { ReductionEdgeData { overhead: ReductionOverhead::new(vec![("c", Expr::Const(c)), ("s", s)]), - reduce_fn: None, + reduce_fn: Some(measured_source_to_a), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, } } @@ -489,9 +498,9 @@ fn powk(v: &'static str, k: f64) -> Expr { fn growth_edge(fields: Vec<(&'static str, Expr)>) -> ReductionEdgeData { ReductionEdgeData { overhead: ReductionOverhead::new(fields), - reduce_fn: None, + reduce_fn: Some(measured_source_to_a), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, } } @@ -517,7 +526,6 @@ fn test_growth_label_extend_composes_overhead() { let redge = ReductionEdge { overhead: &edge_data.overhead, reduce_fn: None, - capabilities: EdgeCapabilities::witness_only(), target_name: "Target", target_variant: &target_variant, }; @@ -534,7 +542,6 @@ fn test_growth_label_extend_composes_overhead() { let redge2 = ReductionEdge { overhead: &edge2.overhead, reduce_fn: None, - capabilities: EdgeCapabilities::witness_only(), target_name: "Target2", target_variant: &target_variant, }; @@ -565,7 +572,6 @@ fn test_growth_label_propagates_unknown() { let redge = ReductionEdge { overhead: &edge.overhead, reduce_fn: None, - capabilities: EdgeCapabilities::witness_only(), target_name: "T", target_variant: &tv, }; @@ -824,7 +830,6 @@ fn test_growth_label_monotone_overhead_preserves_order() { let redge = ReductionEdge { overhead: &overhead.overhead, reduce_fn: None, - capabilities: EdgeCapabilities::witness_only(), target_name: "T", target_variant: &tv, }; @@ -1533,7 +1538,6 @@ fn test_growth_label_taints_absent_variable() { let redge = ReductionEdge { overhead: &edge.overhead, reduce_fn: None, - capabilities: EdgeCapabilities::witness_only(), target_name: "T", target_variant: &tv, }; diff --git a/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs b/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs index 5553c3831..4a61de474 100644 --- a/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs +++ b/src/unit_tests/rules/partiallyorderedknapsack_ilp.rs @@ -26,7 +26,7 @@ fn test_partiallyorderedknapsack_to_ilp_bf_vs_ilp() { let bf_value = problem.evaluate(&bf_solutions[0]); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -41,7 +41,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).is_valid()); } diff --git a/src/unit_tests/rules/partition_binpacking.rs b/src/unit_tests/rules/partition_binpacking.rs index c70410722..482e330eb 100644 --- a/src/unit_tests/rules/partition_binpacking.rs +++ b/src/unit_tests/rules/partition_binpacking.rs @@ -45,7 +45,7 @@ fn test_partition_to_binpacking_odd_total_is_not_satisfying() { let value = target.evaluate(&best); assert_eq!(value, Min(Some(3))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/partition_cosineproductintegration.rs b/src/unit_tests/rules/partition_cosineproductintegration.rs index 410a1d9c8..739ac5916 100644 --- a/src/unit_tests/rules/partition_cosineproductintegration.rs +++ b/src/unit_tests/rules/partition_cosineproductintegration.rs @@ -69,7 +69,7 @@ fn test_partition_to_cosineproductintegration_solution_extraction() { let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), source.num_elements()); let target_valid = target.evaluate(sol); let source_valid = source.evaluate(&extracted); diff --git a/src/unit_tests/rules/partition_integralflowwithmultipliers.rs b/src/unit_tests/rules/partition_integralflowwithmultipliers.rs index 6149a2d9e..65cd581f3 100644 --- a/src/unit_tests/rules/partition_integralflowwithmultipliers.rs +++ b/src/unit_tests/rules/partition_integralflowwithmultipliers.rs @@ -71,7 +71,10 @@ 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().find_witness(target).is_none()); - assert_eq!(reduction.extract_solution(&[]), vec![0, 0]); + assert_eq!( + reduction.extract_solution(&[]).unwrap_err().to_string(), + "the fixed infeasible target instance has no extractable witness" + ); } #[test] @@ -80,7 +83,9 @@ fn test_partition_to_integralflowwithmultipliers_extract_solution() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 0, 1, 0, 1, 0, 2, 0, 4, 0, 6, 0, 12]), + reduction + .extract_solution(&[1, 0, 1, 0, 1, 0, 2, 0, 4, 0, 6, 0, 12]) + .unwrap(), vec![1, 0, 1, 0, 1, 0] ); } diff --git a/src/unit_tests/rules/partition_knapsack.rs b/src/unit_tests/rules/partition_knapsack.rs index e308d172c..edddea5f3 100644 --- a/src/unit_tests/rules/partition_knapsack.rs +++ b/src/unit_tests/rules/partition_knapsack.rs @@ -40,7 +40,7 @@ fn test_partition_to_knapsack_odd_total_is_not_satisfying() { assert_eq!(target.evaluate(&best), Max(Some(5))); - let extracted = reduction.extract_solution(&best); + let extracted = reduction.extract_solution(&best).unwrap(); assert!(!source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/partition_multiprocessorscheduling.rs b/src/unit_tests/rules/partition_multiprocessorscheduling.rs index b72884a81..0404c2087 100644 --- a/src/unit_tests/rules/partition_multiprocessorscheduling.rs +++ b/src/unit_tests/rules/partition_multiprocessorscheduling.rs @@ -83,7 +83,7 @@ fn test_partition_to_multiprocessorscheduling_solution_extraction() { let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); // 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 ff3b42f81..e02aed5cd 100644 --- a/src/unit_tests/rules/partition_openshopscheduling.rs +++ b/src/unit_tests/rules/partition_openshopscheduling.rs @@ -41,7 +41,7 @@ fn test_partition_to_open_shop_scheduling_extract_solution() { let target_solution = BruteForce::new() .find_witness(target) .expect("target should have an optimal solution"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // The extracted solution should be a valid partition decision assert_eq!(extracted.len(), 3); @@ -60,5 +60,5 @@ fn test_partition_to_open_shop_scheduling_odd_total_is_not_satisfying() { .expect("open-shop target should always have an optimal solution"); assert_eq!(target.evaluate(&best), Min(Some(16))); - assert!(!source.evaluate(&reduction.extract_solution(&best))); + assert!(!source.evaluate(&reduction.extract_solution(&best).unwrap())); } diff --git a/src/unit_tests/rules/partition_productionplanning.rs b/src/unit_tests/rules/partition_productionplanning.rs index ceca9105c..26d7f98a7 100644 --- a/src/unit_tests/rules/partition_productionplanning.rs +++ b/src/unit_tests/rules/partition_productionplanning.rs @@ -48,7 +48,7 @@ fn test_partition_to_productionplanning_extract_solution() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[0, 0, 0, 4, 6, 0]), + reduction.extract_solution(&[0, 0, 0, 4, 6, 0]).unwrap(), vec![0, 0, 0, 1, 1] ); } diff --git a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs index 75749bc3b..bd8b82888 100644 --- a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs @@ -38,7 +38,7 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_extract_solution() let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&[1, 2, 4, 5, 0, 3]), + reduction.extract_solution(&[1, 2, 4, 5, 0, 3]).unwrap(), vec![1, 0, 0, 1, 0, 0] ); } @@ -53,7 +53,7 @@ fn test_partition_to_sequencing_to_minimize_tardy_task_weight_odd_total_is_unsat .expect("target should always have an optimal schedule"); assert_eq!(target.evaluate(&best), Min(Some(6))); - assert!(!source.evaluate(&reduction.extract_solution(&best))); + assert!(!source.evaluate(&reduction.extract_solution(&best).unwrap())); } #[cfg(feature = "example-db")] diff --git a/src/unit_tests/rules/partition_subsetsum.rs b/src/unit_tests/rules/partition_subsetsum.rs index 9395e8571..c02398bcc 100644 --- a/src/unit_tests/rules/partition_subsetsum.rs +++ b/src/unit_tests/rules/partition_subsetsum.rs @@ -49,7 +49,7 @@ fn test_partition_to_subsetsum_odd_total() { assert!(witness.is_none()); // extract_solution should return all-zeros for the source - let extracted = reduction.extract_solution(&[]); + let extracted = reduction.extract_solution(&[]).unwrap(); assert_eq!(extracted, vec![0, 0, 0]); // The extracted solution should not satisfy the source assert!(!source.evaluate(&extracted)); diff --git a/src/unit_tests/rules/partition_sumofsquarespartition.rs b/src/unit_tests/rules/partition_sumofsquarespartition.rs index c1801c296..01eba1828 100644 --- a/src/unit_tests/rules/partition_sumofsquarespartition.rs +++ b/src/unit_tests/rules/partition_sumofsquarespartition.rs @@ -30,7 +30,7 @@ fn test_partition_to_sumofsquarespartition_closed_loop() { let target_witnesses = solver.find_all_witnesses(target_no_even); assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction_no_even.extract_solution(witness); + let extracted = reduction_no_even.extract_solution(witness).unwrap(); assert_eq!(extracted.len(), source_no_even.num_elements()); assert!( !source_no_even.evaluate(&extracted).0, @@ -47,7 +47,7 @@ fn test_partition_to_sumofsquarespartition_closed_loop() { let target_witnesses_odd = solver.find_all_witnesses(target_no_odd); assert!(!target_witnesses_odd.is_empty()); for witness in &target_witnesses_odd { - let extracted = reduction_no_odd.extract_solution(witness); + let extracted = reduction_no_odd.extract_solution(witness).unwrap(); assert!( !source_no_odd.evaluate(&extracted).0, "odd-sum NO Partition: extracted witness {extracted:?} should not satisfy source" @@ -104,7 +104,7 @@ fn test_partition_to_sumofsquarespartition_singleton_sentinel() { assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted.len(), source.num_elements()); assert_eq!(extracted, vec![0]); assert!( @@ -130,7 +130,7 @@ fn test_partition_to_sumofsquarespartition_solution_extraction_identity() { solver.find_all_witnesses(&source).into_iter().collect(); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted, *witness); assert!( source_witnesses.contains(&extracted), diff --git a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs index b1b5d9836..492b51cf4 100644 --- a/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/unit_tests/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -2,7 +2,7 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; use crate::topology::Graph; use crate::traits::Problem; -use crate::types::{Min, Or}; +use crate::types::Min; #[test] fn test_partitionintocliques_to_minimumcoveringbycliques_closed_loop() { @@ -68,7 +68,10 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_orlin_example_structure ], ); assert_eq!(target.evaluate(&target_solution), Min(Some(6))); - assert_eq!(reduction.extract_solution(&target_solution), vec![0, 0, 1]); + assert_eq!( + reduction.extract_solution(&target_solution).unwrap(), + vec![0, 0, 1] + ); } #[test] @@ -97,7 +100,11 @@ fn test_partitionintocliques_to_minimumcoveringbycliques_unsat_extracts_invalid_ ); assert_eq!(target.evaluate(&target_solution), Min(Some(4))); - let extracted = reduction.extract_solution(&target_solution); - - assert_eq!(source.evaluate(&extracted), Or(false)); + assert_eq!( + reduction + .extract_solution(&target_solution) + .unwrap_err() + .to_string(), + "target cover uses 2 cliques, exceeding source bound 1" + ); } diff --git a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index 66482bc1e..83a9a0898 100644 --- a/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/unit_tests/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -88,7 +88,7 @@ fn test_partitionintopathsoflength2_to_boundedcomponentspanningforest_extract_so let result = ReduceTo::>::reduce_to(&source); let target_config = vec![0, 0, 0, 1, 1, 1]; - let extracted = result.extract_solution(&target_config); + let extracted = result.extract_solution(&target_config).unwrap(); 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 a76085cd5..365f11f6f 100644 --- a/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs +++ b/src/unit_tests/rules/partitionintopathsoflength2_ilp.rs @@ -41,7 +41,7 @@ fn test_partitionintopathsoflength2_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -65,7 +65,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -80,7 +80,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), diff --git a/src/unit_tests/rules/partitionintotriangles_ilp.rs b/src/unit_tests/rules/partitionintotriangles_ilp.rs index e66443b53..982df3ada 100644 --- a/src/unit_tests/rules/partitionintotriangles_ilp.rs +++ b/src/unit_tests/rules/partitionintotriangles_ilp.rs @@ -41,7 +41,7 @@ fn test_partitionintotriangles_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -59,7 +59,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 0, 1, 1, 1]); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -74,6 +74,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs b/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs index a6f971326..b3a9b4476 100644 --- a/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/unit_tests/rules/pathconstrainednetworkflow_ilp.rs @@ -25,7 +25,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs index b921910b7..4c2bca369 100644 --- a/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/precedenceconstrainedscheduling_ilp.rs @@ -44,7 +44,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -70,7 +70,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/preemptivescheduling_ilp.rs b/src/unit_tests/rules/preemptivescheduling_ilp.rs index 210b8aa3b..2ef0c0448 100644 --- a/src/unit_tests/rules/preemptivescheduling_ilp.rs +++ b/src/unit_tests/rules/preemptivescheduling_ilp.rs @@ -47,7 +47,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -72,7 +72,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = p.evaluate(&extracted); assert!( value.0.is_some(), @@ -109,7 +109,7 @@ fn test_preemptivescheduling_to_ilp_extract_solution() { let p = small_instance(); let reduction: ReductionPSToILP = ReduceTo::>::reduce_to(&p); let ilp_solution = vec![1, 0, 0, 1, 2]; // last element is M - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 0, 0, 1]); assert_eq!(p.evaluate(&extracted), Min(Some(2))); } diff --git a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs index 57001da27..9c9fc12ca 100644 --- a/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/unit_tests/rules/prizecollectingsteinerforest_steinertree.rs @@ -65,7 +65,7 @@ fn test_prizecollectingsteinerforest_to_steinertree_extract_witness_canonical() let target_witness = BruteForce::new() .find_witness(target) .expect("target SteinerTree must be feasible"); - let source_witness = reduction.extract_solution(&target_witness); + let source_witness = reduction.extract_solution(&target_witness).unwrap(); // Source layout is `n` vertex-bits then `m` edge-bits. assert_eq!(source_witness.len(), source.num_variables()); diff --git a/src/unit_tests/rules/quadraticassignment_ilp.rs b/src/unit_tests/rules/quadraticassignment_ilp.rs index d7a648d0e..7a008132f 100644 --- a/src/unit_tests/rules/quadraticassignment_ilp.rs +++ b/src/unit_tests/rules/quadraticassignment_ilp.rs @@ -33,7 +33,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( @@ -61,7 +61,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.is_valid()); @@ -76,7 +76,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let metric = problem.evaluate(&extracted); assert!(metric.is_valid()); } @@ -99,7 +99,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.is_valid()); diff --git a/src/unit_tests/rules/qubo_ilp.rs b/src/unit_tests/rules/qubo_ilp.rs index 5895e0a4f..b3606c20a 100644 --- a/src/unit_tests/rules/qubo_ilp.rs +++ b/src/unit_tests/rules/qubo_ilp.rs @@ -29,7 +29,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = qubo.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -49,7 +49,7 @@ fn test_qubo_to_ilp_diagonal_only() { let solver = BruteForce::new(); let best = solver.find_all_witnesses(ilp); - let extracted = reduction.extract_solution(&best[0]); + let extracted = reduction.extract_solution(&best[0]).unwrap(); assert_eq!(extracted, vec![0, 1]); } @@ -72,6 +72,6 @@ fn test_qubo_to_ilp_3var() { let solver = BruteForce::new(); let best = solver.find_all_witnesses(ilp); - let extracted = reduction.extract_solution(&best[0]); + let extracted = reduction.extract_solution(&best[0]).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } diff --git a/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs b/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs index 9ad9e01be..b07d1b386 100644 --- a/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs +++ b/src/unit_tests/rules/rectilinearpicturecompression_ilp.rs @@ -26,7 +26,7 @@ fn test_rectilinearpicturecompression_to_ilp_bf_vs_ilp() { assert_eq!(problem.evaluate(&bf_witness), Or(true)); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -38,7 +38,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/reduction_path_parity.rs b/src/unit_tests/rules/reduction_path_parity.rs index 4d5d6ef2e..ffb641025 100644 --- a/src/unit_tests/rules/reduction_path_parity.rs +++ b/src/unit_tests/rules/reduction_path_parity.rs @@ -61,7 +61,7 @@ fn test_jl_parity_maxcut_to_spinglass_path() { let solver = BruteForce::new(); let target_solution = solver.find_witness(target).unwrap(); - let source_solution = chain.extract_solution(&target_solution); + let source_solution = chain.extract_solution(&target_solution).unwrap(); // Source solution should be valid let metric = source.evaluate(&source_solution); @@ -164,7 +164,7 @@ 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); + let factoring_solution = reduction.extract_solution(&ilp_solution).unwrap(); let metric = factoring.evaluate(&factoring_solution); assert_eq!( metric.unwrap(), diff --git a/src/unit_tests/rules/registersufficiency_ilp.rs b/src/unit_tests/rules/registersufficiency_ilp.rs index 504f86727..6d4f6b094 100644 --- a/src/unit_tests/rules/registersufficiency_ilp.rs +++ b/src/unit_tests/rules/registersufficiency_ilp.rs @@ -50,7 +50,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); let mut sorted = extracted.clone(); @@ -105,7 +105,7 @@ fn test_register_sufficiency_to_ilp_canonical_example_spec() { let solution = &example.solutions[0]; assert_eq!(source.evaluate(&solution.source_config), Or(true)); assert_eq!( - reduction.extract_solution(&solution.target_config), + reduction.extract_solution(&solution.target_config).unwrap(), solution.source_config ); } diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index eeabcf017..3512135a2 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -1,6 +1,5 @@ use super::*; use crate::expr::Expr; -use crate::rules::registry::EdgeCapabilities; use std::path::Path; /// Dummy reduce_fn for unit tests that don't exercise runtime reduction. @@ -53,7 +52,7 @@ fn test_reduction_entry_overhead() { module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -75,7 +74,7 @@ fn test_reduction_entry_debug() { module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -96,7 +95,7 @@ fn test_is_base_reduction_unweighted() { module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -114,7 +113,7 @@ fn test_is_base_reduction_source_weighted() { module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -132,7 +131,7 @@ fn test_is_base_reduction_target_weighted() { module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -150,7 +149,7 @@ fn test_is_base_reduction_both_weighted() { module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -169,7 +168,7 @@ fn test_is_base_reduction_no_weight_key() { module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, - capabilities: EdgeCapabilities::witness_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -187,7 +186,7 @@ fn test_reduction_entry_can_store_aggregate_executor() { module_path: "test::module", reduce_fn: None, reduce_aggregate_fn: Some(dummy_reduce_aggregate_fn), - capabilities: EdgeCapabilities::aggregate_only(), + turing: false, overhead_eval_fn: dummy_overhead_eval_fn, source_size_fn: dummy_source_size_fn, }; @@ -370,7 +369,7 @@ fn walk_rust_files(dir: &Path, files: &mut Vec) { } } -fn reduction_attribute_has_extra_top_level_field(path: &Path) -> bool { +fn reduction_attribute_does_not_start_with_overhead(path: &Path) -> bool { let contents = std::fs::read_to_string(path).unwrap(); let mut in_reduction_attr = false; let mut attr_text = String::new(); @@ -443,13 +442,13 @@ fn every_registered_reduction_has_non_empty_names() { } #[test] -fn repo_reductions_use_overhead_only_attribute() { +fn repo_reduction_attributes_start_with_overhead() { let mut rust_files = Vec::new(); walk_rust_files(Path::new("src/rules"), &mut rust_files); let offenders: Vec<_> = rust_files .into_iter() - .filter(|path| reduction_attribute_has_extra_top_level_field(path)) + .filter(|path| reduction_attribute_does_not_start_with_overhead(path)) .collect(); assert!( @@ -460,35 +459,36 @@ fn repo_reductions_use_overhead_only_attribute() { } #[test] -fn test_edge_capabilities_constructors() { - let wo = EdgeCapabilities::witness_only(); - assert!(wo.witness); - assert!(!wo.aggregate); - - let ao = EdgeCapabilities::aggregate_only(); - assert!(!ao.witness); - assert!(ao.aggregate); - - let both = EdgeCapabilities::both(); - assert!(both.witness); - assert!(both.aggregate); - - let none = EdgeCapabilities::none(); - assert!(!none.witness); - assert!(!none.aggregate); - assert!(!none.turing); -} +fn test_edge_capabilities_come_from_executors() { + let entry = ReductionEntry { + source_name: "A", + target_name: "B", + source_variant_fn: Vec::new, + target_variant_fn: Vec::new, + overhead_fn: ReductionOverhead::default, + module_path: "test::module", + reduce_fn: Some(dummy_reduce_fn), + reduce_aggregate_fn: Some(dummy_reduce_aggregate_fn), + turing: false, + overhead_eval_fn: dummy_overhead_eval_fn, + source_size_fn: dummy_source_size_fn, + }; + let caps = entry.capabilities(); + assert!(caps.witness); + assert!(caps.aggregate); + assert!(!caps.turing); -#[test] -fn test_edge_capabilities_default_is_witness_only() { - let default = EdgeCapabilities::default(); - assert_eq!(default, EdgeCapabilities::witness_only()); + let json = serde_json::to_string(&caps).unwrap(); + assert_eq!(json, r#"{"witness":true,"aggregate":true,"turing":false}"#); } #[test] fn test_edge_capabilities_serde_roundtrip() { - let caps = EdgeCapabilities::both(); - let json = serde_json::to_string(&caps).unwrap(); - let back: EdgeCapabilities = serde_json::from_str(&json).unwrap(); - assert_eq!(caps, back); + let json = r#"{"witness":true,"aggregate":false,"turing":true}"#; + let capabilities: EdgeCapabilities = serde_json::from_str(json).unwrap(); + + assert!(capabilities.witness); + assert!(!capabilities.aggregate); + assert!(capabilities.turing); + assert_eq!(serde_json::to_string(&capabilities).unwrap(), json); } diff --git a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs index 8acd6b484..fcd12b8eb 100644 --- a/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/unit_tests/rules/resourceconstrainedscheduling_ilp.rs @@ -40,7 +40,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs index ec413aeb6..3fdaa50ab 100644 --- a/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/unit_tests/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -83,7 +83,7 @@ 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); + let source_config = reduction.extract_solution(&target_config).unwrap(); // 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 93a6f1958..a97f8d7b2 100644 --- a/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs +++ b/src/unit_tests/rules/rootedtreestorageassignment_ilp.rs @@ -35,7 +35,7 @@ fn test_rootedtreestorageassignment_to_ilp_bf_vs_ilp() { match ilp_result { Ok(ilp_solution) => { - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!(ilp_value.0, "ILP solution should be feasible"); assert!(bf_value.0, "BF should also find feasible solution"); @@ -74,7 +74,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 3); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/ruralpostman_ilp.rs b/src/unit_tests/rules/ruralpostman_ilp.rs index 8798cd6ea..69ee2f30b 100644 --- a/src/unit_tests/rules/ruralpostman_ilp.rs +++ b/src/unit_tests/rules/ruralpostman_ilp.rs @@ -22,7 +22,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(source.evaluate(&extracted).0.is_some()); } @@ -47,7 +47,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = source.evaluate(&extracted); assert!(ilp_value.0.is_some(), "ILP solution must be valid"); diff --git a/src/unit_tests/rules/sat_circuitsat.rs b/src/unit_tests/rules/sat_circuitsat.rs index 77b27e20a..903f0c37c 100644 --- a/src/unit_tests/rules/sat_circuitsat.rs +++ b/src/unit_tests/rules/sat_circuitsat.rs @@ -62,7 +62,7 @@ fn test_sat_to_circuitsat_single_literal_clause() { let target_solution = solve_satisfaction_problem(result.target_problem()) .expect("CircuitSAT should have a satisfying solution"); - let extracted = result.extract_solution(&target_solution); + let extracted = result.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); } diff --git a/src/unit_tests/rules/sat_coloring.rs b/src/unit_tests/rules/sat_coloring.rs index a193f02bb..da70226a2 100644 --- a/src/unit_tests/rules/sat_coloring.rs +++ b/src/unit_tests/rules/sat_coloring.rs @@ -81,7 +81,7 @@ 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); + let sat_sol = reduction.extract_solution(sol).unwrap(); let assignment: Vec = sat_sol.iter().map(|&v| v == 1).collect(); if sat.is_satisfying(&assignment) { found_satisfying = true; @@ -194,7 +194,7 @@ fn test_single_literal_clauses() { let mut found_correct = false; for sol in &solutions { - let sat_sol = reduction.extract_solution(sol); + let sat_sol = reduction.extract_solution(sol).unwrap(); if sat_sol == vec![1, 1] { found_correct = true; break; @@ -272,7 +272,7 @@ 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); + let extracted = reduction.extract_solution(&valid_coloring).unwrap(); // x1 should be true (1) because vertex 3 has color 0 which equals TRUE vertex's color assert_eq!(extracted, vec![1]); } @@ -287,14 +287,14 @@ 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); + let extracted = reduction.extract_solution(&coloring_permuted).unwrap(); // x1 should still be true because its color equals TRUE vertex's color assert_eq!(extracted, vec![1]); // 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); + let extracted2 = reduction.extract_solution(&coloring_permuted2).unwrap(); assert_eq!(extracted2, vec![1]); } @@ -323,7 +323,7 @@ fn test_jl_parity_sat_to_coloring() { let target_sol = ilp_solver .solve_reduced::(target) .expect("ILP should find a coloring"); - let extracted = result.extract_solution(&target_sol); + let extracted = result.extract_solution(&target_sol).unwrap(); let best_source: HashSet> = BruteForce::new() .find_all_witnesses(&source) .into_iter() diff --git a/src/unit_tests/rules/sat_ksat.rs b/src/unit_tests/rules/sat_ksat.rs index 3c20a3c05..2eb0210d3 100644 --- a/src/unit_tests/rules/sat_ksat.rs +++ b/src/unit_tests/rules/sat_ksat.rs @@ -152,7 +152,7 @@ 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); + let sat_sol = reduction.extract_solution(ksat_sol).unwrap(); // Should only have original 2 variables assert_eq!(sat_sol.len(), 2); // Should satisfy original problem @@ -188,7 +188,7 @@ fn test_3sat_to_sat_solution_extraction() { let reduction = ReduceTo::::reduce_to(&ksat); let sol = vec![1, 0, 1]; - let extracted = reduction.extract_solution(&sol); + let extracted = reduction.extract_solution(&sol).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } diff --git a/src/unit_tests/rules/sat_maximumindependentset.rs b/src/unit_tests/rules/sat_maximumindependentset.rs index 9c2bd8f12..d55f2ab58 100644 --- a/src/unit_tests/rules/sat_maximumindependentset.rs +++ b/src/unit_tests/rules/sat_maximumindependentset.rs @@ -86,12 +86,12 @@ fn test_extract_solution_basic() { // Select vertex 0 (literal x1) let is_sol = vec![1, 0]; - let sat_sol = reduction.extract_solution(&is_sol); + let sat_sol = reduction.extract_solution(&is_sol).unwrap(); assert_eq!(sat_sol, vec![1, 0]); // x1=true, x2=false // Select vertex 1 (literal x2) let is_sol = vec![0, 1]; - let sat_sol = reduction.extract_solution(&is_sol); + let sat_sol = reduction.extract_solution(&is_sol).unwrap(); assert_eq!(sat_sol, vec![0, 1]); // x1=false, x2=true } @@ -102,7 +102,7 @@ fn test_extract_solution_with_negation() { let reduction = ReduceTo::>::reduce_to(&sat); let is_sol = vec![1]; - let sat_sol = reduction.extract_solution(&is_sol); + let sat_sol = reduction.extract_solution(&is_sol).unwrap(); assert_eq!(sat_sol, vec![0]); // x1=false (so NOT x1 is true) } @@ -217,7 +217,7 @@ fn test_jl_parity_sat_to_independentset() { if sat_solutions.is_empty() { let target_solution = solve_optimization_problem(result.target_problem()) .expect("SAT->IS: target should have an optimal solution"); - let extracted = result.extract_solution(&target_solution); + let extracted = result.extract_solution(&target_solution).unwrap(); assert!( !source.evaluate(&extracted), "SAT->IS [{label}]: unsatisfiable but extracted satisfies" diff --git a/src/unit_tests/rules/sat_minimumdominatingset.rs b/src/unit_tests/rules/sat_minimumdominatingset.rs index a421375b0..824d2d3c9 100644 --- a/src/unit_tests/rules/sat_minimumdominatingset.rs +++ b/src/unit_tests/rules/sat_minimumdominatingset.rs @@ -5,7 +5,6 @@ use crate::rules::test_helpers::{ }; use crate::solvers::BruteForce; use crate::topology::Graph; -use crate::traits::Problem; include!("../jl_helpers.rs"); #[test] @@ -50,7 +49,7 @@ fn test_extract_solution_positive_literal() { // Solution: select vertex 0 (positive literal x1) // This dominates vertices 1, 2 (gadget) and vertex 3 (clause) let ds_sol = vec![1, 0, 0, 0]; - let sat_sol = reduction.extract_solution(&ds_sol); + let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); assert_eq!(sat_sol, vec![1]); // x1 = true } @@ -63,7 +62,7 @@ fn test_extract_solution_negative_literal() { // Solution: select vertex 1 (negative literal NOT x1) // This dominates vertices 0, 2 (gadget) and vertex 3 (clause) let ds_sol = vec![0, 1, 0, 0]; - let sat_sol = reduction.extract_solution(&ds_sol); + let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); assert_eq!(sat_sol, vec![0]); // x1 = false } @@ -77,7 +76,7 @@ fn test_extract_solution_dummy() { // Vertex 0 dominates: itself, 1, 2, and clause 6 // Vertex 5 dominates: 3, 4, and itself let ds_sol = vec![1, 0, 0, 0, 0, 1, 0]; - let sat_sol = reduction.extract_solution(&ds_sol); + let sat_sol = reduction.extract_solution(&ds_sol).unwrap(); assert_eq!(sat_sol, vec![1, 0]); // x1 = true, x2 = false (from dummy) } @@ -134,15 +133,14 @@ fn test_accessors() { #[test] fn test_extract_solution_too_many_selected() { - // Test that extract_solution handles invalid (non-minimal) dominating sets let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); let reduction = ReduceTo::>::reduce_to(&sat); - // Select all 4 vertices (more than num_literals=1) let ds_sol = vec![1, 1, 1, 1]; - let sat_sol = reduction.extract_solution(&ds_sol); - // Should return default (all false) - assert_eq!(sat_sol, vec![0]); + assert_eq!( + reduction.extract_solution(&ds_sol).unwrap_err().to_string(), + "selected 4 dominating-set vertices for 1 source variables" + ); } #[test] @@ -205,11 +203,7 @@ fn test_jl_parity_sat_to_dominatingset() { if sat_solutions.is_empty() { let target_solution = solve_optimization_problem(result.target_problem()) .expect("SAT->DS: target should have an optimal solution"); - let extracted = result.extract_solution(&target_solution); - assert!( - !source.evaluate(&extracted), - "SAT->DS [{label}]: unsatisfiable but extracted satisfies" - ); + assert!(result.extract_solution(&target_solution).is_err()); } else { assert_satisfaction_round_trip_from_optimization_target( &source, diff --git a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs index ccbbe362c..496adc0ae 100644 --- a/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/unit_tests/rules/satisfiability_integralflowhomologousarcs.rs @@ -66,7 +66,7 @@ fn test_satisfiability_to_integralflowhomologousarcs_issue_example_assignment_en let satisfying_flow = reduction.encode_assignment(&satisfying_assignment); assert!(target.evaluate(&satisfying_flow).0); assert_eq!( - reduction.extract_solution(&satisfying_flow), + reduction.extract_solution(&satisfying_flow).unwrap(), satisfying_assignment ); diff --git a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs index 502874a6a..287ad85aa 100644 --- a/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs +++ b/src/unit_tests/rules/satisfiability_maximum2satisfiability.rs @@ -55,7 +55,7 @@ fn test_satisfiability_to_maximum2satisfiability_unsatisfiable_gap() { let target_solution = solve_optimization_problem(target).expect("MAX-2-SAT target should always have a witness"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(!source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/satisfiability_naesatisfiability.rs b/src/unit_tests/rules/satisfiability_naesatisfiability.rs index fbe452648..7a155ed7c 100644 --- a/src/unit_tests/rules/satisfiability_naesatisfiability.rs +++ b/src/unit_tests/rules/satisfiability_naesatisfiability.rs @@ -64,10 +64,24 @@ fn test_solution_extraction_sentinel_false() { let reduction = ReduceTo::::reduce_to(&sat); // target_solution: [1, 0, 1, 0] means x1=true, x2=false, x3=true, sentinel=false - let extracted = reduction.extract_solution(&[1, 0, 1, 0]); + let extracted = reduction.extract_solution(&[1, 0, 1, 0]).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } +#[test] +fn test_solution_extraction_distinguishes_zero_assignment_from_malformed_input() { + let sat = Satisfiability::new(2, vec![CNFClause::new(vec![-1, -2])]); + let reduction = ReduceTo::::reduce_to(&sat); + + assert_eq!(reduction.extract_solution(&[0, 0, 0]).unwrap(), vec![0, 0]); + + let error = reduction.extract_solution(&[0, 0]).unwrap_err(); + assert_eq!( + error.to_string(), + "expected at least 3 values including the sentinel, got 2" + ); +} + #[test] fn test_solution_extraction_sentinel_true() { // When sentinel is true, return complement of original variables @@ -77,7 +91,7 @@ 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(&[0, 1, 0, 1]); + let extracted = reduction.extract_solution(&[0, 1, 0, 1]).unwrap(); assert_eq!(extracted, vec![1, 0, 1]); } @@ -170,7 +184,7 @@ fn test_all_satisfying_assignments_map_back() { let nae_solutions = solver.find_all_witnesses(naesat); for nae_sol in &nae_solutions { - let sat_sol = reduction.extract_solution(nae_sol); + let sat_sol = reduction.extract_solution(nae_sol).unwrap(); assert_eq!(sat_sol.len(), 2); assert!( sat.evaluate(&sat_sol).0, diff --git a/src/unit_tests/rules/satisfiability_nontautology.rs b/src/unit_tests/rules/satisfiability_nontautology.rs index e7a2101e1..4e7ee26d4 100644 --- a/src/unit_tests/rules/satisfiability_nontautology.rs +++ b/src/unit_tests/rules/satisfiability_nontautology.rs @@ -57,7 +57,7 @@ fn test_satisfiability_to_non_tautology_extract_solution_is_identity() { .expect("target should have a witness"); assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), target_solution ); } diff --git a/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs index b33d2dbf7..ef961c920 100644 --- a/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/schedulingtominimizeweightedcompletiontime_ilp.rs @@ -53,7 +53,7 @@ fn test_solution_extraction() { // y vars: index 6 sol[6] = 1; // y_{0,1} = 1 - let extracted = reduction.extract_solution(&sol); + let extracted = reduction.extract_solution(&sol).unwrap(); 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), Min(Some(5))); @@ -73,7 +73,7 @@ fn test_ilp_matches_bruteforce_small() { let reduction: ReductionSMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(ilp_value, bf_value); @@ -91,7 +91,7 @@ fn test_issue_example_closed_loop() { let reduction: ReductionSMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(47))); } @@ -103,7 +103,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Min(Some(15))); } @@ -122,7 +122,7 @@ fn test_equal_tasks_multiple_processors() { let reduction: ReductionSMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); 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 72c20ef12..63a0aec83 100644 --- a/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/unit_tests/rules/schedulingwithindividualdeadlines_ilp.rs @@ -44,7 +44,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -71,7 +71,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0, 1]); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs b/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs index cd04dcf49..08f9cf976 100644 --- a/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -18,7 +18,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert!( @@ -44,7 +44,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } @@ -55,6 +55,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs index 71199ad2e..893eb6a6c 100644 --- a/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -33,7 +33,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!(bf_value, ilp_value); @@ -49,7 +49,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); assert!(value.is_valid()); assert_eq!(value.0, Some(0)); @@ -73,7 +73,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); let bf = BruteForce::new(); diff --git a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs index 1bd5baa1b..32336c7b1 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -42,7 +42,7 @@ 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(&[3, 1, 0]); + let extracted = reduction.extract_solution(&[3, 1, 0]).unwrap(); assert_eq!(extracted, vec![1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(14))); } @@ -58,7 +58,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 2, 0, 1, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(46))); @@ -81,7 +81,7 @@ fn test_ilp_matches_bruteforce_optimum() { let reduction: ReductionSTMWCTToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ILPSolver::new().solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_metric = problem.evaluate(&extracted); assert_eq!(ilp_metric, brute_force_metric); @@ -152,7 +152,7 @@ fn test_solve_reduced_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); + let source_solution = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source_solution, vec![1, 2, 0, 1, 0]); assert_eq!(problem.evaluate(&source_solution), Min(Some(46))); diff --git a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs index f09f97d6b..2ee464f3d 100644 --- a/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/unit_tests/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -14,7 +14,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -32,7 +32,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -61,6 +61,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 8e6541bea..6590da713 100644 --- a/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/unit_tests/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -42,7 +42,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -70,7 +70,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -97,7 +97,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } } @@ -116,6 +116,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs index 32c0b2082..90f5922ac 100644 --- a/src/unit_tests/rules/sequencingwithinintervals_ilp.rs +++ b/src/unit_tests/rules/sequencingwithinintervals_ilp.rs @@ -56,7 +56,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -82,7 +82,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0]); assert!( problem.evaluate(&extracted).0, diff --git a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 6da8a68e4..2201a7d11 100644 --- a/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/unit_tests/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -32,7 +32,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -54,6 +54,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/setsplitting_betweenness.rs b/src/unit_tests/rules/setsplitting_betweenness.rs index 9177da2ab..476b5ff4b 100644 --- a/src/unit_tests/rules/setsplitting_betweenness.rs +++ b/src/unit_tests/rules/setsplitting_betweenness.rs @@ -52,7 +52,9 @@ fn test_setsplitting_to_betweenness_issue_yes_instance_structure() { ], ); assert_eq!( - reduction.extract_solution(&[8, 2, 9, 0, 1, 4, 3, 6, 7, 5]), + reduction + .extract_solution(&[8, 2, 9, 0, 1, 4, 3, 6, 7, 5]) + .unwrap(), vec![1, 0, 1, 0, 0] ); } diff --git a/src/unit_tests/rules/setsplitting_ilp.rs b/src/unit_tests/rules/setsplitting_ilp.rs index d30286c0e..1d82a5888 100644 --- a/src/unit_tests/rules/setsplitting_ilp.rs +++ b/src/unit_tests/rules/setsplitting_ilp.rs @@ -46,7 +46,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), @@ -83,7 +83,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_result = problem.evaluate(&extracted); 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 70cc18914..1a6480839 100644 --- a/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs +++ b/src/unit_tests/rules/shortestcommonsupersequence_ilp.rs @@ -27,7 +27,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!( bf_value, ilp_value, @@ -47,7 +47,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!(problem.evaluate(&extracted).0.is_some()); } @@ -60,7 +60,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), problem.max_length()); assert!(problem.evaluate(&extracted).0.is_some()); } diff --git a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs index 26343fc58..5eabec62d 100644 --- a/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/unit_tests/rules/shortestweightconstrainedpath_ilp.rs @@ -53,7 +53,7 @@ fn test_shortestweightconstrainedpath_to_ilp_bf_vs_ilp() { match ilp_result { Ok(ilp_solution) => { - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); // Both should agree on the optimal length assert_eq!(ilp_value, bf_value); @@ -73,7 +73,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); // length = 2 + 3 = 5 @@ -96,7 +96,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 0]); assert_eq!(problem.evaluate(&extracted), Min(Some(0))); diff --git a/src/unit_tests/rules/sparsematrixcompression_ilp.rs b/src/unit_tests/rules/sparsematrixcompression_ilp.rs index d92744b41..a173f870e 100644 --- a/src/unit_tests/rules/sparsematrixcompression_ilp.rs +++ b/src/unit_tests/rules/sparsematrixcompression_ilp.rs @@ -64,7 +64,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/spinglass_maxcut.rs b/src/unit_tests/rules/spinglass_maxcut.rs index b6dcac86d..1e7837260 100644 --- a/src/unit_tests/rules/spinglass_maxcut.rs +++ b/src/unit_tests/rules/spinglass_maxcut.rs @@ -31,7 +31,7 @@ fn test_solution_extraction_no_ancilla() { let reduction = ReduceTo::>::reduce_to(&sg); let mc_sol = vec![0, 1]; - let extracted = reduction.extract_solution(&mc_sol); + let extracted = reduction.extract_solution(&mc_sol).unwrap(); assert_eq!(extracted, vec![0, 1]); } @@ -42,12 +42,12 @@ fn test_solution_extraction_with_ancilla() { // If ancilla is 0, don't flip let mc_sol = vec![0, 1, 0]; - let extracted = reduction.extract_solution(&mc_sol); + let extracted = reduction.extract_solution(&mc_sol).unwrap(); assert_eq!(extracted, vec![0, 1]); // If ancilla is 1, flip all let mc_sol = vec![0, 1, 1]; - let extracted = reduction.extract_solution(&mc_sol); + let extracted = reduction.extract_solution(&mc_sol).unwrap(); assert_eq!(extracted, vec![1, 0]); // flipped and ancilla removed } diff --git a/src/unit_tests/rules/steinertree_ilp.rs b/src/unit_tests/rules/steinertree_ilp.rs index 4f3dbfb98..6db33e38a 100644 --- a/src/unit_tests/rules/steinertree_ilp.rs +++ b/src/unit_tests/rules/steinertree_ilp.rs @@ -48,7 +48,7 @@ fn test_steinertree_to_ilp_closed_loop() { let ilp_solver = ILPSolver::new(); let best_source = bf.find_all_witnesses(&problem); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&best_source[0]), Min(Some(6))); assert_eq!(problem.evaluate(&extracted), Min(Some(6))); @@ -66,7 +66,7 @@ fn test_solution_extraction_reads_edge_selector_prefix() { ]; assert_eq!( - reduction.extract_solution(&target_solution), + reduction.extract_solution(&target_solution).unwrap(), vec![1, 1, 1, 1, 0, 0, 0] ); } diff --git a/src/unit_tests/rules/stringtostringcorrection_ilp.rs b/src/unit_tests/rules/stringtostringcorrection_ilp.rs index d9d6dbea6..7a93273eb 100644 --- a/src/unit_tests/rules/stringtostringcorrection_ilp.rs +++ b/src/unit_tests/rules/stringtostringcorrection_ilp.rs @@ -30,7 +30,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -43,7 +43,7 @@ fn test_solution_extraction_delete() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted.len(), 1); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -81,6 +81,6 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs index 8e963a52a..e18631a1c 100644 --- a/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/unit_tests/rules/strongconnectivityaugmentation_ilp.rs @@ -29,7 +29,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!( source.evaluate(&extracted).0, @@ -44,7 +44,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert_eq!(extracted.len(), 2); assert!(source.evaluate(&extracted).0); } @@ -56,7 +56,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_sol).unwrap(); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/subgraphisomorphism_ilp.rs b/src/unit_tests/rules/subgraphisomorphism_ilp.rs index 026c5e79a..293bc584a 100644 --- a/src/unit_tests/rules/subgraphisomorphism_ilp.rs +++ b/src/unit_tests/rules/subgraphisomorphism_ilp.rs @@ -37,7 +37,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!( problem.evaluate(&extracted), Or(true), @@ -65,7 +65,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -91,7 +91,7 @@ fn test_solution_extraction() { let ilp_solution = ilp_solver .solve(reduction.target_problem()) .expect("solvable"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } diff --git a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs index 87b3fe6a3..fed29ae65 100644 --- a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs +++ b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs @@ -45,10 +45,15 @@ fn test_subsetsum_to_integerexpressionmembership_extract_solution_matches_choice let reduction = ReduceTo::::reduce_to(&source); assert_eq!( - reduction.extract_solution(&issue_example_target_config()), + reduction + .extract_solution(&issue_example_target_config()) + .unwrap(), issue_example_source_config() ); - assert_eq!(reduction.extract_solution(&[1, 0, 0, 1]), vec![1, 0, 0, 1]); + assert_eq!( + reduction.extract_solution(&[1, 0, 0, 1]).unwrap(), + vec![1, 0, 0, 1] + ); } #[test] diff --git a/src/unit_tests/rules/subsetsum_partition.rs b/src/unit_tests/rules/subsetsum_partition.rs index cda81b51d..d6507e4f8 100644 --- a/src/unit_tests/rules/subsetsum_partition.rs +++ b/src/unit_tests/rules/subsetsum_partition.rs @@ -30,8 +30,14 @@ fn test_subsetsum_to_partition_sigma_greater_than_two_t_extraction() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!(reduction.target_problem().sizes(), &[10, 20, 30, 40]); - assert_eq!(reduction.extract_solution(&[1, 0, 0, 1]), vec![1, 0, 0]); - assert_eq!(reduction.extract_solution(&[0, 1, 1, 0]), vec![1, 0, 0]); + assert_eq!( + reduction.extract_solution(&[1, 0, 0, 1]).unwrap(), + vec![1, 0, 0] + ); + assert_eq!( + reduction.extract_solution(&[0, 1, 1, 0]).unwrap(), + vec![1, 0, 0] + ); } #[test] @@ -40,7 +46,10 @@ fn test_subsetsum_to_partition_sigma_equals_two_t_extraction() { let reduction = ReduceTo::::reduce_to(&source); assert_eq!(reduction.target_problem().sizes(), &[3, 5, 2, 6]); - assert_eq!(reduction.extract_solution(&[1, 1, 0, 0]), vec![1, 1, 0, 0]); + assert_eq!( + reduction.extract_solution(&[1, 1, 0, 0]).unwrap(), + vec![1, 1, 0, 0] + ); } #[test] diff --git a/src/unit_tests/rules/sumofsquarespartition_ilp.rs b/src/unit_tests/rules/sumofsquarespartition_ilp.rs index 8fb35803e..765d58b6f 100644 --- a/src/unit_tests/rules/sumofsquarespartition_ilp.rs +++ b/src/unit_tests/rules/sumofsquarespartition_ilp.rs @@ -37,7 +37,7 @@ fn test_sumofsquarespartition_to_ilp_bf_vs_ilp() { let reduction: ReductionSSPToILP = ReduceTo::>::reduce_to(&problem); let ilp = reduction.target_problem(); let ilp_solution = ilp_solver.solve(ilp).expect("ILP should be feasible"); - let extracted = reduction.extract_solution(&ilp_solution); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let ilp_value = problem.evaluate(&extracted); assert_eq!( ilp_value, bf_value, @@ -59,7 +59,7 @@ fn test_solution_extraction() { 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![0, 1, 1, 0]); } @@ -75,7 +75,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let value = problem.evaluate(&extracted); // Optimal: {1},{2} -> 1+4=5 assert_eq!(value, Min(Some(5))); diff --git a/src/unit_tests/rules/threedimensionalmatching_ilp.rs b/src/unit_tests/rules/threedimensionalmatching_ilp.rs index 242749dcb..bff3276d4 100644 --- a/src/unit_tests/rules/threedimensionalmatching_ilp.rs +++ b/src/unit_tests/rules/threedimensionalmatching_ilp.rs @@ -98,7 +98,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(extracted, vec![1, 1, 1, 0, 0]); assert_eq!(problem.evaluate(&extracted), Or(true)); @@ -134,7 +134,7 @@ 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); + let direct_source = direct.extract_solution(&direct_solution).unwrap(); assert_eq!(problem.evaluate(&direct_source), Or(true)); assert!( diff --git a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs index 271cb910c..4e34e4ce6 100644 --- a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -111,7 +111,7 @@ 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![0]); - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); // Source has 0 triples → extracted vector has length 0. assert_eq!(extracted.len(), source.num_triples()); assert_eq!(extracted, Vec::::new()); @@ -133,7 +133,7 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_sentinel_no_triples() let target_witnesses = solver.find_all_witnesses(target); assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted.len(), source.num_triples()); // Empty triple set cannot cover non-empty universe. assert!( @@ -158,7 +158,7 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_solution_extraction_id assert!(!target_witnesses.is_empty()); for witness in &target_witnesses { - let extracted = reduction.extract_solution(witness); + let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted, *witness); assert!( source_witnesses.contains(&extracted), diff --git a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs index 3e5cb86b0..7a4ea920f 100644 --- a/src/unit_tests/rules/threedimensionalmatching_threepartition.rs +++ b/src/unit_tests/rules/threedimensionalmatching_threepartition.rs @@ -57,7 +57,7 @@ fn test_threedimensionalmatching_to_threepartition_extracts_manual_q1_witness() assert!(reduction.target_problem().evaluate(&target_config).0); - let extracted = reduction.extract_solution(&target_config); + let extracted = reduction.extract_solution(&target_config).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } @@ -68,7 +68,7 @@ fn test_threedimensionalmatching_to_threepartition_closed_loop_from_known_matchi let target_solution = reduction.build_target_witness(&[1]); assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1]); assert!(source.evaluate(&extracted).0); } @@ -80,7 +80,7 @@ fn test_threedimensionalmatching_to_threepartition_round_trip_q2_minimal_matchin assert!(reduction.target_problem().evaluate(&target_solution).0); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(extracted, vec![1, 1]); assert!(source.evaluate(&extracted).0); } diff --git a/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs b/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs index e33249f79..d1e91e870 100644 --- a/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/unit_tests/rules/threepartition_resourceconstrainedscheduling.rs @@ -64,7 +64,7 @@ fn test_threepartition_to_resourceconstrainedscheduling_solution_extraction() { let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), source.num_elements()); let target_valid = target.evaluate(sol); let source_valid = source.evaluate(&extracted); diff --git a/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index 58deb0fa5..637fa2b19 100644 --- a/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/unit_tests/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -73,7 +73,7 @@ fn test_threepartition_to_sequencingwithreleasetimesanddeadlines_solution_extrac let target_solutions = solver.find_all_witnesses(target); for sol in &target_solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert_eq!(extracted.len(), source.num_elements()); let source_valid = source.evaluate(&extracted); assert!( diff --git a/src/unit_tests/rules/timetabledesign_ilp.rs b/src/unit_tests/rules/timetabledesign_ilp.rs index 556bcee1e..a32abd2cc 100644 --- a/src/unit_tests/rules/timetabledesign_ilp.rs +++ b/src/unit_tests/rules/timetabledesign_ilp.rs @@ -45,7 +45,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(problem.evaluate(&extracted), Or(true)); } @@ -75,7 +75,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Identity extraction: ILP solution == source config assert_eq!(extracted, ilp_solution); diff --git a/src/unit_tests/rules/traits.rs b/src/unit_tests/rules/traits.rs index a7c1acd69..b26e3c30a 100644 --- a/src/unit_tests/rules/traits.rs +++ b/src/unit_tests/rules/traits.rs @@ -55,8 +55,11 @@ impl ReductionResult for TestReduction { fn target_problem(&self) -> &TargetProblem { &self.target } - fn extract_solution(&self, target_config: &[usize]) -> Vec { - target_config.to_vec() + fn extract_solution( + &self, + target_config: &[usize], + ) -> crate::rules::ExtractionResult> { + Ok(target_config.to_vec()) } } @@ -75,7 +78,7 @@ fn test_reduction() { let result = >::reduce_to(&source); let target = result.target_problem(); assert_eq!(target.evaluate(&[1, 1]), 2); - assert_eq!(result.extract_solution(&[1, 0]), vec![1, 0]); + assert_eq!(result.extract_solution(&[1, 0]).unwrap(), vec![1, 0]); } #[derive(Clone)] diff --git a/src/unit_tests/rules/travelingsalesman_ilp.rs b/src/unit_tests/rules/travelingsalesman_ilp.rs index 03c472daf..07dec72d1 100644 --- a/src/unit_tests/rules/travelingsalesman_ilp.rs +++ b/src/unit_tests/rules/travelingsalesman_ilp.rs @@ -38,7 +38,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Verify extracted solution is valid on source problem let metric = problem.evaluate(&extracted); @@ -56,7 +56,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // Solve via brute force for cross-check let bf = BruteForce::new(); @@ -83,7 +83,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); let metric = problem.evaluate(&extracted); assert!(metric.is_valid()); @@ -121,7 +121,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // 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 1095a6937..54843970a 100644 --- a/src/unit_tests/rules/travelingsalesman_qubo.rs +++ b/src/unit_tests/rules/travelingsalesman_qubo.rs @@ -16,7 +16,7 @@ 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); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = tsp.evaluate(&extracted); assert!(metric.is_valid(), "Extracted solution should be valid"); // K3 has only one Hamiltonian cycle (all 3 edges), cost = 1+2+3 = 6 @@ -44,7 +44,7 @@ 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); + let extracted = reduction.extract_solution(sol).unwrap(); let metric = tsp.evaluate(&extracted); assert!(metric.is_valid(), "Extracted solution should be valid"); assert_eq!(metric, Min(Some(4))); diff --git a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs index a8391993c..66179f48d 100644 --- a/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/unit_tests/rules/undirectedflowlowerbounds_ilp.rs @@ -58,7 +58,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); // extract_solution returns edge orientations z_e assert_eq!(extracted.len(), 2); @@ -86,7 +86,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // 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![0, 0]); assert!( diff --git a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs index f6f91eec9..9d173a877 100644 --- a/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/unit_tests/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -86,7 +86,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert!( problem.evaluate(&extracted).0, @@ -121,7 +121,7 @@ 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); + let extracted = reduction.extract_solution(&target_solution).unwrap(); // extract_solution returns first 4*3=12 flow variables assert_eq!(extracted.len(), 12); assert!( diff --git a/src/unit_tests/solvers/registry.rs b/src/unit_tests/solvers/registry.rs index 909be9442..0e77008e0 100644 --- a/src/unit_tests/solvers/registry.rs +++ b/src/unit_tests/solvers/registry.rs @@ -339,7 +339,7 @@ fn solver_capability_registry_ambiguous_exact_edge_is_rejected() { let reduction = reduction_entries() .into_iter() .find(|entry| { - entry.capabilities.witness + entry.capabilities().witness && entry.reduce_fn.is_some() && edge_key(entry, true) == path[0] && edge_key(entry, false) == path[1] diff --git a/tests/suites/ksatisfiability_simultaneous_incongruences.rs b/tests/suites/ksatisfiability_simultaneous_incongruences.rs index 73791447a..bf9ede560 100644 --- a/tests/suites/ksatisfiability_simultaneous_incongruences.rs +++ b/tests/suites/ksatisfiability_simultaneous_incongruences.rs @@ -25,7 +25,7 @@ fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { let target_solution = solver .find_witness(target) .expect("target should be satisfiable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert!(source.evaluate(&extracted)); } diff --git a/tests/suites/reductions.rs b/tests/suites/reductions.rs index 0d7bbea7a..730eae2ff 100644 --- a/tests/suites/reductions.rs +++ b/tests/suites/reductions.rs @@ -38,7 +38,7 @@ mod is_vc_reductions { let vc_solutions = solver.find_all_witnesses(vc_problem); // Extract back to IS solution - let is_solution = result.extract_solution(&vc_solutions[0]); + let is_solution = result.extract_solution(&vc_solutions[0]).unwrap(); // Solution should be valid for original problem assert!(is_problem.evaluate(&is_solution).is_valid()); @@ -65,7 +65,7 @@ mod is_vc_reductions { let is_solutions = solver.find_all_witnesses(is_problem); // Extract back to VC solution - let vc_solution = result.extract_solution(&is_solutions[0]); + let vc_solution = result.extract_solution(&is_solutions[0]).unwrap(); // Solution should be valid for original problem assert!(vc_problem.evaluate(&vc_solution).is_valid()); @@ -98,8 +98,8 @@ mod is_vc_reductions { let solutions = solver.find_all_witnesses(final_is); // Extract through the chain - let intermediate_sol = back_to_is.extract_solution(&solutions[0]); - let original_sol = to_vc.extract_solution(&intermediate_sol); + let intermediate_sol = back_to_is.extract_solution(&solutions[0]).unwrap(); + let original_sol = to_vc.extract_solution(&intermediate_sol).unwrap(); // Should be valid assert!(original.evaluate(&original_sol).is_valid()); @@ -163,7 +163,7 @@ mod is_sp_reductions { let sp_solutions = solver.find_all_witnesses(sp_problem); // Extract to IS solution - let is_solution = result.extract_solution(&sp_solutions[0]); + let is_solution = result.extract_solution(&sp_solutions[0]).unwrap(); assert!(is_problem.evaluate(&is_solution).is_valid()); } @@ -185,7 +185,7 @@ mod is_sp_reductions { let is_solutions = solver.find_all_witnesses(is_problem); // Extract to SP solution - let sp_solution = result.extract_solution(&is_solutions[0]); + let sp_solution = result.extract_solution(&is_solutions[0]).unwrap(); // All sets can be packed (disjoint) assert_eq!(sp_solution.iter().sum::(), 3); @@ -208,7 +208,7 @@ mod is_sp_reductions { let sp_solutions = solver.find_all_witnesses(sp_problem); // Extract to IS solution - let is_solution = to_sp.extract_solution(&sp_solutions[0]); + let is_solution = to_sp.extract_solution(&sp_solutions[0]).unwrap(); // Valid for original assert!(original.evaluate(&is_solution).is_valid()); @@ -241,7 +241,7 @@ mod sg_qubo_reductions { let qubo_solutions = solver.find_all_witnesses(qubo); // Extract to SG solution - let sg_solution = result.extract_solution(&qubo_solutions[0]); + let sg_solution = result.extract_solution(&qubo_solutions[0]).unwrap(); assert_eq!(sg_solution.len(), 2); } @@ -260,7 +260,7 @@ mod sg_qubo_reductions { let sg_solutions = solver.find_all_witnesses(sg); // Extract to QUBO solution - let qubo_solution = result.extract_solution(&sg_solutions[0]); + let qubo_solution = result.extract_solution(&sg_solutions[0]).unwrap(); assert_eq!(qubo_solution.len(), 2); } @@ -283,7 +283,7 @@ mod sg_qubo_reductions { let qubo_solutions = solver.find_all_witnesses(qubo); // Extract QUBO solution back to SG - let extracted = result.extract_solution(&qubo_solutions[0]); + let extracted = result.extract_solution(&qubo_solutions[0]).unwrap(); // Convert solutions to spins for energy computation // SpinGlass::config_to_spins converts 0/1 configs to -1/+1 spins @@ -316,7 +316,7 @@ 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); + let extracted = reduction.extract_solution(&ilp_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Min(Some(3))); } @@ -336,7 +336,7 @@ mod partition_into_cliques_covering_by_cliques_reductions { let target_solution = BruteForce::new() .find_witness(target) .expect("target should be solvable"); - let extracted = reduction.extract_solution(&target_solution); + let extracted = reduction.extract_solution(&target_solution).unwrap(); assert_eq!(source.evaluate(&extracted), Or(true)); } @@ -376,7 +376,7 @@ mod max2sat_maxcut_reductions { let solver = BruteForce::new(); let target_solutions = solver.find_all_witnesses(target); - let extracted = reduction.extract_solution(&target_solutions[0]); + let extracted = reduction.extract_solution(&target_solutions[0]).unwrap(); assert_eq!(source.evaluate(&extracted), Max(Some(5))); } @@ -406,7 +406,7 @@ mod sg_maxcut_reductions { let maxcut_solutions = solver.find_all_witnesses(maxcut); // Extract to SG solution - let sg_solution = result.extract_solution(&maxcut_solutions[0]); + let sg_solution = result.extract_solution(&maxcut_solutions[0]).unwrap(); assert_eq!(sg_solution.len(), 3); } @@ -428,7 +428,7 @@ mod sg_maxcut_reductions { let sg_solutions = solver.find_all_witnesses(sg); // Extract to MaxCut solution - let maxcut_solution = result.extract_solution(&sg_solutions[0]); + let maxcut_solution = result.extract_solution(&sg_solutions[0]).unwrap(); assert_eq!(maxcut_solution.len(), 3); } @@ -451,7 +451,7 @@ mod sg_maxcut_reductions { let maxcut_solutions = solver.find_all_witnesses(maxcut); // Extract MaxCut solution back to SG - let extracted = result.extract_solution(&maxcut_solutions[0]); + let extracted = result.extract_solution(&maxcut_solutions[0]).unwrap(); // Convert solutions to spins for energy computation // SpinGlass::config_to_spins converts 0/1 configs to -1/+1 spins @@ -572,13 +572,13 @@ mod qubo_reductions { // All QUBO optimal solutions should extract to valid IS solutions for sol in &solutions { - let extracted = chain.extract_solution(sol); + let extracted = chain.extract_solution(sol).unwrap(); assert!(is.evaluate(&extracted).is_valid()); } // Optimal IS size should match ground truth let gt_is_size: usize = data.qubo_optimal.configs[0].iter().sum(); - let our_is_size: usize = chain.extract_solution(&solutions[0]).iter().sum(); + let our_is_size: usize = chain.extract_solution(&solutions[0]).unwrap().iter().sum(); assert_eq!(our_is_size, gt_is_size); } @@ -616,7 +616,7 @@ mod qubo_reductions { let solutions = solver.find_all_witnesses(qubo); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(kc.evaluate(&extracted)); } @@ -653,13 +653,17 @@ mod qubo_reductions { let solutions = solver.find_all_witnesses(qubo); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(sp.evaluate(&extracted).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]).iter().sum(); + let our_selected: usize = reduction + .extract_solution(&solutions[0]) + .unwrap() + .iter() + .sum(); assert_eq!(our_selected, gt_selected); } @@ -718,13 +722,13 @@ mod qubo_reductions { let solutions = solver.find_all_witnesses(qubo); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ksat.evaluate(&extracted)); } // Verify extracted solution matches ground truth assignment let gt_config = &data.qubo_optimal.configs[0]; - let our_config = reduction.extract_solution(&solutions[0]); + let our_config = reduction.extract_solution(&solutions[0]).unwrap(); assert_eq!(&our_config, gt_config); } @@ -802,13 +806,13 @@ mod qubo_reductions { let solutions = solver.find_all_witnesses(qubo); for sol in &solutions { - let extracted = reduction.extract_solution(sol); + let extracted = reduction.extract_solution(sol).unwrap(); assert!(ilp.evaluate(&extracted).is_valid()); } // Optimal assignment should match ground truth let gt_config = &data.qubo_optimal.configs[0]; - let our_config = reduction.extract_solution(&solutions[0]); + let our_config = reduction.extract_solution(&solutions[0]).unwrap(); assert_eq!(&our_config, gt_config); } @@ -873,12 +877,12 @@ mod qubo_reductions { // Extract back through the full chain to get VC solution for sol in &solutions { - let vc_sol = chain.extract_solution(sol); + let vc_sol = chain.extract_solution(sol).unwrap(); assert!(vc.evaluate(&vc_sol).is_valid()); } // Optimal VC size should match ground truth - let vc_sol = chain.extract_solution(&solutions[0]); + let vc_sol = chain.extract_solution(&solutions[0]).unwrap(); let gt_vc_size: usize = data.qubo_optimal.configs[0].iter().sum(); let our_vc_size: usize = vc_sol.iter().sum(); assert_eq!(our_vc_size, gt_vc_size); @@ -964,14 +968,14 @@ mod end_to_end { let to_vc = ReduceTo::>::reduce_to(&is); let vc = to_vc.target_problem(); let vc_solutions = solver.find_all_witnesses(vc); - let vc_extracted = to_vc.extract_solution(&vc_solutions[0]); + let vc_extracted = to_vc.extract_solution(&vc_solutions[0]).unwrap(); let via_vc_size = vc_extracted.iter().sum::(); // Reduce to MaximumSetPacking and solve let to_sp = ReduceTo::>::reduce_to(&is); let sp = to_sp.target_problem(); let sp_solutions = solver.find_all_witnesses(sp); - let sp_extracted = to_sp.extract_solution(&sp_solutions[0]); + let sp_extracted = to_sp.extract_solution(&sp_solutions[0]).unwrap(); let via_sp_size = sp_extracted.iter().sum::(); // All should give same optimal size @@ -1000,7 +1004,7 @@ mod end_to_end { let to_maxcut = ReduceTo::>::reduce_to(&sg); let maxcut = to_maxcut.target_problem(); let maxcut_solutions = solver.find_all_witnesses(maxcut); - let maxcut_extracted = to_maxcut.extract_solution(&maxcut_solutions[0]); + let maxcut_extracted = to_maxcut.extract_solution(&maxcut_solutions[0]).unwrap(); // Convert extracted solution to spins for energy computation let extracted_spins: Vec = maxcut_extracted.iter().map(|&x| x as i32).collect(); @@ -1029,8 +1033,8 @@ mod end_to_end { let vc_solutions = solver.find_all_witnesses(vc); // Extract back through chain - let is_sol = is_to_vc.extract_solution(&vc_solutions[0]); - let sp_sol = sp_to_is.extract_solution(&is_sol); + let is_sol = is_to_vc.extract_solution(&vc_solutions[0]).unwrap(); + let sp_sol = sp_to_is.extract_solution(&is_sol).unwrap(); // Should be valid MaximumSetPacking assert!(sp.evaluate(&sp_sol).is_valid()); diff --git a/tests/suites/register_assignment_reductions.rs b/tests/suites/register_assignment_reductions.rs index e0c710f56..466188b9d 100644 --- a/tests/suites/register_assignment_reductions.rs +++ b/tests/suites/register_assignment_reductions.rs @@ -81,10 +81,10 @@ 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); + let fra_solution = fra_chain.extract_solution(&ilp_solution).unwrap(); assert_eq!(fra.evaluate(&fra_solution), Or(true)); - let sat_solution = ksat_chain.extract_solution(&fra_solution); + let sat_solution = ksat_chain.extract_solution(&fra_solution).unwrap(); assert_eq!(source.evaluate(&sat_solution), Or(true)); } From 83dacc043a34048be4422089135f87277d4b7d86 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 6 Aug 2026 19:22:47 +0800 Subject: [PATCH 29/31] fix infeasible reduction bundle solving --- problemreductions-cli/src/commands/solve.rs | 40 +++-------- problemreductions-cli/src/dispatch.rs | 59 ++++++++++++++++ problemreductions-cli/src/mcp/tests.rs | 28 ++++++++ problemreductions-cli/src/mcp/tools.rs | 26 +------ problemreductions-cli/tests/cli_tests.rs | 78 +++++++++++++++++++++ src/registry/dyn_problem.rs | 15 +++- 6 files changed, 191 insertions(+), 55 deletions(-) diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs index b77dfa4af..661959988 100644 --- a/problemreductions-cli/src/commands/solve.rs +++ b/problemreductions-cli/src/commands/solve.rs @@ -125,41 +125,19 @@ fn solve_problem( /// Solve a reduction bundle: solve the target problem, then map the solution back. fn solve_bundle(bundle: ReductionBundle, request: SolverRequest, out: &OutputConfig) -> Result<()> { let replay = BundleReplay::prepare(&bundle)?; - - let target_result = replay - .target - .solve_deterministically(request) - .map_err(add_solver_hint)?; - let target_config = target_result.config.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", - replay.target_name - ) - })?; - - let (source_config, source_eval) = replay.extract(target_config)?; + let result = replay.solve(request).map_err(add_solver_hint)?; let solver_desc = format!( "{} (via {})", - solver_text(&target_result.solver), - replay.target_name - ); - let text = format!( - "Problem: {}\nSolver: {}\nSolution: {:?}\nEvaluation: {}", - replay.source_name, solver_desc, source_config, source_eval, + solver_text(&result.solver), + result.target_name ); - - let json = serde_json::json!({ - "problem": replay.source_name, - "solver": &target_result.solver, - "solution": source_config, - "evaluation": source_eval, - "intermediate": { - "problem": replay.target_name, - "solution": target_config, - "evaluation": target_result.evaluation, - }, - }); + let mut text = format!("Problem: {}\nSolver: {}", result.source_name, solver_desc); + if let Some(config) = &result.source_config { + text.push_str(&format!("\nSolution: {:?}", config)); + } + text.push_str(&format!("\nEvaluation: {}", result.source_evaluation)); + let json = result.to_json(); let result = out.emit_with_default_name("", &text, &json); if out.output.is_none() && crate::output::stderr_is_tty() { diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs index fad94b40e..54598ab5b 100644 --- a/problemreductions-cli/src/dispatch.rs +++ b/problemreductions-cli/src/dispatch.rs @@ -131,6 +131,32 @@ pub fn solve_result_json(problem: &str, result: &DeterministicSolveResult) -> se json } +pub(crate) struct BundleSolveResult { + pub(crate) source_name: String, + pub(crate) target_name: String, + pub(crate) solver: problemreductions::solvers::SolverExecution, + pub(crate) source_config: Option>, + pub(crate) source_evaluation: String, + pub(crate) target_config: Option>, + pub(crate) target_evaluation: String, +} + +impl BundleSolveResult { + pub(crate) fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "problem": self.source_name, + "solver": self.solver, + "solution": self.source_config, + "evaluation": self.source_evaluation, + "intermediate": { + "problem": self.target_name, + "solution": self.target_config, + "evaluation": self.target_evaluation, + }, + }) + } +} + /// A validated reduction bundle ready to replay: /// source, target, and the reconstructed reduction chain. Construct via /// [`BundleReplay::prepare`]. All three CLI/MCP bundle workflows @@ -241,6 +267,39 @@ impl BundleReplay { let source_eval = self.source.evaluate_dyn(&source_config); Ok((source_config, source_eval)) } + + /// Solve the target and map the result back to the source problem. + /// + /// A witness-capable aggregate returns its identity when an instance has no + /// witness. Witness preservation therefore makes the source aggregate + /// identity the corresponding result without requiring a configuration. + pub(crate) fn solve(&self, request: SolverRequest) -> Result { + let target_result = self.target.solve_deterministically(request)?; + + let (source_config, source_evaluation) = match target_result.config.as_deref() { + Some(target_config) => { + let (source_config, source_evaluation) = self.extract(target_config)?; + (Some(source_config), source_evaluation) + } + None if self.target.supports_witnesses_dyn() => { + (None, self.source.aggregate_identity_dyn()) + } + None => anyhow::bail!( + "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", + self.target_name + ), + }; + + Ok(BundleSolveResult { + source_name: self.source_name.clone(), + target_name: self.target_name.clone(), + solver: target_result.solver, + source_config, + source_evaluation, + target_config: target_result.config, + target_evaluation: target_result.evaluation, + }) + } } fn format_step(name: &str, variant: &BTreeMap) -> String { diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 270e42f44..0acf7e518 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -639,6 +639,34 @@ mod tests { assert_eq!(json["problem"], "MaximumIndependentSet"); } + #[test] + fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability() { + let server = McpServer::new(); + + for (clauses, evaluation, has_solution) in + [("1;-1", "Or(false)", false), ("1", "Or(true)", true)] + { + let problem_json = server + .create_problem_inner( + "Satisfiability", + &serde_json::json!({"num_vars": 1, "clauses": clauses}), + ) + .unwrap(); + let bundle_json = server + .reduce_inner(&problem_json, "NAESatisfiability", &SearchParams::default()) + .unwrap(); + let solved = server + .solve_inner(&bundle_json, Some("brute-force"), None) + .unwrap(); + let json: serde_json::Value = serde_json::from_str(&solved).unwrap(); + + assert_eq!(json["evaluation"], evaluation); + assert_eq!(json["solution"].is_array(), has_solution); + assert_eq!(json["intermediate"]["evaluation"], evaluation); + assert_eq!(json["intermediate"]["solution"].is_array(), has_solution); + } + } + #[test] fn test_solve_bundle_rejects_removed_customized_override() { let server = McpServer::new(); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 7853e2613..49148dadc 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -1629,29 +1629,9 @@ fn solve_problem_inner( /// Solve a reduction bundle: solve the target, then map the solution back. fn solve_bundle_inner(bundle: ReductionBundle, request: SolverRequest) -> anyhow::Result { let replay = BundleReplay::prepare(&bundle)?; - - let target_result = replay.target.solve_deterministically(request)?; - let target_config = target_result.config.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.", - replay.target_name - ) - })?; - - let (source_config, source_eval) = replay.extract(target_config)?; - - let json = serde_json::json!({ - "problem": replay.source_name, - "solver": &target_result.solver, - "solution": source_config, - "evaluation": source_eval, - "intermediate": { - "problem": replay.target_name, - "solution": target_config, - "evaluation": target_result.evaluation, - }, - }); - Ok(serde_json::to_string_pretty(&json)?) + Ok(serde_json::to_string_pretty( + &replay.solve(request)?.to_json(), + )?) } #[cfg(test)] diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 0a8061535..5a48681e7 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -3196,6 +3196,84 @@ fn test_solve_bundle() { std::fs::remove_file(&bundle_file).ok(); } +fn solve_sat_to_nae_bundle(case: &str, clauses: &str) -> serde_json::Value { + let temp_dir = std::env::temp_dir(); + let process_id = std::process::id(); + let problem_file = temp_dir.join(format!("pred_test_{case}_{process_id}_sat.json")); + let bundle_file = temp_dir.join(format!("pred_test_{case}_{process_id}_sat_nae_bundle.json")); + + let create = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "Satisfiability", + "--num-vars", + "1", + "--clauses", + clauses, + ]) + .output() + .unwrap(); + assert!( + create.status.success(), + "create stderr: {}", + String::from_utf8_lossy(&create.stderr) + ); + + let reduce = pred() + .args([ + "-o", + bundle_file.to_str().unwrap(), + "reduce", + problem_file.to_str().unwrap(), + "--to", + "NAESatisfiability", + ]) + .output() + .unwrap(); + assert!( + reduce.status.success(), + "reduce stderr: {}", + String::from_utf8_lossy(&reduce.stderr) + ); + + let solve = pred() + .args([ + "solve", + bundle_file.to_str().unwrap(), + "--solver", + "brute-force", + "--json", + ]) + .output() + .unwrap(); + assert!( + solve.status.success(), + "solve stderr: {}", + String::from_utf8_lossy(&solve.stderr) + ); + + std::fs::remove_file(problem_file).unwrap(); + std::fs::remove_file(bundle_file).unwrap(); + serde_json::from_slice(&solve.stdout).unwrap() +} + +#[test] +fn test_solve_bundle_distinguishes_infeasibility_from_missing_witness_capability() { + let infeasible = solve_sat_to_nae_bundle("infeasible", "1;-1"); + assert_eq!(infeasible["evaluation"], "Or(false)"); + assert!(infeasible["solution"].is_null()); + assert_eq!(infeasible["intermediate"]["evaluation"], "Or(false)"); + assert!(infeasible["intermediate"]["solution"].is_null()); + + let feasible = solve_sat_to_nae_bundle("feasible", "1"); + assert_eq!(feasible["evaluation"], "Or(true)"); + assert!(feasible["solution"].is_array()); + assert_eq!(feasible["intermediate"]["evaluation"], "Or(true)"); + assert!(feasible["intermediate"]["solution"].is_array()); +} + #[test] fn test_solve_bundle_ilp() { // Create → Reduce → Solve bundle with ILP diff --git a/src/registry/dyn_problem.rs b/src/registry/dyn_problem.rs index 19483463a..037bfc723 100644 --- a/src/registry/dyn_problem.rs +++ b/src/registry/dyn_problem.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use std::fmt; use crate::traits::Problem; +use crate::types::Aggregate; /// Format a metric for CLI- and registry-facing dynamic dispatch. /// @@ -38,12 +39,16 @@ pub trait DynProblem: Any { fn variant_map(&self) -> BTreeMap; /// Return the number of variables. fn num_variables_dyn(&self) -> usize; + /// Whether the aggregate value admits representative witness configurations. + fn supports_witnesses_dyn(&self) -> bool; + /// Return the aggregate identity in the CLI-facing metric format. + fn aggregate_identity_dyn(&self) -> String; } impl DynProblem for T where T: Problem + Serialize + 'static, - T::Value: fmt::Display + Serialize, + T::Value: Aggregate + fmt::Display + Serialize, { fn evaluate_dyn(&self, config: &[usize]) -> String { format_metric(&self.evaluate(config)) @@ -76,6 +81,14 @@ where fn num_variables_dyn(&self) -> usize { self.num_variables() } + + fn supports_witnesses_dyn(&self) -> bool { + T::Value::supports_witnesses() + } + + fn aggregate_identity_dyn(&self) -> String { + format_metric(&T::Value::identity()) + } } /// Function pointer type for brute-force value solve dispatch. From 471a0351e586c8d42e4ef91f483d9462d5010bdf Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Thu, 6 Aug 2026 22:01:30 +0800 Subject: [PATCH 30/31] reject malformed extracted solutions --- ...onianpath_degreeconstrainedspanningtree.rs | 7 ++--- src/rules/partition_subsetsum.rs | 18 ++++++------- src/rules/partition_sumofsquarespartition.rs | 23 ++++++++-------- src/rules/satisfiability_naesatisfiability.rs | 27 ++++++++++++------- ...mensionalmatching_minimumweightdecoding.rs | 25 ++++++++--------- src/unit_tests/rules/partition_subsetsum.rs | 19 ++++++++----- .../rules/partition_sumofsquarespartition.rs | 4 ++- .../rules/satisfiability_naesatisfiability.rs | 4 ++- ...mensionalmatching_minimumweightdecoding.rs | 2 ++ 9 files changed, 71 insertions(+), 58 deletions(-) diff --git a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs index 1b46decf5..5cc4085ac 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -52,11 +52,8 @@ fn extract_hamiltonian_order( target_solution: &[usize], ) -> crate::rules::ExtractionResult> { let num_vertices = graph.num_vertices(); - if num_vertices == 0 { - return Ok(vec![]); - } - if num_vertices == 1 { - return Ok(vec![0]); + if num_vertices < 2 { + return Ok((0..num_vertices).collect()); } let edges = graph.edges(); diff --git a/src/rules/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs index 58148eb19..26526461a 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -30,16 +30,14 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - if target_solution.len() == self.source_n { - // Normal case: same elements, same binary vector. - target_solution.to_vec() - } else { - // Odd-sum case: target is trivially infeasible (0 elements). - // Return all-zero config for the source (which also won't satisfy it). - vec![0; self.source_n] - } - }) + 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()) } } diff --git a/src/rules/partition_sumofsquarespartition.rs b/src/rules/partition_sumofsquarespartition.rs index a0f3f512e..1d6b642ce 100644 --- a/src/rules/partition_sumofsquarespartition.rs +++ b/src/rules/partition_sumofsquarespartition.rs @@ -42,22 +42,21 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { &self.target } - /// Solution extraction: identity mapping in the normal case. - /// In the sentinel case (source has fewer than two elements) the target's - /// witness has a different length, so we return an all-zero source-sized - /// vector; `Partition::evaluate` then yields `Or(false)`, which is the - /// correct answer because a single positive element cannot be balanced. + /// 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( &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - if target_solution.len() == self.source_n { - target_solution.to_vec() - } else { - vec![0; self.source_n] - } - }) + let expected = self.target.num_elements(); + if target_solution.len() != expected { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {expected} group assignments, got {}", + target_solution.len() + ))); + } + Ok(target_solution[..self.source_n].to_vec()) } } diff --git a/src/rules/satisfiability_naesatisfiability.rs b/src/rules/satisfiability_naesatisfiability.rs index 2fbe4a144..c0073d313 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -33,20 +33,29 @@ impl ReductionResult for ReductionSATToNAESAT { target_solution: &[usize], ) -> crate::rules::ExtractionResult> { let n = self.source_num_vars; - if target_solution.len() <= n { + let expected = n + 1; + if target_solution.len() != expected { return Err(crate::rules::ExtractionError::invalid(format!( - "expected at least {} values including the sentinel, got {}", - n + 1, + "expected {expected} values including the sentinel, got {}", target_solution.len() ))); } - - // The sentinel variable is the last variable (index n). - if target_solution[n] == 0 { - Ok(target_solution[..n].to_vec()) - } else { - Ok(target_solution[..n].iter().map(|&v| 1 - v).collect()) + if let Some((index, value)) = target_solution + .iter() + .copied() + .enumerate() + .find(|(_, value)| *value > 1) + { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected a binary value at position {index}, got {value}" + ))); } + + let sentinel = target_solution[n]; + Ok(target_solution[..n] + .iter() + .map(|&value| value ^ sentinel) + .collect()) } } diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index 7a47d727f..d7a7e097f 100644 --- a/src/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -44,24 +44,21 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin &self.target } - /// Solution extraction: identity mapping in the main branch. The target - /// codeword `x ∈ {0,1}^m` is the source subset indicator over the same - /// triple index set. In the sentinel branch the target witness has length - /// `1` (always `[0]`); we return the all-zero source-sized vector, - /// which decodes to `S = ∅`. `ThreeDimensionalMatching::evaluate(∅)` - /// then yields `Or(true)` iff `q == 0` (the correct answer for both - /// sentinel sub-cases). + /// 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( &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - if target_solution.len() == self.source_num_triples { - target_solution.to_vec() - } else { - vec![0; self.source_num_triples] - } - }) + let expected = self.target.num_cols(); + if target_solution.len() != expected { + return Err(crate::rules::ExtractionError::invalid(format!( + "expected {expected} codeword values, got {}", + target_solution.len() + ))); + } + Ok(target_solution[..self.source_num_triples].to_vec()) } } diff --git a/src/unit_tests/rules/partition_subsetsum.rs b/src/unit_tests/rules/partition_subsetsum.rs index c02398bcc..b980b6a2f 100644 --- a/src/unit_tests/rules/partition_subsetsum.rs +++ b/src/unit_tests/rules/partition_subsetsum.rs @@ -1,7 +1,6 @@ use super::*; use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; use crate::solvers::BruteForce; -use crate::traits::Problem; #[test] fn test_partition_to_subsetsum_closed_loop() { @@ -48,11 +47,11 @@ fn test_partition_to_subsetsum_odd_total() { let witness = BruteForce::new().find_witness(target); assert!(witness.is_none()); - // extract_solution should return all-zeros for the source - let extracted = reduction.extract_solution(&[]).unwrap(); - assert_eq!(extracted, vec![0, 0, 0]); - // The extracted solution should not satisfy the source - assert!(!source.evaluate(&extracted)); + let error = reduction.extract_solution(&[]).unwrap_err(); + assert_eq!( + error.to_string(), + "expected 3 subset-selection values, got 0" + ); } #[test] @@ -67,3 +66,11 @@ fn test_partition_to_subsetsum_equal_elements() { "Partition -> SubsetSum equal elements", ); } + +#[test] +fn test_partition_to_subsetsum_rejects_wrong_solution_length() { + let source = Partition::new(vec![1, 1, 2, 2]); + let reduction = ReduceTo::::reduce_to(&source); + + assert!(reduction.extract_solution(&[0, 1, 0]).is_err()); +} diff --git a/src/unit_tests/rules/partition_sumofsquarespartition.rs b/src/unit_tests/rules/partition_sumofsquarespartition.rs index 01eba1828..d124a0e72 100644 --- a/src/unit_tests/rules/partition_sumofsquarespartition.rs +++ b/src/unit_tests/rules/partition_sumofsquarespartition.rs @@ -106,7 +106,7 @@ fn test_partition_to_sumofsquarespartition_singleton_sentinel() { for witness in &target_witnesses { let extracted = reduction.extract_solution(witness).unwrap(); assert_eq!(extracted.len(), source.num_elements()); - assert_eq!(extracted, vec![0]); + assert_eq!(extracted, witness[..source.num_elements()]); assert!( !source.evaluate(&extracted).0, "singleton Partition: extracted witness must yield Or(false)" @@ -137,4 +137,6 @@ fn test_partition_to_sumofsquarespartition_solution_extraction_identity() { "extracted witness {extracted:?} must be a valid Partition solution" ); } + + assert!(reduction.extract_solution(&[0]).is_err()); } diff --git a/src/unit_tests/rules/satisfiability_naesatisfiability.rs b/src/unit_tests/rules/satisfiability_naesatisfiability.rs index 7a155ed7c..53c60a1ec 100644 --- a/src/unit_tests/rules/satisfiability_naesatisfiability.rs +++ b/src/unit_tests/rules/satisfiability_naesatisfiability.rs @@ -78,8 +78,10 @@ fn test_solution_extraction_distinguishes_zero_assignment_from_malformed_input() let error = reduction.extract_solution(&[0, 0]).unwrap_err(); assert_eq!( error.to_string(), - "expected at least 3 values including the sentinel, got 2" + "expected 3 values including the sentinel, got 2" ); + assert!(reduction.extract_solution(&[0, 0, 0, 0]).is_err()); + assert!(reduction.extract_solution(&[0, 2, 0]).is_err()); } #[test] diff --git a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs index 4e34e4ce6..4ca06949a 100644 --- a/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/unit_tests/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -165,4 +165,6 @@ fn test_threedimensionalmatching_to_minimumweightdecoding_solution_extraction_id "extracted witness {extracted:?} must be a valid 3DM solution" ); } + + assert!(reduction.extract_solution(&[0, 1, 0]).is_err()); } From 9e6960e32b46e9a0f32d9f63d258749a8791a2de Mon Sep 17 00:00:00 2001 From: Xiwei Pan <90967972+isPANN@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:49:15 +0800 Subject: [PATCH 31/31] Establish a repository-wide standard for solution extraction (#1119) * document solution extraction contract * enforce exact solution extraction * reject missing circuit factor variables * close extraction validation gaps * condense extraction guidance * test CLI structural extraction errors * teach exact solution extraction workflow * remove stale rule workflow guidance * drop legacy fixture checks * clarify extraction validation scope * reject malformed SAT dominating-set witnesses * require shared target validation --- .claude/CLAUDE.md | 12 ++-- .claude/skills/add-rule/SKILL.md | 31 +++++--- .claude/skills/final-review/SKILL.md | 4 +- .claude/skills/issue-to-pr/SKILL.md | 8 +-- .claude/skills/review-paper/SKILL.md | 4 +- .claude/skills/review-structural/SKILL.md | 6 +- .claude/skills/write-model-in-paper/SKILL.md | 10 +-- .claude/skills/write-rule-in-paper/SKILL.md | 8 +-- Makefile | 2 +- docs/agent-profiles/SKILLS.md | 10 +-- docs/paper/reductions.typ | 2 +- docs/src/design.md | 38 +++++++++- problemreductions-cli/tests/cli_tests.rs | 61 ++++++++++++++++ src/models/decision.rs | 2 + src/rules/acyclicpartition_ilp.rs | 13 +--- .../balancedcompletebipartitesubgraph_ilp.rs | 2 + src/rules/bicliquecover_bmf.rs | 2 + src/rules/biconnectivityaugmentation_ilp.rs | 2 + src/rules/binpacking_ilp.rs | 17 ++--- src/rules/bmf_bicliquecover.rs | 2 + src/rules/bmf_ilp.rs | 2 + src/rules/bottlenecktravelingsalesman_ilp.rs | 30 ++++---- .../boundedcomponentspanningforest_ilp.rs | 15 ++-- src/rules/capacityassignment_ilp.rs | 18 +++-- src/rules/circuit_ilp.rs | 2 + src/rules/circuit_sat.rs | 10 +-- src/rules/circuit_spinglass.rs | 18 ++--- src/rules/closeststring_ilp.rs | 8 +-- src/rules/closestsubstring_ilp.rs | 8 +-- src/rules/closestvectorproblem_qubo.rs | 10 +-- src/rules/clustering_ilp.rs | 26 +++---- src/rules/coloring_ilp.rs | 24 ++----- src/rules/coloring_qubo.rs | 14 ++-- src/rules/consecutiveblockminimization_ilp.rs | 7 +- .../consecutiveonesmatrixaugmentation_ilp.rs | 9 +-- src/rules/consecutiveonessubmatrix_ilp.rs | 2 + ...onsistencyofdatabasefrequencytables_ilp.rs | 28 +++++--- ...imumdominatingset_minimumsummulticenter.rs | 2 + ...nminimumdominatingset_minmaxmulticenter.rs | 2 + ...onminimumvertexcover_hamiltoniancircuit.rs | 6 +- src/rules/directedhamiltonianpath_ilp.rs | 4 +- .../directedtwocommodityintegralflow_ilp.rs | 2 + src/rules/disjointconnectingpaths_ilp.rs | 2 + src/rules/eulerianpath_ilp.rs | 10 ++- ...tcoverby3sets_algebraicequationsovergf2.rs | 2 + ...overby3sets_boundeddiameterspanningtree.rs | 12 +--- src/rules/exactcoverby3sets_ilp.rs | 2 + .../exactcoverby3sets_maximumsetpacking.rs | 2 + .../exactcoverby3sets_minimumaxiomset.rs | 4 +- ...verby3sets_minimumfaultdetectiontestset.rs | 2 + .../exactcoverby3sets_staffscheduling.rs | 2 + src/rules/exactcoverby3sets_subsetproduct.rs | 2 + src/rules/expectedretrievalcost_ilp.rs | 17 ++--- src/rules/factoring_circuit.rs | 31 ++++---- src/rules/factoring_ilp.rs | 6 +- src/rules/feasibleregisterassignment_ilp.rs | 2 + src/rules/flowshopscheduling_ilp.rs | 4 +- src/rules/graphpartitioning_ilp.rs | 2 + src/rules/graphpartitioning_maxcut.rs | 2 + src/rules/graphpartitioning_qubo.rs | 2 + ...oniancircuit_biconnectivityaugmentation.rs | 4 +- ...niancircuit_bottlenecktravelingsalesman.rs | 2 + .../hamiltoniancircuit_hamiltonianpath.rs | 10 +-- .../hamiltoniancircuit_longestcircuit.rs | 2 + .../hamiltoniancircuit_quadraticassignment.rs | 2 + src/rules/hamiltoniancircuit_ruralpostman.rs | 6 +- src/rules/hamiltoniancircuit_stackercrane.rs | 2 + ...ncircuit_strongconnectivityaugmentation.rs | 2 + .../hamiltoniancircuit_travelingsalesman.rs | 2 + ...onianpath_degreeconstrainedspanningtree.rs | 10 +-- src/rules/hamiltonianpath_ilp.rs | 9 +-- .../hamiltonianpath_isomorphicspanningtree.rs | 2 + ...onianpathbetweentwovertices_longestpath.rs | 2 + src/rules/highlyconnecteddeletion_ilp.rs | 8 +-- src/rules/ilp_bool_ilp_i32.rs | 2 + src/rules/ilp_helpers.rs | 54 ++++++++++++-- src/rules/ilp_i32_ilp_bool.rs | 2 + src/rules/ilp_qubo.rs | 2 + src/rules/integerknapsack_ilp.rs | 2 + src/rules/integralflowbundles_ilp.rs | 2 + src/rules/integralflowhomologousarcs_ilp.rs | 2 + src/rules/integralflowwithmultipliers_ilp.rs | 2 + src/rules/isomorphicspanningtree_ilp.rs | 13 +--- ...lique_balancedcompletebipartitesubgraph.rs | 2 + src/rules/kclique_conjunctivebooleanquery.rs | 2 + src/rules/kclique_ilp.rs | 2 + src/rules/kclique_subgraphisomorphism.rs | 2 + src/rules/kcoloring_bicliquecover.rs | 53 +++++++------- src/rules/kcoloring_clustering.rs | 4 +- src/rules/kcoloring_partitionintocliques.rs | 2 + ...kcoloring_twodimensionalconsecutivesets.rs | 2 + src/rules/knapsack_ilp.rs | 2 + src/rules/knapsack_qubo.rs | 2 + src/rules/ksatisfiability_acyclicpartition.rs | 21 +++--- src/rules/ksatisfiability_bicliquecover.rs | 15 +--- src/rules/ksatisfiability_cyclicordering.rs | 2 + ...bility_directedtwocommodityintegralflow.rs | 12 +--- ...tisfiability_feasibleregisterassignment.rs | 2 + src/rules/ksatisfiability_kclique.rs | 2 + src/rules/ksatisfiability_kernel.rs | 4 +- .../ksatisfiability_minimumvertexcover.rs | 2 + .../ksatisfiability_monochromatictriangle.rs | 9 +-- ...satisfiability_oneinthreesatisfiability.rs | 2 + .../ksatisfiability_preemptivescheduling.rs | 2 + .../ksatisfiability_quadraticcongruences.rs | 12 ++-- ...fiability_quadraticdiophantineequations.rs | 2 + src/rules/ksatisfiability_qubo.rs | 4 ++ .../ksatisfiability_registersufficiency.rs | 16 +++-- ...atisfiability_simultaneousincongruences.rs | 4 +- src/rules/ksatisfiability_subsetsum.rs | 2 + src/rules/ksatisfiability_timetabledesign.rs | 2 + src/rules/lengthboundeddisjointpaths_ilp.rs | 2 + src/rules/longestcircuit_ilp.rs | 2 + src/rules/longestcommonsubsequence_ilp.rs | 21 +++--- ...commonsubsequence_maximumindependentset.rs | 2 + src/rules/longestpath_ilp.rs | 14 ++-- src/rules/maxcut_minimumcutintoboundedsets.rs | 2 + src/rules/maxcut_minimummatrixcover.rs | 2 + src/rules/maximalis_ilp.rs | 2 + src/rules/maximum2satisfiability_ilp.rs | 2 + src/rules/maximum2satisfiability_maxcut.rs | 2 + src/rules/maximumclique_ilp.rs | 2 + .../maximumclique_maximumindependentset.rs | 2 + src/rules/maximumcokplex_ilp.rs | 2 + src/rules/maximumcommonedgesubgraph_ilp.rs | 27 ++++--- src/rules/maximumcontactmapoverlap_ilp.rs | 28 ++++---- src/rules/maximumdomaticnumber_ilp.rs | 2 + src/rules/maximumedgeweightedkclique_ilp.rs | 2 + src/rules/maximumindependentset_gridgraph.rs | 2 + ...ximumindependentset_integralflowbundles.rs | 10 +-- .../maximumindependentset_maximumclique.rs | 2 + ...maximumindependentset_maximumsetpacking.rs | 4 ++ src/rules/maximumindependentset_triangular.rs | 2 + src/rules/maximumleafspanningtree_ilp.rs | 2 + src/rules/maximumlikelihoodranking_ilp.rs | 2 + src/rules/maximummatching_ilp.rs | 2 + .../maximummatching_maximumsetpacking.rs | 2 + src/rules/maximumsetpacking_ilp.rs | 2 + src/rules/maximumsetpacking_qubo.rs | 2 + .../minimumcapacitatedspanningtree_ilp.rs | 2 + ...mcostmaximumflow_minimumcostcirculation.rs | 2 + src/rules/minimumcoveringbycliques_ilp.rs | 30 ++++---- ...bycliques_minimumintersectiongraphbasis.rs | 2 + src/rules/minimumcutintoboundedsets_ilp.rs | 2 + ...mumdiscreteplanarinversekinematics_qubo.rs | 34 +++++---- src/rules/minimumdominatingset_ilp.rs | 2 + src/rules/minimumedgecostflow_ilp.rs | 2 + ...minimumexternalmacrodatacompression_ilp.rs | 70 +++++++++++-------- src/rules/minimumfaultdetectiontestset_ilp.rs | 2 + src/rules/minimumfeedbackarcset_ilp.rs | 2 + ...feedbackarcset_maximumlikelihoodranking.rs | 2 + src/rules/minimumfeedbackvertexset_ilp.rs | 2 + ...minimumcodegenerationunlimitedregisters.rs | 2 + src/rules/minimumgraphbandwidth_ilp.rs | 18 +++-- src/rules/minimumhittingset_ilp.rs | 2 + ...minimuminternalmacrodatacompression_ilp.rs | 2 + src/rules/minimummatrixcover_ilp.rs | 2 + src/rules/minimummaximalmatching_ilp.rs | 2 + ...maximalmatching_maximumachromaticnumber.rs | 2 + ...maximalmatching_minimummatrixdomination.rs | 45 ++++++------ src/rules/minimummetricdimension_ilp.rs | 2 + src/rules/minimummultiwaycut_ilp.rs | 2 + src/rules/minimummultiwaycut_qubo.rs | 11 ++- src/rules/minimumsetcovering_ilp.rs | 2 + src/rules/minimumsummulticenter_ilp.rs | 2 + src/rules/minimumtardinesssequencing_ilp.rs | 8 ++- ...nimumvertexcover_comparativecontainment.rs | 5 +- .../minimumvertexcover_ensemblecomputation.rs | 2 + ...mumvertexcover_longestcommonsubsequence.rs | 2 + ...inimumvertexcover_maximumindependentset.rs | 4 ++ ...inimumvertexcover_minimumfeedbackarcset.rs | 2 + ...mumvertexcover_minimumfeedbackvertexset.rs | 2 + .../minimumvertexcover_minimumhittingset.rs | 2 + .../minimumvertexcover_minimumsetcovering.rs | 2 + ...imumvertexcover_minimumweightandorgraph.rs | 4 +- src/rules/minimumweightdecoding_ilp.rs | 2 + src/rules/minmaxmulticenter_ilp.rs | 2 + src/rules/mixedchinesepostman_ilp.rs | 2 + src/rules/mod.rs | 2 +- src/rules/monochromatictriangle_ilp.rs | 2 + src/rules/multiplecopyfileallocation_ilp.rs | 2 + src/rules/multiprocessorscheduling_ilp.rs | 18 +++-- src/rules/naesatisfiability_ilp.rs | 2 + src/rules/naesatisfiability_maxcut.rs | 2 + ...fiability_partitionintoperfectmatchings.rs | 2 + src/rules/naesatisfiability_setsplitting.rs | 12 +--- ...atching_numericalmatchingwithtargetsums.rs | 14 +++- .../numericalmatchingwithtargetsums_ilp.rs | 2 + src/rules/openshopscheduling_ilp.rs | 4 +- ...ement_consecutiveonesmatrixaugmentation.rs | 10 +-- src/rules/optimallineararrangement_ilp.rs | 18 +++-- ...uencingtominimizeweightedcompletiontime.rs | 8 ++- .../optimumcommunicationspanningtree_ilp.rs | 2 + src/rules/paintshop_ilp.rs | 2 + src/rules/paintshop_qubo.rs | 2 + src/rules/partiallyorderedknapsack_ilp.rs | 2 + src/rules/partition_binpacking.rs | 2 + .../partition_cosineproductintegration.rs | 2 + .../partition_integralflowwithmultipliers.rs | 8 +-- src/rules/partition_knapsack.rs | 2 + .../partition_multiprocessorscheduling.rs | 2 + src/rules/partition_openshopscheduling.rs | 33 ++++++--- src/rules/partition_productionplanning.rs | 13 ++-- ...ion_sequencingtominimizetardytaskweight.rs | 35 +++++----- src/rules/partition_subsetsum.rs | 2 + src/rules/partition_sumofsquarespartition.rs | 9 +-- ...ionintocliques_minimumcoveringbycliques.rs | 26 +++---- ...flength2_boundedcomponentspanningforest.rs | 2 + src/rules/partitionintopathsoflength2_ilp.rs | 21 +++--- src/rules/partitionintotriangles_ilp.rs | 21 +++--- src/rules/pathconstrainednetworkflow_ilp.rs | 2 + .../precedenceconstrainedscheduling_ilp.rs | 18 +++-- src/rules/preemptivescheduling_ilp.rs | 4 +- ...rizecollectingsteinerforest_steinertree.rs | 4 +- src/rules/quadraticassignment_ilp.rs | 18 +++-- src/rules/qubo_ilp.rs | 2 + .../rectilinearpicturecompression_ilp.rs | 2 + src/rules/registersufficiency_ilp.rs | 2 + .../resourceconstrainedscheduling_ilp.rs | 18 +++-- ...arrangement_rootedtreestorageassignment.rs | 2 + src/rules/rootedtreestorageassignment_ilp.rs | 14 ++-- src/rules/ruralpostman_ilp.rs | 2 + src/rules/sat_circuitsat.rs | 2 + src/rules/sat_coloring.rs | 25 ++++--- src/rules/sat_ksat.rs | 4 ++ src/rules/sat_maximumindependentset.rs | 2 + src/rules/sat_minimumdominatingset.rs | 67 +++++++----------- ...tisfiability_integralflowhomologousarcs.rs | 12 +--- .../satisfiability_maximum2satisfiability.rs | 2 + src/rules/satisfiability_naesatisfiability.rs | 20 +----- src/rules/satisfiability_nontautology.rs | 2 + ...ingtominimizeweightedcompletiontime_ilp.rs | 13 ++-- .../schedulingwithindividualdeadlines_ilp.rs | 14 ++-- ...cingtominimizemaximumcumulativecost_ilp.rs | 4 +- ...sequencingtominimizetardytaskweight_ilp.rs | 4 +- ...ingtominimizeweightedcompletiontime_ilp.rs | 4 +- ...quencingtominimizeweightedtardiness_ilp.rs | 4 +- ...equencingwithdeadlinesandsetuptimes_ilp.rs | 4 +- src/rules/sequencingwithinintervals_ilp.rs | 28 +++++--- ...uencingwithreleasetimesanddeadlines_ilp.rs | 13 ++-- src/rules/setsplitting_betweenness.rs | 25 ++----- src/rules/setsplitting_ilp.rs | 2 + src/rules/shortestcommonsupersequence_ilp.rs | 19 +++-- .../shortestweightconstrainedpath_ilp.rs | 14 ++-- src/rules/sparsematrixcompression_ilp.rs | 18 +++-- src/rules/spinglass_maxcut.rs | 4 ++ src/rules/spinglass_qubo.rs | 4 ++ src/rules/stackercrane_ilp.rs | 4 +- src/rules/steinertree_ilp.rs | 2 + src/rules/steinertreeingraphs_ilp.rs | 2 + src/rules/stringtostringcorrection_ilp.rs | 41 +++++------ .../strongconnectivityaugmentation_ilp.rs | 2 + src/rules/subgraphisomorphism_ilp.rs | 20 +++--- src/rules/subsetsum_closestvectorproblem.rs | 2 + .../subsetsum_integerexpressionmembership.rs | 2 + src/rules/subsetsum_partition.rs | 2 + src/rules/sumofsquarespartition_ilp.rs | 21 +++--- src/rules/test_helpers.rs | 8 +++ src/rules/threedimensionalmatching_ilp.rs | 2 + ...mensionalmatching_minimumweightdecoding.rs | 9 +-- ...sionalmatching_threematroidintersection.rs | 2 + ...threedimensionalmatching_threepartition.rs | 2 + ...partition_resourceconstrainedscheduling.rs | 2 + ..._sequencingwithreleasetimesanddeadlines.rs | 10 ++- src/rules/timetabledesign_ilp.rs | 2 + src/rules/traits.rs | 30 ++++++++ src/rules/travelingsalesman_ilp.rs | 38 ++++------ src/rules/travelingsalesman_qubo.rs | 23 +++--- src/rules/undirectedflowlowerbounds_ilp.rs | 2 + .../undirectedtwocommodityintegralflow_ilp.rs | 2 + src/unit_tests/example_db.rs | 23 ++++++ src/unit_tests/rules/ilp_helpers.rs | 21 +++++- .../rules/ksatisfiability_acyclicpartition.rs | 11 +++ .../ksatisfiability_quadraticcongruences.rs | 9 +++ ...ement_consecutiveonesmatrixaugmentation.rs | 4 +- .../rules/sat_minimumdominatingset.rs | 32 ++++++++- .../rules/satisfiability_naesatisfiability.rs | 5 +- src/unit_tests/rules/traits.rs | 14 +++- 278 files changed, 1394 insertions(+), 993 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 63928ab40..502e837f5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,7 +25,7 @@ These repo-local skills live under `.claude/skills/*/SKILL.md`. - [review-quality](skills/review-quality/SKILL.md) -- Generic code quality review: DRY, KISS, cohesion/coupling, test quality, HCI. Read-only, no code changes. Called by `review-pipeline`. - [fix-pr](skills/fix-pr/SKILL.md) -- Resolve PR review comments, fix CI failures, and address codecov coverage gaps. Uses `gh api` for codecov (not local `cargo-llvm-cov`). - [write-model-in-paper](skills/write-model-in-paper/SKILL.md) -- Write or improve a problem-def entry in the Typst paper (standalone, for improving existing entries). Core instructions are inlined in `add-model` Step 6. -- [write-rule-in-paper](skills/write-rule-in-paper/SKILL.md) -- Write or improve a reduction-rule entry in the Typst paper (standalone, for improving existing entries). Core instructions are inlined in `add-rule` Step 5. +- [write-rule-in-paper](skills/write-rule-in-paper/SKILL.md) -- Write or improve a reduction-rule entry in the Typst paper (standalone, for improving existing entries). Core instructions are inlined in `add-rule` Step 6. - [release](skills/release/SKILL.md) -- Create a new crate release. Determines version bump from diff, verifies tests/clippy, then runs `make release`. - [check-issue](skills/check-issue/SKILL.md) -- Quality gate for `[Rule]` and `[Model]` issues. Checks usefulness, non-triviality, correctness of literature, and writing quality. Posts structured report and adds failure labels. - [fix-issue](skills/fix-issue/SKILL.md) -- Fix quality issues found by check-issue — auto-fixes mechanical problems, brainstorms substantive issues with human, then re-checks and moves to Ready. @@ -59,7 +59,7 @@ make fmt-check # Check code formatting make clippy # Run clippy lints make doc # Build mdBook documentation (includes reduction graph export) make mdbook # Build and serve mdBook with live reload -make paper # Build Typst paper from checked-in example fixtures +make paper # Generate example data and build the Typst paper make coverage # Generate coverage report (>95% required) make check # Quick pre-commit check (fmt + clippy + test) make rust-export # Generate Julia parity test data (mapping stages) @@ -158,6 +158,8 @@ Max, Min, Sum, Or, And, Extremum, ExtremumSense - `Solver::solve()` computes the aggregate value for any `Problem` whose `Value` implements `Aggregate` - `BruteForce::find_witness()` / `find_all_witnesses()` recover witnesses only when `P::Value::supports_witnesses()` - `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)`) - Graph types: SimpleGraph, PlanarGraph, BipartiteGraph, UnitDiskGraph, KingsSubgraph, TriangularSubgraph - Weight types: `One` (unit weight marker), `i32`, `f64` — all implement `WeightElement` trait @@ -208,14 +210,14 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - Aggregate-only models are first-class in `declare_variants!`; aggregate-only and Turing reduction edges still need manual `ReductionEntry` wiring because `#[reduction]` only registers witness/config reductions today - 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 paper and CLI examples live in `src/example_db/model_builders.rs` and `src/example_db/rule_builders.rs` +- 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` ## Conventions ### 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) -- Canonical examples: builder functions in `src/example_db/rule_builders.rs` and `src/example_db/model_builders.rs` +- Canonical examples: model builders in `src/example_db/model_builders.rs`; rule-local `canonical_rule_example_specs()` functions collected by `src/rules/mod.rs` - Example binaries in `examples/`: utility/export tools and pedagogical demos only (not per-reduction files) - Test naming: `test__to__closed_loop` @@ -261,7 +263,7 @@ Model review automation checks for a dedicated test file under `src/unit_tests/m - `.claude/` — Claude Code instructions and skills - `docs/book/` — mdBook user documentation (built with `make doc`) - `docs/paper/reductions.typ` — Typst paper with problem definitions and reduction theorems -- `src/example_db/` — Canonical model/rule examples: `model_builders.rs`, `rule_builders.rs` (in-memory builders), `specs.rs` (per-module invariant specs), consumed by `pred create --example` and paper exports +- `src/example_db/` — Model builders, shared example specs, and rule-example aggregation consumed by `pred create --example` and paper exports - `examples/` — Export utilities, graph-analysis helpers, and pedagogical demos ## Documentation Requirements diff --git a/.claude/skills/add-rule/SKILL.md b/.claude/skills/add-rule/SKILL.md index 33a303af6..5e9b01ea6 100644 --- a/.claude/skills/add-rule/SKILL.md +++ b/.claude/skills/add-rule/SKILL.md @@ -106,13 +106,19 @@ impl ReductionResult for ReductionXToY { type Source = SourceType; type Target = TargetType; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution(&self, target_solution: &[usize]) -> Vec { - // Map target solution back to source solution - // If Step 1 ran: translate the verified Python extract_solution() logic + fn extract_solution( + &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) } } ``` +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**): ```rust #[reduction(overhead = { @@ -156,6 +162,8 @@ Additional recommended tests: - 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()` @@ -163,9 +171,9 @@ For aggregate-only reductions, replace the closed-loop witness test with value-c Link via `#[cfg(test)] #[path = "..."] mod tests;` at the bottom of the rule file. -## Step 5: Add canonical example to example_db +## Step 5: Add canonical example -Add a builder function in `src/example_db/rule_builders.rs` that constructs a small, canonical instance for this reduction. Follow the existing patterns in that file. Register the builder in `build_rule_examples()`. +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. ## Step 6: Document in paper (MANDATORY — DO NOT SKIP) @@ -231,11 +239,11 @@ Checklist: notation self-contained, complexity cited, overhead consistent, examp ```bash cargo run --example export_graph # Generate reduction_graph.json for docs/paper builds cargo run --example export_schemas # Generate problem schemas for docs/paper builds -make regenerate-fixtures # Regenerate example_db/fixtures/examples.json (slow, needs ILP) +cargo run --features "example-db" --example export_examples make test clippy # Must pass ``` -`make regenerate-fixtures` is required so the paper can load the new rule's example data from `src/example_db/fixtures/examples.json`. Without it, the `reduction-rule` entry in Step 6 will reference missing fixture data. +`export_examples` refreshes the gitignored `docs/paper/data/examples.json` used by the paper. Structural and quality review is handled by the `review-pipeline` stage, not here. The run stage just needs to produce working code. @@ -251,6 +259,8 @@ Structural and quality review is handled by the `review-pipeline` stage, not her Adding a witness-preserving reduction rule does NOT require CLI changes -- the reduction graph is auto-generated from `#[reduction]` macros and the CLI discovers paths dynamically. However, both source and target models must already be fully registered through their model files (`declare_variants!`), aliases as needed in `problem_name.rs`, and `pred create` support where applicable (see `add-model` skill). +`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 @@ -261,7 +271,7 @@ Aggregate-only reductions currently have a narrower CLI surface: - Rule file: `src/rules/_.rs` -- no underscores within a problem name - e.g., `maximumindependentset_qubo.rs`, `minimumvertexcover_maximumindependentset.rs` - Test file: `src/unit_tests/rules/_.rs` -- Canonical example: builder function in `src/example_db/rule_builders.rs` +- Canonical example: `canonical_rule_example_specs()` in the rule module, included from `src/rules/mod.rs` ## Common Mistakes @@ -272,9 +282,10 @@ Aggregate-only reductions currently have a narrower CLI surface: | 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 | -| Not adding canonical example to `example_db` | Add builder in `src/example_db/rule_builders.rs` | +| Permissive extraction | Validate first, then map exactly or return `ExtractionError` | +| 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 5 (paper documentation) | **Every rule MUST have a `reduction-rule` entry in the paper. This is mandatory, not optional. PRs without documentation will be rejected.** | +| 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 `declare_variants!`, aliases as needed, and CLI create support -- 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 | | Skipping verification for complex reductions | Verification is default for a reason — `--no-verify` is for trivial identity/complement reductions only | diff --git a/.claude/skills/final-review/SKILL.md b/.claude/skills/final-review/SKILL.md index 633d86ad8..ced146b7d 100644 --- a/.claude/skills/final-review/SKILL.md +++ b/.claude/skills/final-review/SKILL.md @@ -168,12 +168,12 @@ Use `AskUserQuestion` with your recommendation: Scan the PR diff for dangerous actions: -- **Blacklisted files**: If the diff touches `docs/src/reductions/reduction_graph.json`, `docs/src/reductions/problem_schemas.json`, or `src/example_db/fixtures/examples.json` (legacy, no longer exists), **block merge**. These files are auto-generated and must not be committed in PRs — they are rebuilt by CI/`make doc`/`make paper`. Flag immediately and recommend OnHold. +- **Blacklisted files**: If the diff touches `docs/src/reductions/reduction_graph.json` or `docs/src/reductions/problem_schemas.json`, **block merge**. These files are auto-generated and must not be committed in PRs — they are rebuilt by CI/`make doc`/`make paper`. Flag immediately and recommend OnHold. - **Removed features**: Any existing model, rule, test, or example deleted? - **Unrelated changes**: Files modified that don't belong to this PR (e.g., changes to unrelated models/rules, CI config, Cargo.toml dependency changes not needed for this PR) - **Force push indicators**: Any sign of history rewriting - **Broad modifications**: Changes to core traits, macros, or shared infrastructure that could affect other features -- **No committed `examples.json`**: The example database is generated on demand by `make paper` (via `export_examples`). PRs should not commit `src/example_db/fixtures/examples.json` (legacy path, deleted) or `docs/paper/data/examples.json` (current output path) — both are gitignored build artifacts. +- **No committed `examples.json`**: The example database is generated on demand by `make paper` (via `export_examples`). Do not commit the gitignored `docs/paper/data/examples.json` build artifact. Report findings with fix options for each concern: diff --git a/.claude/skills/issue-to-pr/SKILL.md b/.claude/skills/issue-to-pr/SKILL.md index 2573bedd5..87734784e 100644 --- a/.claude/skills/issue-to-pr/SKILL.md +++ b/.claude/skills/issue-to-pr/SKILL.md @@ -92,12 +92,12 @@ Write implementation plan to `docs/plans/YYYY-MM-DD-.md` using `superpower The plan MUST reference the appropriate implementation skill and follow its steps: - **For ordinary `[Model]` issues:** Follow [add-model](../add-model/SKILL.md) Steps 1-7 as the action pipeline -- **For `[Model]` issues that explicitly claim direct ILP solving:** Follow [add-model](../add-model/SKILL.md) Steps 1-7 **and** [add-rule](../add-rule/SKILL.md) Steps 1-6 for the direct ` -> ILP` rule in the same plan / PR +- **For `[Model]` issues that explicitly claim direct ILP solving:** Follow [add-model](../add-model/SKILL.md) Steps 1-7 **and** [add-rule](../add-rule/SKILL.md) Steps 1-7 for the direct ` -> ILP` rule in the same plan / PR - **For `[Rule]` issues:** Follow [add-rule](../add-rule/SKILL.md) Steps 1-7 as the action pipeline. By default, `/add-rule` runs mathematical verification (Step 1) before implementation. If `--no-verify` was passed, include `--no-verify` when invoking `/add-rule` to skip verification. Include the concrete details from the issue (problem definition, reduction algorithm, example, etc.) mapped onto each step. -**Plan batching:** The paper writing step (add-model Step 6 / add-rule Step 5) MUST be in a **separate batch** from the implementation steps, so it gets its own subagent with fresh context. It depends on the implementation being complete (needs exports). Example batch structure for a `[Model]` plan: +**Plan batching:** The paper writing step (add-model Step 6 / add-rule Step 6) MUST be in a **separate batch** from the implementation steps, so it gets its own subagent with fresh context. It depends on the implementation being complete (needs exports). Example batch structure for a `[Model]` plan: - Batch 1: Steps 1-5.5 (implement model, register, CLI, tests) - Batch 2: Step 6 (write paper entry — depends on batch 1 for exports) @@ -112,8 +112,8 @@ For a `[Model]` issue with an explicit direct ILP claim, use: - Otherwise, ensure the information provided is enough to implement a solver. **Example rules:** -- Implement the user-provided example instance in the canonical `example_db` path for the issue (`src/example_db/model_builders.rs` or `src/example_db/rule_builders.rs`, as appropriate). -- Run the relevant export and fixture regeneration steps; verify the generated example data against the user-provided information. +- Implement the user-provided example in `src/example_db/model_builders.rs` for a model, or in the rule-local `canonical_rule_example_specs()` for a rule. +- Run the relevant exports and verify the generated example data against the user-provided information. - Present in `docs/paper/reductions.typ` in tutorial style with clear intuition (see KColoring->QUBO section for reference). ### 6. Create PR (or Resume Existing) diff --git a/.claude/skills/review-paper/SKILL.md b/.claude/skills/review-paper/SKILL.md index d1c98cdf6..e5a8e2378 100644 --- a/.claude/skills/review-paper/SKILL.md +++ b/.claude/skills/review-paper/SKILL.md @@ -46,7 +46,7 @@ For each of the 10 entries, read the full entry text and evaluate against the ch | M3. Self-contained notation | Every symbol in `def` is defined before first use | | M4. Background text | Body contains at least 2 sentences of background/motivation | | M5. Example present | Body contains `*Example.*` or `Example.` | -| M6. Example from fixture | Example data matches `src/example_db/fixtures/examples.json` (not invented) — check by loading the JSON and comparing | +| M6. Example from fixture | Example data matches `docs/paper/data/examples.json` (not invented) — check by loading the JSON and comparing | | M7. Figure present | Body contains `#figure(` | | M8. Pred commands | Body contains `pred-commands(` or `pred create` | | M9. Algorithm citation | Complexity claims have `@citation` or a footnote explaining absence | @@ -72,7 +72,7 @@ For each of the 10 entries, read the full entry text and evaluate against the ch | M3. Proof length | Proof is at least 3 sentences (not just "trivial" or a one-liner) | | M4. Overhead documented | Overhead is auto-generated from JSON (verify edge exists in `reduction_graph.json`) | | M5. Example present | `example: true` and example renders correctly | -| M6. Example from fixture | Example data matches `src/example_db/fixtures/examples.json` | +| M6. Example from fixture | Example data matches `docs/paper/data/examples.json` | | M7. Pred commands | Example section contains `pred-commands(` with create/reduce/evaluate pipeline | | M8. Both directions | If the reverse rule also exists in the graph, check it has its own entry | diff --git a/.claude/skills/review-structural/SKILL.md b/.claude/skills/review-structural/SKILL.md index 5c30e15b6..cdf144284 100644 --- a/.claude/skills/review-structural/SKILL.md +++ b/.claude/skills/review-structural/SKILL.md @@ -81,16 +81,16 @@ Only run if review type includes "rule". Given: source `S`, target `T`, rule fil | 6 | Test file exists | `Glob("src/unit_tests/rules/{R}.rs")` | | 7 | Closed-loop test present | `Grep("fn test_.*closed_loop\|fn test_.*to_.*basic", test_file)` | | 8 | Registered in `rules/mod.rs` | `Grep("mod {R}", "src/rules/mod.rs")` | -| 9 | Canonical rule example registered | `Grep("{S}|{T}|{R}", "src/example_db/rule_builders.rs")` | +| 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. | ## Step 2b: Blacklisted File Check Scan the PR's changed files for auto-generated files that must never be committed: - `docs/src/reductions/reduction_graph.json` - `docs/src/reductions/problem_schemas.json` -- `src/example_db/fixtures/examples.json` (legacy path, deleted on main) - `docs/paper/data/examples.json` (current output path, gitignored) If any of these files appear in the diff, report **FAIL — blacklisted auto-generated file committed**. These files are rebuilt by CI/`make doc`/`make paper` and must not be in PRs. @@ -113,7 +113,7 @@ Report pass/fail. If tests fail, identify which tests. **Do NOT fix anything** 4. **Weight handling** — Are weights managed via inherent methods, not traits? ### For Rules: -1. **`extract_solution` correctness** — Does it correctly invert the reduction? Does the returned solution have the right length (source dimensions)? +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? 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? diff --git a/.claude/skills/write-model-in-paper/SKILL.md b/.claude/skills/write-model-in-paper/SKILL.md index 9ded09507..e3c95d4dd 100644 --- a/.claude/skills/write-model-in-paper/SKILL.md +++ b/.claude/skills/write-model-in-paper/SKILL.md @@ -126,16 +126,16 @@ achieves $O^*(2^n)$ @bjorklund2009. ### 3c. Example with Visualization -A concrete small instance that illustrates the problem. **The example must use data from the checked-in canonical fixture DB**, not an independently invented instance. +A concrete small instance that illustrates the problem. **Use the generated canonical example data**, not an independently invented instance. #### Sourcing example data -1. If you changed example builders/specs, run `make regenerate-fixtures` to refresh `src/example_db/fixtures/examples.json`. -2. Find the problem's entry in `src/example_db/fixtures/examples.json` under `models` — it contains the canonical `instance`, `samples`, and `optimal` fields. +1. If you changed example builders/specs, run `cargo run --features "example-db" --example export_examples`. +2. Find the problem's entry in `docs/paper/data/examples.json` under `models` — it contains the canonical `instance`, `samples`, and `optimal` fields. 3. Use the values from `instance` in the paper example (translating 0-indexed code values to 1-indexed math notation where conventional, e.g., vertices {0,...,n-1} → {1,...,n}). 4. Use `optimal` configurations to show the solution. -**Do not invent a different instance.** If the canonical example is too large or not pedagogically ideal, fix it in `canonical_model_example_specs()` first, re-run `make regenerate-fixtures`, then write the paper entry from the updated JSON. +**Do not invent a different instance.** If the canonical example is unsuitable, fix it in `canonical_model_example_specs()`, re-run `export_examples`, then use the updated JSON. #### Requirements @@ -206,7 +206,7 @@ make paper - [ ] **Notation self-contained**: every symbol in `def` is defined before first use - [ ] **Background present**: historical context, applications, or structural properties - [ ] **Algorithms cited**: every complexity claim has `@citation` or footnote warning -- [ ] **Example from JSON**: instance data matches `src/example_db/fixtures/examples.json` canonical example (not independently invented) +- [ ] **Example from JSON**: instance data matches the canonical entry in `docs/paper/data/examples.json` - [ ] **Evaluation shown**: objective/verifier computed on the example solution - [ ] **Diagram included**: figure with caption and label for graph/matrix/set visualization - [ ] **Paper compiles**: `make paper` succeeds without errors diff --git a/.claude/skills/write-rule-in-paper/SKILL.md b/.claude/skills/write-rule-in-paper/SKILL.md index 0d9755b1b..b1bda1d9c 100644 --- a/.claude/skills/write-rule-in-paper/SKILL.md +++ b/.claude/skills/write-rule-in-paper/SKILL.md @@ -7,7 +7,7 @@ description: Use when writing or improving a reduction-rule entry in the Typst p Full authoring guide for writing a `reduction-rule` entry in `docs/paper/reductions.typ`. Covers Typst mechanics, writing quality, and verification. -> **Note:** This content is also inlined in `add-rule` Step 5 (condensed form). This standalone version has more detail and is useful for improving existing entries. +> **Note:** This content is also inlined in `add-rule` Step 6 (condensed form). This standalone version has more detail and is useful for improving existing entries. ## Reference Example @@ -17,8 +17,8 @@ Full authoring guide for writing a `reduction-rule` entry in `docs/paper/reducti Before using this skill, ensure: - The reduction is implemented and tested (`src/rules/_.rs`) -- A canonical example exists in `src/example_db/rule_builders.rs` -- If the canonical example changed, fixtures are regenerated (`make regenerate-fixtures`) +- A rule-local `canonical_rule_example_specs()` exists and is included by `src/rules/mod.rs` +- If the canonical example changed, regenerate the paper data with `cargo run --features "example-db" --example export_examples` - The reduction graph and schemas are up to date (`cargo run --example export_graph && cargo run --example export_schemas`) ## Source Material @@ -38,7 +38,7 @@ Do NOT invent proofs — always cross-check against the issue and derivation sou ``` Where: -- `load-example(source, target, ...)` looks up the canonical rule entry from `src/example_db/fixtures/examples.json` +- `load-example(source, target, ...)` looks up the canonical rule entry from `docs/paper/data/examples.json` - The returned record contains `source`, `target`, and `solutions` - Access fields: `src_tgt.source.instance`, `src_tgt.target.instance`, `src_tgt_sol.source_config`, `src_tgt_sol.target_config` diff --git a/Makefile b/Makefile index a854eba53..5df8224a0 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ help: @echo " doc - Build mdBook documentation" @echo " diagrams - Generate SVG diagrams from Typst (light + dark)" @echo " mdbook - Build and serve mdBook (with live reload)" - @echo " paper - Build Typst paper from checked-in fixtures (requires typst)" + @echo " paper - Generate example data and build the Typst paper (requires typst)" @echo " coverage - Generate coverage report (requires cargo-llvm-cov)" @echo " clean - Clean build artifacts" @echo " check - Quick check (fmt + clippy + test)" diff --git a/docs/agent-profiles/SKILLS.md b/docs/agent-profiles/SKILLS.md index b7c7a6e3a..df3ffde16 100644 --- a/docs/agent-profiles/SKILLS.md +++ b/docs/agent-profiles/SKILLS.md @@ -1,20 +1,20 @@ # Skills -Example generation now goes through the example catalog and checked-in fixture DB. +Example generation goes through the example catalog and generated paper data. When a workflow needs a paper/example instance, prefer the catalog path over ad hoc `examples/reduction_*.rs` binaries: -- use `src/example_db/fixtures/examples.json` directly for paper/example data -- use `make regenerate-fixtures` when canonical examples change +- use `docs/paper/data/examples.json` directly for paper/example data +- run `cargo run --features "example-db" --example export_examples` when canonical examples change - use `pred create --example ` to materialize a canonical model example as normal problem JSON - use `pred create --example --to ` to materialize a canonical rule example as normal problem JSON - when adding new example coverage, register a catalog entry instead of creating a new standalone reduction example file Post-refactor extension points: -- new model load/serialize/brute-force dispatch comes from `declare_variants!` in the model file, with explicit `opt` or `sat` markers and an optional `default` +- new model load/serialize/brute-force dispatch comes from `declare_variants!` in the model file, with an optional `default` - alias resolution lives in `problemreductions-cli/src/problem_name.rs` - `pred create` UX lives in `problemreductions-cli/src/commands/create.rs` -- canonical examples live in `src/example_db/model_builders.rs` and `src/example_db/rule_builders.rs` +- model examples live in `src/example_db/model_builders.rs`; rule examples live beside their rules and are collected by `src/rules/mod.rs` - [issue-to-pr] — Convert a GitHub issue into a PR with an implementation plan - [add-model] — Add a new problem model to the codebase diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 88cd07556..bdf289f4c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -65,7 +65,7 @@ #show: thmrules.with(qed-symbol: $square$) // === Example JSON helpers === -// Load canonical example database directly from the checked-in fixture file. +// Load the generated canonical example database. #let example-db = json("data/examples.json") // Pre-index rules by (source, target) and models by name so lookups are O(bucket) diff --git a/docs/src/design.md b/docs/src/design.md index 1fec44b46..20f351018 100644 --- a/docs/src/design.md +++ b/docs/src/design.md @@ -162,12 +162,46 @@ impl ReductionResult for ReductionISToVC { type Target = MinimumVertexCover; fn target_problem(&self) -> &Self::Target { &self.target } - fn extract_solution(&self, target_sol: &[usize]) -> Vec { - target_sol.iter().map(|&x| 1 - x).collect() // complement + fn extract_solution( + &self, + target_sol: &[usize], + ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_sol)?; + Ok(target_sol.iter().map(|&x| 1 - x).collect()) } } ``` +### 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. + +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. + +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 `#[reduction]` attribute on the `ReduceTo` impl registers the reduction in the global registry (via `inventory`): ```rust,ignore diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 5a48681e7..9c504229a 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -9263,6 +9263,67 @@ fn test_extract_roundtrip_mis_to_qubo() { std::fs::remove_file(&bundle_file).ok(); } +#[test] +fn test_extract_rejects_structurally_invalid_one_hot_config() { + 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"); + + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "TSP", + "--graph", + "0-1,1-2,0-2", + "--edge-weights", + "1,1,1", + ]) + .output() + .unwrap(); + assert!( + create_out.status.success(), + "create stderr: {}", + String::from_utf8_lossy(&create_out.stderr) + ); + + let reduce_out = pred() + .args([ + "-o", + bundle_file.to_str().unwrap(), + "reduce", + problem_file.to_str().unwrap(), + "--to", + "QUBO", + ]) + .output() + .unwrap(); + assert!( + reduce_out.status.success(), + "reduce stderr: {}", + String::from_utf8_lossy(&reduce_out.stderr) + ); + + let extract_out = pred() + .args([ + "extract", + bundle_file.to_str().unwrap(), + "--config", + "0,0,0,0,0,0,0,0,0", + ]) + .output() + .unwrap(); + assert!(!extract_out.status.success()); + let stderr = String::from_utf8(extract_out.stderr).unwrap(); + assert!( + stderr.contains("assignment slot 0 has no selected item"), + "unexpected stderr: {stderr}" + ); + + std::fs::remove_file(&problem_file).ok(); + std::fs::remove_file(&bundle_file).ok(); +} + #[test] fn test_extract_rejects_plain_problem_file() { let problem_file = std::env::temp_dir().join("pred_test_extract_plain.json"); diff --git a/src/models/decision.rs b/src/models/decision.rs index 9f8fddc9e..2d559f4e5 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -283,6 +283,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/acyclicpartition_ilp.rs b/src/rules/acyclicpartition_ilp.rs index 7ce8944ba..39e1d4c6d 100644 --- a/src/rules/acyclicpartition_ilp.rs +++ b/src/rules/acyclicpartition_ilp.rs @@ -29,16 +29,9 @@ impl ReductionResult for ReductionAcyclicPartitionToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n = self.n; - (0..n) - .map(|v| { - (0..n) - .find(|&c| target_solution[v * n + c] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, self.n, self.n, 0) } } diff --git a/src/rules/balancedcompletebipartitesubgraph_ilp.rs b/src/rules/balancedcompletebipartitesubgraph_ilp.rs index 754a46b45..39cd6d0a1 100644 --- a/src/rules/balancedcompletebipartitesubgraph_ilp.rs +++ b/src/rules/balancedcompletebipartitesubgraph_ilp.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionBCBSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/bicliquecover_bmf.rs b/src/rules/bicliquecover_bmf.rs index 887b084fe..f27722628 100644 --- a/src/rules/bicliquecover_bmf.rs +++ b/src/rules/bicliquecover_bmf.rs @@ -40,6 +40,8 @@ impl ReductionResult for ReductionBicliqueCoverToBMF { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(config_bmf_to_bc(target_solution, self.m, self.n, self.k)) } } diff --git a/src/rules/biconnectivityaugmentation_ilp.rs b/src/rules/biconnectivityaugmentation_ilp.rs index c46aa4e98..17b2eff47 100644 --- a/src/rules/biconnectivityaugmentation_ilp.rs +++ b/src/rules/biconnectivityaugmentation_ilp.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionBiconnAugToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_candidates].to_vec()) } } diff --git a/src/rules/binpacking_ilp.rs b/src/rules/binpacking_ilp.rs index 4ce03e6a6..e95288c26 100644 --- a/src/rules/binpacking_ilp.rs +++ b/src/rules/binpacking_ilp.rs @@ -9,6 +9,7 @@ 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}; /// Result of reducing BinPacking to ILP. @@ -40,19 +41,9 @@ impl ReductionResult for ReductionBPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n = self.n; - let mut assignment = vec![0usize; n]; - for i in 0..n { - for j in 0..n { - if target_solution[i * n + j] == 1 { - assignment[i] = j; - break; - } - } - } - assignment - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.n, self.n, 0) } } diff --git a/src/rules/bmf_bicliquecover.rs b/src/rules/bmf_bicliquecover.rs index cafa92380..44af229b8 100644 --- a/src/rules/bmf_bicliquecover.rs +++ b/src/rules/bmf_bicliquecover.rs @@ -79,6 +79,8 @@ impl ReductionResult for ReductionBMFToBicliqueCover { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(config_bc_to_bmf(target_solution, self.m, self.n, self.k)) } } diff --git a/src/rules/bmf_ilp.rs b/src/rules/bmf_ilp.rs index 452772dae..3a220cee5 100644 --- a/src/rules/bmf_ilp.rs +++ b/src/rules/bmf_ilp.rs @@ -29,6 +29,8 @@ impl ReductionResult for ReductionBMFToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // Extract B (m x k) then C (k x n) — first m*k + k*n variables let total = self.m * self.k + self.k * self.n; diff --git a/src/rules/bottlenecktravelingsalesman_ilp.rs b/src/rules/bottlenecktravelingsalesman_ilp.rs index a099b26f7..6d3f20452 100644 --- a/src/rules/bottlenecktravelingsalesman_ilp.rs +++ b/src/rules/bottlenecktravelingsalesman_ilp.rs @@ -10,6 +10,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::BottleneckTravelingSalesman; use crate::reduction; use crate::rules::ilp_helpers::mccormick_product; +use crate::rules::ilp_helpers::one_hot_decode; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::Graph; @@ -39,31 +40,28 @@ impl ReductionResult for ReductionBTSPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_vertices; - // Decode tour: for each position p, find vertex v with x_{v,p} = 1 - let mut tour = vec![0usize; n]; - for p in 0..n { - for v in 0..n { - if target_solution[v * n + p] == 1 { - tour[p] = v; - break; - } - } - } + let tour = one_hot_decode(target_solution, n, n, 0)?; // Map tour to edge selection let mut edge_selection = vec![0usize; self.source_edges.len()]; for p in 0..n { let u = tour[p]; let v = tour[(p + 1) % n]; - for (idx, &(a, b)) in self.source_edges.iter().enumerate() { - if (a == u && b == v) || (a == v && b == u) { - edge_selection[idx] = 1; - break; - } - } + let edge = 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] = 1; } edge_selection diff --git a/src/rules/boundedcomponentspanningforest_ilp.rs b/src/rules/boundedcomponentspanningforest_ilp.rs index 3722a430c..f5b7f7539 100644 --- a/src/rules/boundedcomponentspanningforest_ilp.rs +++ b/src/rules/boundedcomponentspanningforest_ilp.rs @@ -7,6 +7,7 @@ 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::topology::{Graph, SimpleGraph}; @@ -30,17 +31,9 @@ impl ReductionResult for ReductionBCSFToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n = self.n; - let k = self.k; - (0..n) - .map(|v| { - (0..k) - .find(|&c| target_solution[v * k + c] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.n, self.k, 0) } } diff --git a/src/rules/capacityassignment_ilp.rs b/src/rules/capacityassignment_ilp.rs index bec8a0981..d7e0f716f 100644 --- a/src/rules/capacityassignment_ilp.rs +++ b/src/rules/capacityassignment_ilp.rs @@ -38,16 +38,14 @@ impl ReductionResult for ReductionCAToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let num_capacities = self.num_capacities; - (0..self.num_links) - .map(|l| { - (0..num_capacities) - .find(|&c| target_solution[l * num_capacities + c] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 76f410ad9..c28ddebef 100644 --- a/src/rules/circuit_ilp.rs +++ b/src/rules/circuit_ilp.rs @@ -40,6 +40,8 @@ impl ReductionResult for ReductionCircuitToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.source_variables .iter() diff --git a/src/rules/circuit_sat.rs b/src/rules/circuit_sat.rs index 316d3cb67..384ba8770 100644 --- a/src/rules/circuit_sat.rs +++ b/src/rules/circuit_sat.rs @@ -297,13 +297,9 @@ impl ReductionResult for ReductionCircuitSATToSAT { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - target_solution - .iter() - .take(self.source_var_count) - .copied() - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_var_count].to_vec()) } } diff --git a/src/rules/circuit_spinglass.rs b/src/rules/circuit_spinglass.rs index 8ffcb6265..ea27658e9 100644 --- a/src/rules/circuit_spinglass.rs +++ b/src/rules/circuit_spinglass.rs @@ -200,17 +200,13 @@ impl ReductionResult for ReductionCircuitToSG { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - self.source_variables - .iter() - .map(|var| { - self.variable_map - .get(var) - .and_then(|&idx| target_solution.get(idx).copied()) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(self + .source_variables + .iter() + .map(|variable| target_solution[self.variable_map[variable]]) + .collect()) } } diff --git a/src/rules/closeststring_ilp.rs b/src/rules/closeststring_ilp.rs index 79abbc4b0..16b6b4cbd 100644 --- a/src/rules/closeststring_ilp.rs +++ b/src/rules/closeststring_ilp.rs @@ -55,13 +55,7 @@ impl ReductionResult for ReductionClosestStringToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - if target_solution.len() != self.target.num_vars { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} ILP values, got {}", - self.target.num_vars, - target_solution.len() - ))); - } + 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); diff --git a/src/rules/closestsubstring_ilp.rs b/src/rules/closestsubstring_ilp.rs index 77dff6561..c3ac611cc 100644 --- a/src/rules/closestsubstring_ilp.rs +++ b/src/rules/closestsubstring_ilp.rs @@ -75,13 +75,7 @@ impl ReductionResult for ReductionClosestSubstringToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - if target_solution.len() != self.target.num_vars { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} ILP values, got {}", - self.target.num_vars, - target_solution.len() - ))); - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; let q = self.alphabet_size; let ell = self.substring_length; diff --git a/src/rules/closestvectorproblem_qubo.rs b/src/rules/closestvectorproblem_qubo.rs index b2046d02e..d53e03dde 100644 --- a/src/rules/closestvectorproblem_qubo.rs +++ b/src/rules/closestvectorproblem_qubo.rs @@ -35,6 +35,8 @@ impl ReductionResult for ReductionCVPToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.encodings .iter() @@ -43,13 +45,7 @@ impl ReductionResult for ReductionCVPToQUBO { .weights .iter() .enumerate() - .map(|(offset, weight)| { - target_solution - .get(encoding.start + offset) - .copied() - .unwrap_or(0) - * weight - }) + .map(|(offset, weight)| target_solution[encoding.start + offset] * weight) .sum() }) .collect() diff --git a/src/rules/clustering_ilp.rs b/src/rules/clustering_ilp.rs index 00e80e4b4..8fee8f663 100644 --- a/src/rules/clustering_ilp.rs +++ b/src/rules/clustering_ilp.rs @@ -18,12 +18,6 @@ pub struct ReductionClusteringToILP { num_clusters: usize, } -impl ReductionClusteringToILP { - fn var_index(&self, element: usize, cluster: usize) -> usize { - element * self.num_clusters + cluster - } -} - impl ReductionResult for ReductionClusteringToILP { type Source = Clustering; type Target = ILP; @@ -36,18 +30,14 @@ impl ReductionResult for ReductionClusteringToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - (0..self.num_elements) - .map(|element| { - (0..self.num_clusters) - .find(|&cluster| { - let idx = self.var_index(element, cluster); - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 dd9d4b266..a11a2244d 100644 --- a/src/rules/coloring_ilp.rs +++ b/src/rules/coloring_ilp.rs @@ -10,6 +10,7 @@ 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::topology::{Graph, SimpleGraph}; use crate::variant::{KValue, K1, K2, K3, K4, KN}; @@ -28,13 +29,6 @@ pub struct ReductionKColoringToILP { _phantom: std::marker::PhantomData<(K, G)>, } -impl ReductionKColoringToILP { - /// Get the variable index for vertex v with color c. - fn var_index(&self, vertex: usize, color: usize) -> usize { - vertex * self.num_colors + color - } -} - impl ReductionResult for ReductionKColoringToILP where G: Graph + crate::variant::VariantParam, @@ -54,19 +48,9 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let k = self.num_colors; - (0..self.num_vertices) - .map(|v| { - (0..k) - .find(|&c| { - let var_idx = self.var_index(v, c); - var_idx < target_solution.len() && target_solution[var_idx] == 1 - }) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 fede8ccb1..f23fa4b58 100644 --- a/src/rules/coloring_qubo.rs +++ b/src/rules/coloring_qubo.rs @@ -11,6 +11,7 @@ use crate::models::algebraic::QUBO; 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::topology::{Graph, SimpleGraph}; use crate::variant::{KValue, K2, K3, KN}; @@ -37,16 +38,9 @@ impl ReductionResult for ReductionKColoringToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let k = self.num_colors; - (0..self.num_vertices) - .map(|v| { - (0..k) - .find(|&c| target_solution[v * k + c] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.num_vertices, self.num_colors, 0) } } diff --git a/src/rules/consecutiveblockminimization_ilp.rs b/src/rules/consecutiveblockminimization_ilp.rs index 519616040..84c72279f 100644 --- a/src/rules/consecutiveblockminimization_ilp.rs +++ b/src/rules/consecutiveblockminimization_ilp.rs @@ -28,10 +28,9 @@ impl ReductionResult for ReductionCBMToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - // Decode the column permutation from x_{c,p} - one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 aadf9abd0..f2b996dba 100644 --- a/src/rules/consecutiveonesmatrixaugmentation_ilp.rs +++ b/src/rules/consecutiveonesmatrixaugmentation_ilp.rs @@ -29,12 +29,9 @@ impl ReductionResult for ReductionCOMAToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok(one_hot_decode( - target_solution, - self.num_cols, - self.num_cols, - 0, - )) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode(target_solution, self.num_cols, self.num_cols, 0) } } diff --git a/src/rules/consecutiveonessubmatrix_ilp.rs b/src/rules/consecutiveonessubmatrix_ilp.rs index 03bb93dcf..a15914f95 100644 --- a/src/rules/consecutiveonessubmatrix_ilp.rs +++ b/src/rules/consecutiveonessubmatrix_ilp.rs @@ -26,6 +26,8 @@ impl ReductionResult for ReductionCOSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // Output the selection bits s_c (first num_cols variables) target_solution[..self.num_cols].to_vec() diff --git a/src/rules/consistencyofdatabasefrequencytables_ilp.rs b/src/rules/consistencyofdatabasefrequencytables_ilp.rs index 712e0509a..d6fdac1fa 100644 --- a/src/rules/consistencyofdatabasefrequencytables_ilp.rs +++ b/src/rules/consistencyofdatabasefrequencytables_ilp.rs @@ -94,20 +94,30 @@ impl ReductionResult for ReductionCDFTToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 value = (0..domain_size) - .find(|&candidate| { - target_solution - .get(self.assignment_var_index(object, attribute, candidate)) - .copied() - .unwrap_or(0) - == 1 - }) - .unwrap_or(0); + 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" + ))) + } + }; source_solution.push(value); } } diff --git a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs index 104180d06..ba8a9fb66 100644 --- a/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minimumsummulticenter.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinimumSumMultic &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs index 38bfdb5ff..a475a5667 100644 --- a/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs +++ b/src/rules/decisionminimumdominatingset_minmaxmulticenter.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionDecisionMinimumDominatingSetToMinMaxMulticente &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs index 99a082038..88148866a 100644 --- a/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs +++ b/src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs @@ -182,7 +182,7 @@ impl TheoremConstruction { witness } - fn extract_solution( + fn decode_solution( &self, target_problem: &HamiltonianCircuit, target_solution: &[usize], @@ -267,6 +267,8 @@ impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ match &self.construction { ConstructionKind::FixedYes { source_cover } => { @@ -284,7 +286,7 @@ impl ReductionResult for ReductionDecisionMinimumVertexCoverToHamiltonianCircuit )) } ConstructionKind::Theorem(construction) => { - construction.extract_solution(&self.target, target_solution)? + construction.decode_solution(&self.target, target_solution)? } } }) diff --git a/src/rules/directedhamiltonianpath_ilp.rs b/src/rules/directedhamiltonianpath_ilp.rs index 52edd18a6..e64b036e9 100644 --- a/src/rules/directedhamiltonianpath_ilp.rs +++ b/src/rules/directedhamiltonianpath_ilp.rs @@ -36,10 +36,12 @@ impl ReductionResult for ReductionDirectedHamiltonianPathToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_vertices; // Decode one-hot assignment: permutation[k] = v where x_{v,k} = 1 - let perm = one_hot_decode(target_solution, n, n, 0); + let perm = one_hot_decode(target_solution, n, n, 0)?; permutation_to_lehmer(&perm) }) } diff --git a/src/rules/directedtwocommodityintegralflow_ilp.rs b/src/rules/directedtwocommodityintegralflow_ilp.rs index 013e3f684..890d239f7 100644 --- a/src/rules/directedtwocommodityintegralflow_ilp.rs +++ b/src/rules/directedtwocommodityintegralflow_ilp.rs @@ -41,6 +41,8 @@ impl ReductionResult for ReductionD2CIFToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..2 * self.num_arcs].to_vec()) } } diff --git a/src/rules/disjointconnectingpaths_ilp.rs b/src/rules/disjointconnectingpaths_ilp.rs index fb4fb415c..c941816ab 100644 --- a/src/rules/disjointconnectingpaths_ilp.rs +++ b/src/rules/disjointconnectingpaths_ilp.rs @@ -38,6 +38,8 @@ impl ReductionResult for ReductionDCPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // Mark an edge selected iff some orientation carries flow for some commodity. let m = self.edges.len(); diff --git a/src/rules/eulerianpath_ilp.rs b/src/rules/eulerianpath_ilp.rs index 468bb6bdc..70502b17f 100644 --- a/src/rules/eulerianpath_ilp.rs +++ b/src/rules/eulerianpath_ilp.rs @@ -74,6 +74,8 @@ impl ReductionResult for ReductionEulerianPathToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let m = self.num_arcs; if m == 0 { @@ -81,9 +83,7 @@ impl ReductionResult for ReductionEulerianPathToILP { } // Find the unique active start arc. - let mut current = match (0..m) - .find(|&a| target_solution.get(self.s_idx(a)).copied().unwrap_or(0) == 1) - { + 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( @@ -103,9 +103,7 @@ impl ReductionResult for ReductionEulerianPathToILP { .pairs .iter() .enumerate() - .find(|&(k, &(a, _))| { - a == current && target_solution.get(k).copied().unwrap_or(0) == 1 - }) + .find(|&(k, &(a, _))| a == current && target_solution[k] == 1) .map(|(_, &(_, b))| b); match next { diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs index a94de0a8b..a8aacac1e 100644 --- a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -22,6 +22,8 @@ impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs index 22aa8da46..882c3b958 100644 --- a/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs +++ b/src/rules/exactcoverby3sets_boundeddiameterspanningtree.rs @@ -62,19 +62,13 @@ impl ReductionResult for ReductionX3CToBoundedDiameterSpanningTree { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let m = self.source_num_subsets; let root_to_set_offset = 2; (0..m) - .map(|i| { - usize::from( - target_solution - .get(root_to_set_offset + i) - .copied() - .unwrap_or(0) - == 1, - ) - }) + .map(|i| usize::from(target_solution[root_to_set_offset + i] == 1)) .collect() }) } diff --git a/src/rules/exactcoverby3sets_ilp.rs b/src/rules/exactcoverby3sets_ilp.rs index 8a9f2e4c6..37455375f 100644 --- a/src/rules/exactcoverby3sets_ilp.rs +++ b/src/rules/exactcoverby3sets_ilp.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionX3CToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_maximumsetpacking.rs b/src/rules/exactcoverby3sets_maximumsetpacking.rs index 8155b236e..b5f5d6815 100644 --- a/src/rules/exactcoverby3sets_maximumsetpacking.rs +++ b/src/rules/exactcoverby3sets_maximumsetpacking.rs @@ -33,6 +33,8 @@ impl ReductionResult for ReductionXC3SToMaximumSetPacking { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_minimumaxiomset.rs b/src/rules/exactcoverby3sets_minimumaxiomset.rs index d1035a9a7..a6df6546c 100644 --- a/src/rules/exactcoverby3sets_minimumaxiomset.rs +++ b/src/rules/exactcoverby3sets_minimumaxiomset.rs @@ -33,10 +33,12 @@ impl ReductionResult for ReductionXC3SToMinimumAxiomSet { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let set_offset = self.source_universe_size; (0..self.source_num_subsets) - .map(|j| usize::from(target_solution.get(set_offset + j).copied().unwrap_or(0) > 0)) + .map(|j| usize::from(target_solution[set_offset + j] > 0)) .collect() }) } diff --git a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs index e16724e38..7e69ccf21 100644 --- a/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs +++ b/src/rules/exactcoverby3sets_minimumfaultdetectiontestset.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionXC3SToMinimumFaultDetectionTestSet { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/exactcoverby3sets_staffscheduling.rs b/src/rules/exactcoverby3sets_staffscheduling.rs index 70585f0bd..da5fbb997 100644 --- a/src/rules/exactcoverby3sets_staffscheduling.rs +++ b/src/rules/exactcoverby3sets_staffscheduling.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionXC3SToStaffScheduling { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ target_solution .iter() diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index 0662a9295..7df3637a4 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionX3CToSubsetProduct { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/expectedretrievalcost_ilp.rs b/src/rules/expectedretrievalcost_ilp.rs index 5e6d88acf..7fdb15d95 100644 --- a/src/rules/expectedretrievalcost_ilp.rs +++ b/src/rules/expectedretrievalcost_ilp.rs @@ -17,6 +17,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::misc::ExpectedRetrievalCost; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; /// Compute the latency distance between sectors on a circular device. @@ -69,19 +70,9 @@ impl ReductionResult for ReductionERCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let num_sectors = self.num_sectors; - (0..self.num_records) - .map(|r| { - (0..num_sectors) - .find(|&s| { - let idx = r * num_sectors + s; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.num_records, self.num_sectors, 0) } } diff --git a/src/rules/factoring_circuit.rs b/src/rules/factoring_circuit.rs index af5ad802e..4f7d4541e 100644 --- a/src/rules/factoring_circuit.rs +++ b/src/rules/factoring_circuit.rs @@ -46,6 +46,8 @@ impl ReductionResult for ReductionFactoringToCircuit { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let var_names = self.target.variable_names(); @@ -53,27 +55,20 @@ impl ReductionResult for ReductionFactoringToCircuit { let var_map: std::collections::HashMap<&str, usize> = var_names .iter() .enumerate() - .map(|(i, name)| (name.as_str(), target_solution.get(i).copied().unwrap_or(0))) - .collect(); - - // Extract p bits - let p_bits: Vec = self - .p_vars - .iter() - .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) + .map(|(i, name)| (name.as_str(), target_solution[i])) .collect(); - // Extract q bits - let q_bits: Vec = self - .q_vars + self.p_vars .iter() - .map(|name| *var_map.get(name.as_str()).unwrap_or(&0)) - .collect(); - - // Concatenate p and q bits - let mut result = p_bits; - result.extend(q_bits); - result + .chain(&self.q_vars) + .map(|name| { + var_map.get(name.as_str()).copied().ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target circuit does not contain factor variable {name}" + )) + }) + }) + .collect::>>()? }) } } diff --git a/src/rules/factoring_ilp.rs b/src/rules/factoring_ilp.rs index 51d3ea332..4f52fa2e3 100644 --- a/src/rules/factoring_ilp.rs +++ b/src/rules/factoring_ilp.rs @@ -79,15 +79,17 @@ impl ReductionResult for ReductionFactoringToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // Extract p bits (first factor) let p_bits: Vec = (0..self.m) - .map(|i| target_solution.get(self.p_var(i)).copied().unwrap_or(0)) + .map(|i| target_solution[self.p_var(i)]) .collect(); // Extract q bits (second factor) let q_bits: Vec = (0..self.n) - .map(|j| target_solution.get(self.q_var(j)).copied().unwrap_or(0)) + .map(|j| target_solution[self.q_var(j)]) .collect(); // Concatenate p and q bits diff --git a/src/rules/feasibleregisterassignment_ilp.rs b/src/rules/feasibleregisterassignment_ilp.rs index ad0028b63..b12ab41f8 100644 --- a/src/rules/feasibleregisterassignment_ilp.rs +++ b/src/rules/feasibleregisterassignment_ilp.rs @@ -33,6 +33,8 @@ impl ReductionResult for ReductionFeasibleRegisterAssignmentToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/flowshopscheduling_ilp.rs b/src/rules/flowshopscheduling_ilp.rs index 14c0de42f..712edbd62 100644 --- a/src/rules/flowshopscheduling_ilp.rs +++ b/src/rules/flowshopscheduling_ilp.rs @@ -57,6 +57,8 @@ impl ReductionResult for ReductionFSSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_jobs; let m = self.num_machines; @@ -64,7 +66,7 @@ impl ReductionResult for ReductionFSSToILP { let mut jobs: Vec = (0..n).collect(); jobs.sort_by_key(|&j| { let idx = c_offset + j * m + (m - 1); - (target_solution.get(idx).copied().unwrap_or(0), j) + (target_solution[idx], j) }); let perm = permutation_to_lehmer(&jobs); Self::encode_schedule_as_lehmer(&jobs) diff --git a/src/rules/graphpartitioning_ilp.rs b/src/rules/graphpartitioning_ilp.rs index 5f04aa3c7..ffb6a1edf 100644 --- a/src/rules/graphpartitioning_ilp.rs +++ b/src/rules/graphpartitioning_ilp.rs @@ -34,6 +34,8 @@ impl ReductionResult for ReductionGraphPartitioningToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/graphpartitioning_maxcut.rs b/src/rules/graphpartitioning_maxcut.rs index 5ab10a2bc..2e7985fd3 100644 --- a/src/rules/graphpartitioning_maxcut.rs +++ b/src/rules/graphpartitioning_maxcut.rs @@ -26,6 +26,8 @@ impl ReductionResult for ReductionGPToMaxCut { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/graphpartitioning_qubo.rs b/src/rules/graphpartitioning_qubo.rs index ca592d8c9..b9f86d3a9 100644 --- a/src/rules/graphpartitioning_qubo.rs +++ b/src/rules/graphpartitioning_qubo.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionGraphPartitioningToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs index 9b7b3bdc4..5b0dd9230 100644 --- a/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_biconnectivityaugmentation.rs @@ -48,6 +48,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_vertices; if n < 3 { @@ -59,7 +61,7 @@ impl ReductionResult for ReductionHamiltonianCircuitToBiconnectivityAugmentation // 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() { - if i < target_solution.len() && target_solution[i] == 1 { + if target_solution[i] == 1 { adj[u].push(v); adj[v].push(u); } diff --git a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs index 34c6f6e5a..19fd534b1 100644 --- a/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_bottlenecktravelingsalesman.rs @@ -27,6 +27,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToBottleneckTravelingSalesma &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 1a7ad073d..aa83c15d8 100644 --- a/src/rules/hamiltoniancircuit_hamiltonianpath.rs +++ b/src/rules/hamiltoniancircuit_hamiltonianpath.rs @@ -40,20 +40,14 @@ impl ReductionResult for ReductionHamiltonianCircuitToHamiltonianPath { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_original_vertices; if n == 0 { return Ok(vec![]); } - if target_solution.len() != n + 3 { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} path vertices, got {}", - n + 3, - target_solution.len() - ))); - } - 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' diff --git a/src/rules/hamiltoniancircuit_longestcircuit.rs b/src/rules/hamiltoniancircuit_longestcircuit.rs index 3fc0d3d4a..701292bd9 100644 --- a/src/rules/hamiltoniancircuit_longestcircuit.rs +++ b/src/rules/hamiltoniancircuit_longestcircuit.rs @@ -27,6 +27,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToLongestCircuit { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + crate::rules::graph_helpers::edges_to_cycle_order(self.target.graph(), target_solution) } } diff --git a/src/rules/hamiltoniancircuit_quadraticassignment.rs b/src/rules/hamiltoniancircuit_quadraticassignment.rs index d5c4a5571..d03c564ce 100644 --- a/src/rules/hamiltoniancircuit_quadraticassignment.rs +++ b/src/rules/hamiltoniancircuit_quadraticassignment.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToQuadraticAssignment { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // QAP config is a permutation γ mapping positions to vertices, // which is directly the Hamiltonian circuit visit order. diff --git a/src/rules/hamiltoniancircuit_ruralpostman.rs b/src/rules/hamiltoniancircuit_ruralpostman.rs index f9b879091..31f8669e7 100644 --- a/src/rules/hamiltoniancircuit_ruralpostman.rs +++ b/src/rules/hamiltoniancircuit_ruralpostman.rs @@ -50,6 +50,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // The target solution is edge multiplicities. // Required edges are indices 0..n (the {v_i^a, v_i^b} edges). @@ -69,8 +71,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToRuralPostman { let fwd_idx = n + 2 * k; // {v_i^b, v_j^a} let bwd_idx = n + 2 * k + 1; // {v_j^b, v_i^a} - let fwd_mult = target_solution.get(fwd_idx).copied().unwrap_or(0); - let bwd_mult = target_solution.get(bwd_idx).copied().unwrap_or(0); + let fwd_mult = target_solution[fwd_idx]; + let bwd_mult = target_solution[bwd_idx]; // In an optimal HC solution, each connectivity edge is used 0 or 1 times. // Each vertex should have exactly one outgoing connectivity edge. diff --git a/src/rules/hamiltoniancircuit_stackercrane.rs b/src/rules/hamiltoniancircuit_stackercrane.rs index 86f5900d6..8408e8d78 100644 --- a/src/rules/hamiltoniancircuit_stackercrane.rs +++ b/src/rules/hamiltoniancircuit_stackercrane.rs @@ -36,6 +36,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToStackerCrane { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // The target config is a permutation of arc indices. // Arc i corresponds to original vertex i (arc from 2i to 2i+1). diff --git a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs index e56791739..e4b87c770 100644 --- a/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs +++ b/src/rules/hamiltoniancircuit_strongconnectivityaugmentation.rs @@ -31,6 +31,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToStrongConnectivityAugmenta &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.n; if n == 0 { diff --git a/src/rules/hamiltoniancircuit_travelingsalesman.rs b/src/rules/hamiltoniancircuit_travelingsalesman.rs index 19ba0211f..d58b5518d 100644 --- a/src/rules/hamiltoniancircuit_travelingsalesman.rs +++ b/src/rules/hamiltoniancircuit_travelingsalesman.rs @@ -27,6 +27,8 @@ impl ReductionResult for ReductionHamiltonianCircuitToTravelingSalesman { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + 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 5cc4085ac..0ea5af57b 100644 --- a/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs +++ b/src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + extract_hamiltonian_order(self.target.graph(), target_solution) } } @@ -57,14 +59,6 @@ fn extract_hamiltonian_order( } 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(); num_vertices]; for ((u, v), &selected) in edges.iter().copied().zip(target_solution.iter()) { if selected != 1 { diff --git a/src/rules/hamiltonianpath_ilp.rs b/src/rules/hamiltonianpath_ilp.rs index c15336d73..f646ed94b 100644 --- a/src/rules/hamiltonianpath_ilp.rs +++ b/src/rules/hamiltonianpath_ilp.rs @@ -39,12 +39,9 @@ impl ReductionResult for ReductionHamiltonianPathToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok(one_hot_decode( - target_solution, - self.num_vertices, - self.num_vertices, - 0, - )) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 5e4687483..939ba38d3 100644 --- a/src/rules/hamiltonianpath_isomorphicspanningtree.rs +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -32,6 +32,8 @@ impl ReductionResult for ReductionHPToIST { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs index 51d67471f..cd96b1d0c 100644 --- a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionHPBTVToLP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_vertices; diff --git a/src/rules/highlyconnecteddeletion_ilp.rs b/src/rules/highlyconnecteddeletion_ilp.rs index eaff32c3e..227049a8f 100644 --- a/src/rules/highlyconnecteddeletion_ilp.rs +++ b/src/rules/highlyconnecteddeletion_ilp.rs @@ -64,13 +64,7 @@ impl ReductionResult for ReductionHighlyConnectedDeletionToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - if target_solution.len() != self.clusters.len() { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} cluster-selection values, got {}", - self.clusters.len(), - target_solution.len() - ))); - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; let mut cluster_of: Vec> = vec![None; vertex_count(&self.clusters)]; for (c, cluster) in self.clusters.iter().enumerate() { diff --git a/src/rules/ilp_bool_ilp_i32.rs b/src/rules/ilp_bool_ilp_i32.rs index 7df8576c3..172846b64 100644 --- a/src/rules/ilp_bool_ilp_i32.rs +++ b/src/rules/ilp_bool_ilp_i32.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionBinaryILPToIntILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/ilp_helpers.rs b/src/rules/ilp_helpers.rs index db5294571..93fe410ac 100644 --- a/src/rules/ilp_helpers.rs +++ b/src/rules/ilp_helpers.rs @@ -140,12 +140,56 @@ pub fn one_hot_decode( num_items: usize, num_slots: usize, var_offset: usize, -) -> Vec { - (0..num_slots) +) -> crate::rules::ExtractionResult> { + let assignment: Vec = (0..num_slots) .map(|p| { - (0..num_items) - .find(|&v| solution[var_offset + v * num_slots + p] == 1) - .unwrap_or(0) + let mut selected = + (0..num_items).filter(|&v| solution[var_offset + v * num_slots + p] == 1); + let item = selected.next().ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "assignment slot {p} has no selected item" + )) + })?; + if selected.next().is_some() { + return Err(crate::rules::ExtractionError::invalid(format!( + "assignment slot {p} has multiple selected items" + ))); + } + Ok(item) + }) + .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) +} + +/// Decode one selected column from each row of a row-major binary matrix. +pub fn one_hot_decode_rows( + solution: &[usize], + num_rows: usize, + num_columns: usize, + var_offset: usize, +) -> crate::rules::ExtractionResult> { + (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" + ))), + } }) .collect() } diff --git a/src/rules/ilp_i32_ilp_bool.rs b/src/rules/ilp_i32_ilp_bool.rs index 53d1cfbf9..c2313e637 100644 --- a/src/rules/ilp_i32_ilp_bool.rs +++ b/src/rules/ilp_i32_ilp_bool.rs @@ -251,6 +251,8 @@ impl ReductionResult for ReductionIntILPToBinaryILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.encodings .iter() diff --git a/src/rules/ilp_qubo.rs b/src/rules/ilp_qubo.rs index 9e099a241..7549af386 100644 --- a/src/rules/ilp_qubo.rs +++ b/src/rules/ilp_qubo.rs @@ -33,6 +33,8 @@ impl ReductionResult for ReductionILPToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_original_vars].to_vec()) } } diff --git a/src/rules/integerknapsack_ilp.rs b/src/rules/integerknapsack_ilp.rs index 6b8afb4a1..c0a4719bb 100644 --- a/src/rules/integerknapsack_ilp.rs +++ b/src/rules/integerknapsack_ilp.rs @@ -26,6 +26,8 @@ impl ReductionResult for ReductionIntegerKnapsackToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/integralflowbundles_ilp.rs b/src/rules/integralflowbundles_ilp.rs index 70d1823b5..904146977 100644 --- a/src/rules/integralflowbundles_ilp.rs +++ b/src/rules/integralflowbundles_ilp.rs @@ -27,6 +27,8 @@ impl ReductionResult for ReductionIFBToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/integralflowhomologousarcs_ilp.rs b/src/rules/integralflowhomologousarcs_ilp.rs index 8d810fb1a..9c36712d7 100644 --- a/src/rules/integralflowhomologousarcs_ilp.rs +++ b/src/rules/integralflowhomologousarcs_ilp.rs @@ -26,6 +26,8 @@ impl ReductionResult for ReductionIFHAToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/integralflowwithmultipliers_ilp.rs b/src/rules/integralflowwithmultipliers_ilp.rs index f52533bb4..56ed71ddf 100644 --- a/src/rules/integralflowwithmultipliers_ilp.rs +++ b/src/rules/integralflowwithmultipliers_ilp.rs @@ -26,6 +26,8 @@ impl ReductionResult for ReductionIFWMToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index c28f3cfd9..306977592 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -28,16 +28,9 @@ impl ReductionResult for ReductionISTToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n = self.n; - (0..n) - .map(|u| { - (0..n) - .find(|&v| target_solution[u * n + v] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 6817bf98e..d38e05cfc 100644 --- a/src/rules/kclique_balancedcompletebipartitesubgraph.rs +++ b/src/rules/kclique_balancedcompletebipartitesubgraph.rs @@ -38,6 +38,8 @@ impl ReductionResult for ReductionKCliqueToBCBS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.num_original_vertices) .map(|v| 1 - target_solution[v]) diff --git a/src/rules/kclique_conjunctivebooleanquery.rs b/src/rules/kclique_conjunctivebooleanquery.rs index 273ca0d00..4e1d5c153 100644 --- a/src/rules/kclique_conjunctivebooleanquery.rs +++ b/src/rules/kclique_conjunctivebooleanquery.rs @@ -38,6 +38,8 @@ impl ReductionResult for ReductionKCliqueToCBQ { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 4e15084bf..35f985500 100644 --- a/src/rules/kclique_ilp.rs +++ b/src/rules/kclique_ilp.rs @@ -43,6 +43,8 @@ impl ReductionResult for ReductionKCliqueToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/kclique_subgraphisomorphism.rs b/src/rules/kclique_subgraphisomorphism.rs index 3e8c518f2..e4c4c8147 100644 --- a/src/rules/kclique_subgraphisomorphism.rs +++ b/src/rules/kclique_subgraphisomorphism.rs @@ -38,6 +38,8 @@ impl ReductionResult for ReductionKCliqueToSubIso { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 cdaeb6507..b65f9e751 100644 --- a/src/rules/kcoloring_bicliquecover.rs +++ b/src/rules/kcoloring_bicliquecover.rs @@ -68,13 +68,12 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { /// cover yields at most `q` such distinct bicliques, so the result is a /// proper `q`-coloring of the source. /// - /// If the witness is invalid (e.g. some diagonal edge is uncovered), - /// the extracted entry for `v` falls back to color `0`. Validation - /// downstream is the responsibility of `source.is_valid_solution`. fn extract_solution( &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_vertices; let k = self.target.k(); @@ -82,40 +81,36 @@ impl ReductionResult for ReductionKColoringToBicliqueCover { // For each source vertex v, find the first biclique r that contains // both a_v (unified index v) and b_v (unified index left_size + v). - let mut diagonal_biclique = vec![None; n]; - for (v, slot) in diagonal_biclique.iter_mut().enumerate() { + let mut diagonal_biclique = Vec::with_capacity(n); + for v in 0..n { let a_v = v; let b_v = left_size + v; - for r in 0..k { - let a_idx = a_v * k + r; - let b_idx = b_v * k + r; - if target_solution.get(a_idx).copied().unwrap_or(0) == 1 - && target_solution.get(b_idx).copied().unwrap_or(0) == 1 - { - *slot = Some(r); - break; - } - } + let biclique = (0..k) + .find(|&r| { + target_solution[a_v * k + r] == 1 && target_solution[b_v * k + r] == 1 + }) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid(format!( + "target cover leaves diagonal gadget edge {v} uncovered" + )) + })?; + diagonal_biclique.push(biclique); } // Compact distinct biclique indices into colors 0..q-1 in first-seen order. let mut color_of_biclique: std::collections::HashMap = std::collections::HashMap::new(); - let mut coloring = vec![0usize; n]; - for (v, slot) in diagonal_biclique.iter().enumerate() { - if let Some(r) = *slot { - let next_color = color_of_biclique.len(); - let color = *color_of_biclique.entry(r).or_insert(next_color); - // Clamp into [0, q-1]: if the witness exceeds q distinct - // diagonal bicliques (which a valid cover never does) keep - // the entry in range so the downstream validator can - // simply reject it as an improper coloring. - coloring[v] = if self.num_colors == 0 { - 0 - } else { - color.min(self.num_colors - 1) - }; + let mut coloring = Vec::with_capacity(n); + 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 }) diff --git a/src/rules/kcoloring_clustering.rs b/src/rules/kcoloring_clustering.rs index 79b77e7b2..23af4cb85 100644 --- a/src/rules/kcoloring_clustering.rs +++ b/src/rules/kcoloring_clustering.rs @@ -32,7 +32,9 @@ impl ReductionResult for ReductionKColoringToClustering { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok(target_solution[..self.source_num_vertices.min(target_solution.len())].to_vec()) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.source_num_vertices].to_vec()) } } diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs index 3fc634caa..0858828bf 100644 --- a/src/rules/kcoloring_partitionintocliques.rs +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -29,6 +29,8 @@ impl ReductionResult for ReductionKColoringToPartitionIntoCliques { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/kcoloring_twodimensionalconsecutivesets.rs b/src/rules/kcoloring_twodimensionalconsecutivesets.rs index 2a7208af6..88fd78f20 100644 --- a/src/rules/kcoloring_twodimensionalconsecutivesets.rs +++ b/src/rules/kcoloring_twodimensionalconsecutivesets.rs @@ -43,6 +43,8 @@ impl ReductionResult for ReductionKColoringToTDCS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 ffa4c2473..11b6d3f16 100644 --- a/src/rules/knapsack_ilp.rs +++ b/src/rules/knapsack_ilp.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionKnapsackToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/knapsack_qubo.rs b/src/rules/knapsack_qubo.rs index fa4c4d973..d84b6bf68 100644 --- a/src/rules/knapsack_qubo.rs +++ b/src/rules/knapsack_qubo.rs @@ -34,6 +34,8 @@ impl ReductionResult for ReductionKnapsackToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_items].to_vec()) } } diff --git a/src/rules/ksatisfiability_acyclicpartition.rs b/src/rules/ksatisfiability_acyclicpartition.rs index c93c296fe..8b074ca22 100644 --- a/src/rules/ksatisfiability_acyclicpartition.rs +++ b/src/rules/ksatisfiability_acyclicpartition.rs @@ -103,21 +103,16 @@ impl ReductionResult for ReductionPartitionToAcyclicPartition { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - if target_solution.len() != self.source_num_elements + 2 { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} partition labels, got {}", - self.source_num_elements + 2, - target_solution.len() - ))); - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let source_label = target_solution[self.source_vertex]; let sink_label = target_solution[self.sink_vertex]; - debug_assert_ne!( - source_label, sink_label, - "valid target witnesses must place source and sink in different blocks" - ); + if source_label == sink_label { + return Err(crate::rules::ExtractionError::invalid( + "target partition places the source and sink in the same block", + )); + } (0..self.source_num_elements) .map(|item| usize::from(target_solution[item] == sink_label)) @@ -146,6 +141,8 @@ impl ReductionResult for Reduction3SATToAcyclicPartition { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let partition_solution = self .partition_to_acyclic diff --git a/src/rules/ksatisfiability_bicliquecover.rs b/src/rules/ksatisfiability_bicliquecover.rs index 806234010..dd82220fe 100644 --- a/src/rules/ksatisfiability_bicliquecover.rs +++ b/src/rules/ksatisfiability_bicliquecover.rs @@ -102,22 +102,11 @@ impl ReductionResult for ReductionKSatisfiabilityToBicliqueCover { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let n = self.normalized_n; let left_size = self.target.left_size(); let k = self.target.k(); - let expected_len = (left_size + self.target.right_size()) * k; - if target_solution.len() != expected_len { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {expected_len} biclique-membership values, got {}", - target_solution.len() - ))); - } - if target_solution.iter().any(|&value| value > 1) { - return Err(crate::rules::ExtractionError::invalid( - "biclique-membership values must be binary", - )); - } - // Unified-vertex helpers for the named gadget anchors. let s11_u = self.s1_left_offset; // s_{1,1}^u let s11_v = left_size + self.s1_right_offset; // s_{1,1}^v diff --git a/src/rules/ksatisfiability_cyclicordering.rs b/src/rules/ksatisfiability_cyclicordering.rs index e67b1f7e6..86cb118a6 100644 --- a/src/rules/ksatisfiability_cyclicordering.rs +++ b/src/rules/ksatisfiability_cyclicordering.rs @@ -34,6 +34,8 @@ impl ReductionResult for Reduction3SATToCyclicOrdering { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.source_num_vars) .map(|var_idx| { diff --git a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs index fbd7c60b4..f5acd64e2 100644 --- a/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs +++ b/src/rules/ksatisfiability_directedtwocommodityintegralflow.rs @@ -175,18 +175,12 @@ impl ReductionResult for Reduction3SATToDirectedTwoCommodityIntegralFlow { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.variable_paths .iter() - .map(|paths| { - usize::from( - target_solution - .get(paths.lower_entry_arc) - .copied() - .unwrap_or(0) - > 0, - ) - }) + .map(|paths| usize::from(target_solution[paths.lower_entry_arc] > 0)) .collect() }) } diff --git a/src/rules/ksatisfiability_feasibleregisterassignment.rs b/src/rules/ksatisfiability_feasibleregisterassignment.rs index 07bcd6f31..ccfbb7e11 100644 --- a/src/rules/ksatisfiability_feasibleregisterassignment.rs +++ b/src/rules/ksatisfiability_feasibleregisterassignment.rs @@ -73,6 +73,8 @@ impl ReductionResult for Reduction3SATToFeasibleRegisterAssignment { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.num_vars) .map(|var| { diff --git a/src/rules/ksatisfiability_kclique.rs b/src/rules/ksatisfiability_kclique.rs index 994420f7f..1bd049466 100644 --- a/src/rules/ksatisfiability_kclique.rs +++ b/src/rules/ksatisfiability_kclique.rs @@ -40,6 +40,8 @@ impl ReductionResult for Reduction3SATToKClique { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.source_num_vars; // Start with all variables unset (false = 0). diff --git a/src/rules/ksatisfiability_kernel.rs b/src/rules/ksatisfiability_kernel.rs index 02b2568f5..2ba1b794d 100644 --- a/src/rules/ksatisfiability_kernel.rs +++ b/src/rules/ksatisfiability_kernel.rs @@ -29,9 +29,11 @@ impl ReductionResult for Reduction3SatToKernel { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.source_num_vars) - .map(|i| usize::from(target_solution.get(2 * i).copied().unwrap_or(0) == 1)) + .map(|i| usize::from(target_solution[2 * i] == 1)) .collect() }) } diff --git a/src/rules/ksatisfiability_minimumvertexcover.rs b/src/rules/ksatisfiability_minimumvertexcover.rs index c3d7faa62..43a33e0cd 100644 --- a/src/rules/ksatisfiability_minimumvertexcover.rs +++ b/src/rules/ksatisfiability_minimumvertexcover.rs @@ -44,6 +44,8 @@ impl ReductionResult for Reduction3SATToMVC { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.source_num_vars) .map(|i| { diff --git a/src/rules/ksatisfiability_monochromatictriangle.rs b/src/rules/ksatisfiability_monochromatictriangle.rs index 1c756d1ef..345d72b38 100644 --- a/src/rules/ksatisfiability_monochromatictriangle.rs +++ b/src/rules/ksatisfiability_monochromatictriangle.rs @@ -51,15 +51,12 @@ impl ReductionResult for Reduction3SATToMonochromaticTriangle { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let direct: Vec = self .negation_edge_indices .iter() - .map( - |&edge_idx| match target_solution.get(edge_idx).copied().unwrap_or(1) { - 0 => 1, - _ => 0, - }, - ) + .map(|&edge_idx| usize::from(target_solution[edge_idx] == 0)) .collect(); if self.source.evaluate(&direct).0 { return Ok(direct); diff --git a/src/rules/ksatisfiability_oneinthreesatisfiability.rs b/src/rules/ksatisfiability_oneinthreesatisfiability.rs index afd4180c5..b26034708 100644 --- a/src/rules/ksatisfiability_oneinthreesatisfiability.rs +++ b/src/rules/ksatisfiability_oneinthreesatisfiability.rs @@ -23,6 +23,8 @@ impl ReductionResult for Reduction3SATToOneInThreeSAT { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.source_num_vars].to_vec()) } } diff --git a/src/rules/ksatisfiability_preemptivescheduling.rs b/src/rules/ksatisfiability_preemptivescheduling.rs index b4df7c385..406b69bb9 100644 --- a/src/rules/ksatisfiability_preemptivescheduling.rs +++ b/src/rules/ksatisfiability_preemptivescheduling.rs @@ -339,6 +339,8 @@ impl ReductionResult for Reduction3SATToPreemptiveScheduling { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let d_max = self.target.d_max(); self.positive_start_jobs diff --git a/src/rules/ksatisfiability_quadraticcongruences.rs b/src/rules/ksatisfiability_quadraticcongruences.rs index d4c635559..cf39c2aa3 100644 --- a/src/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/rules/ksatisfiability_quadraticcongruences.rs @@ -35,6 +35,8 @@ impl ReductionResult for Reduction3SATToQuadraticCongruences { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let mut source_assignment = vec![0; self.source_num_vars]; let Some(x) = self.target.decode_witness(target_solution) else { @@ -62,10 +64,12 @@ impl ReductionResult for Reduction3SATToQuadraticCongruences { for (active_index, &source_index) in self.active_to_source.iter().enumerate() { let alpha_index = 2 * self.standard_clause_count + active_index + 1; - source_assignment[source_index] = if alpha.get(alpha_index) == Some(&-1) { - 1 - } else { - 0 + source_assignment[source_index] = match alpha[alpha_index] { + 1 => 0, + -1 => 1, + sign => return Err(crate::rules::ExtractionError::invalid(format!( + "target witness encodes invalid sign {sign} for source variable {source_index}" + ))), }; } diff --git a/src/rules/ksatisfiability_quadraticdiophantineequations.rs b/src/rules/ksatisfiability_quadraticdiophantineequations.rs index bff64c52e..4fa64bd24 100644 --- a/src/rules/ksatisfiability_quadraticdiophantineequations.rs +++ b/src/rules/ksatisfiability_quadraticdiophantineequations.rs @@ -32,6 +32,8 @@ impl ReductionResult for Reduction3SATToQuadraticDiophantineEquations { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let Some(x) = self.target.decode_witness(target_solution) else { return Err(crate::rules::ExtractionError::invalid( diff --git a/src/rules/ksatisfiability_qubo.rs b/src/rules/ksatisfiability_qubo.rs index 7233435a5..3c2ab369d 100644 --- a/src/rules/ksatisfiability_qubo.rs +++ b/src/rules/ksatisfiability_qubo.rs @@ -36,6 +36,8 @@ impl ReductionResult for ReductionKSatToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.source_num_vars].to_vec()) } } @@ -59,6 +61,8 @@ impl ReductionResult for Reduction3SATToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.source_num_vars].to_vec()) } } diff --git a/src/rules/ksatisfiability_registersufficiency.rs b/src/rules/ksatisfiability_registersufficiency.rs index d342553b1..ecbb61f94 100644 --- a/src/rules/ksatisfiability_registersufficiency.rs +++ b/src/rules/ksatisfiability_registersufficiency.rs @@ -203,6 +203,8 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ if self.layout.num_vars == 0 { return Ok(Vec::new()); @@ -213,13 +215,15 @@ impl ReductionResult for Reduction3SATToRegisterSufficiency { .map(|var| { let x_pos_before = target_solution[self.layout.x_pos(var)] < cutoff; let x_neg_before = target_solution[self.layout.x_neg(var)] < cutoff; - debug_assert!( - !(x_pos_before && x_neg_before), - "Sethi extraction expects at most one of x_pos/x_neg before w[n]", - ); - usize::from(x_pos_before) + if x_pos_before && x_neg_before { + Err(crate::rules::ExtractionError::invalid(format!( + "both literals of variable {var} precede the extraction cutoff" + ))) + } else { + Ok(usize::from(x_pos_before)) + } }) - .collect() + .collect::>>()? }) } } diff --git a/src/rules/ksatisfiability_simultaneousincongruences.rs b/src/rules/ksatisfiability_simultaneousincongruences.rs index 7e9d1a8bf..f75bdea8c 100644 --- a/src/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/rules/ksatisfiability_simultaneousincongruences.rs @@ -31,8 +31,10 @@ impl ReductionResult for Reduction3SATToSimultaneousIncongruences { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ - let x = target_solution.first().copied().unwrap_or(0) as u64; + let x = target_solution[0] as u64; self.variable_primes .iter() .map(|&prime| if x % prime == 1 { 1 } else { 0 }) diff --git a/src/rules/ksatisfiability_subsetsum.rs b/src/rules/ksatisfiability_subsetsum.rs index 1f4d575c5..6fb792b97 100644 --- a/src/rules/ksatisfiability_subsetsum.rs +++ b/src/rules/ksatisfiability_subsetsum.rs @@ -39,6 +39,8 @@ impl ReductionResult for Reduction3SATToSubsetSum { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 23517ff36..d7a005898 100644 --- a/src/rules/ksatisfiability_timetabledesign.rs +++ b/src/rules/ksatisfiability_timetabledesign.rs @@ -749,6 +749,8 @@ impl ReductionResult for Reduction3SATToTimetableDesign { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let num_tasks = self.target.num_tasks(); let num_periods = self.target.num_periods(); diff --git a/src/rules/lengthboundeddisjointpaths_ilp.rs b/src/rules/lengthboundeddisjointpaths_ilp.rs index 37cacb4e5..2507515d4 100644 --- a/src/rules/lengthboundeddisjointpaths_ilp.rs +++ b/src/rules/lengthboundeddisjointpaths_ilp.rs @@ -36,6 +36,8 @@ impl ReductionResult for ReductionLBDPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // For each path slot k, set the source vertex-indicator block to 1 // exactly on the vertices incident to the commodity-k path, including s and t. diff --git a/src/rules/longestcircuit_ilp.rs b/src/rules/longestcircuit_ilp.rs index 47f733d2b..7f3b6890c 100644 --- a/src/rules/longestcircuit_ilp.rs +++ b/src/rules/longestcircuit_ilp.rs @@ -39,6 +39,8 @@ impl ReductionResult for ReductionLongestCircuitToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/longestcommonsubsequence_ilp.rs b/src/rules/longestcommonsubsequence_ilp.rs index b840018a2..924f72227 100644 --- a/src/rules/longestcommonsubsequence_ilp.rs +++ b/src/rules/longestcommonsubsequence_ilp.rs @@ -35,19 +35,14 @@ impl ReductionResult for ReductionLCSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let num_symbols = self.alphabet_size + 1; - let mut witness = Vec::with_capacity(self.max_length); - for position in 0..self.max_length { - let selected = (0..num_symbols) - .find(|&symbol| { - target_solution.get(position * num_symbols + symbol) == Some(&1) - }) - .unwrap_or(self.alphabet_size); - witness.push(selected); - } - witness - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.max_length, + self.alphabet_size + 1, + 0, + ) } } diff --git a/src/rules/longestcommonsubsequence_maximumindependentset.rs b/src/rules/longestcommonsubsequence_maximumindependentset.rs index bcb89bcf7..3571aa771 100644 --- a/src/rules/longestcommonsubsequence_maximumindependentset.rs +++ b/src/rules/longestcommonsubsequence_maximumindependentset.rs @@ -52,6 +52,8 @@ impl ReductionResult for ReductionLCSToIS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 28b8e41de..5143f8baf 100644 --- a/src/rules/longestpath_ilp.rs +++ b/src/rules/longestpath_ilp.rs @@ -35,20 +35,14 @@ impl ReductionResult for ReductionLongestPathToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.num_edges) .map(|edge_idx| { usize::from( - target_solution - .get(Self::arc_var(edge_idx, 0)) - .copied() - .unwrap_or(0) - > 0 - || target_solution - .get(Self::arc_var(edge_idx, 1)) - .copied() - .unwrap_or(0) - > 0, + target_solution[Self::arc_var(edge_idx, 0)] > 0 + || target_solution[Self::arc_var(edge_idx, 1)] > 0, ) }) .collect() diff --git a/src/rules/maxcut_minimumcutintoboundedsets.rs b/src/rules/maxcut_minimumcutintoboundedsets.rs index 72dc2c678..3b95ec4b6 100644 --- a/src/rules/maxcut_minimumcutintoboundedsets.rs +++ b/src/rules/maxcut_minimumcutintoboundedsets.rs @@ -34,6 +34,8 @@ impl ReductionResult for ReductionMaxCutToMinCutBounded { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.original_n].to_vec()) } } diff --git a/src/rules/maxcut_minimummatrixcover.rs b/src/rules/maxcut_minimummatrixcover.rs index c577dbd97..080f1d924 100644 --- a/src/rules/maxcut_minimummatrixcover.rs +++ b/src/rules/maxcut_minimummatrixcover.rs @@ -52,6 +52,8 @@ impl ReductionResult for ReductionMaxCutToMMC { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximalis_ilp.rs b/src/rules/maximalis_ilp.rs index abb063b50..c77f8578f 100644 --- a/src/rules/maximalis_ilp.rs +++ b/src/rules/maximalis_ilp.rs @@ -26,6 +26,8 @@ impl ReductionResult for ReductionMxISToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximum2satisfiability_ilp.rs b/src/rules/maximum2satisfiability_ilp.rs index 8d2cdbb62..92965836a 100644 --- a/src/rules/maximum2satisfiability_ilp.rs +++ b/src/rules/maximum2satisfiability_ilp.rs @@ -31,6 +31,8 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vars].to_vec()) } } diff --git a/src/rules/maximum2satisfiability_maxcut.rs b/src/rules/maximum2satisfiability_maxcut.rs index f2e5ddfc1..06b12ee6d 100644 --- a/src/rules/maximum2satisfiability_maxcut.rs +++ b/src/rules/maximum2satisfiability_maxcut.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionMaximum2SatisfiabilityToMaxCut { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 145c2b506..1021cd4b4 100644 --- a/src/rules/maximumclique_ilp.rs +++ b/src/rules/maximumclique_ilp.rs @@ -39,6 +39,8 @@ impl ReductionResult for ReductionCliqueToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumclique_maximumindependentset.rs b/src/rules/maximumclique_maximumindependentset.rs index 6d03be0bb..2a5780cbd 100644 --- a/src/rules/maximumclique_maximumindependentset.rs +++ b/src/rules/maximumclique_maximumindependentset.rs @@ -32,6 +32,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumcokplex_ilp.rs b/src/rules/maximumcokplex_ilp.rs index 9cc1751c3..90b56cbc1 100644 --- a/src/rules/maximumcokplex_ilp.rs +++ b/src/rules/maximumcokplex_ilp.rs @@ -35,6 +35,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumcommonedgesubgraph_ilp.rs b/src/rules/maximumcommonedgesubgraph_ilp.rs index 2f1df2648..e64b7b139 100644 --- a/src/rules/maximumcommonedgesubgraph_ilp.rs +++ b/src/rules/maximumcommonedgesubgraph_ilp.rs @@ -47,17 +47,22 @@ impl ReductionResult for ReductionMCESToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n1 = self.num_vertices_1; - let n2 = self.num_vertices_2; - (0..n1) - .map(|u| { - (0..n2) - .find(|&p| target_solution[u * n2 + p] == 1) - .unwrap_or(n2) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + let n2 = self.num_vertices_2; + (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" + ))), + } + }) + .collect() } } diff --git a/src/rules/maximumcontactmapoverlap_ilp.rs b/src/rules/maximumcontactmapoverlap_ilp.rs index b666fe801..39c08a3a7 100644 --- a/src/rules/maximumcontactmapoverlap_ilp.rs +++ b/src/rules/maximumcontactmapoverlap_ilp.rs @@ -50,18 +50,22 @@ impl ReductionResult for ReductionCMOToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n1 = self.num_vertices_1; - let n2 = self.num_vertices_2; - (0..n1) - .map(|i| { - (0..n2) - .find(|&j| target_solution[i * n2 + j] == 1) - .map(|j| j + 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + let n2 = self.num_vertices_2; + (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" + ))), + } + }) + .collect() } } diff --git a/src/rules/maximumdomaticnumber_ilp.rs b/src/rules/maximumdomaticnumber_ilp.rs index 494f62716..1f2f9b0b4 100644 --- a/src/rules/maximumdomaticnumber_ilp.rs +++ b/src/rules/maximumdomaticnumber_ilp.rs @@ -40,6 +40,8 @@ impl ReductionResult for ReductionDomaticNumberToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.n; let mut config = vec![0; n]; diff --git a/src/rules/maximumedgeweightedkclique_ilp.rs b/src/rules/maximumedgeweightedkclique_ilp.rs index c7a0d42e9..32e814eed 100644 --- a/src/rules/maximumedgeweightedkclique_ilp.rs +++ b/src/rules/maximumedgeweightedkclique_ilp.rs @@ -62,6 +62,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/maximumindependentset_gridgraph.rs b/src/rules/maximumindependentset_gridgraph.rs index 36cf30bd2..9330392bc 100644 --- a/src/rules/maximumindependentset_gridgraph.rs +++ b/src/rules/maximumindependentset_gridgraph.rs @@ -29,6 +29,8 @@ impl ReductionResult for ReductionISSimpleOneToGridOne { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(self.mapping_result.map_config_back(target_solution)) } } diff --git a/src/rules/maximumindependentset_integralflowbundles.rs b/src/rules/maximumindependentset_integralflowbundles.rs index 8699ac72d..1f27a8f7b 100644 --- a/src/rules/maximumindependentset_integralflowbundles.rs +++ b/src/rules/maximumindependentset_integralflowbundles.rs @@ -47,15 +47,11 @@ impl ReductionResult for ReductionMISToIFB { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.num_source_vertices) - .map(|i| { - if target_solution.get(2 * i + 1).copied().unwrap_or(0) > 0 { - 1 - } else { - 0 - } - }) + .map(|i| if target_solution[2 * i + 1] > 0 { 1 } else { 0 }) .collect() }) } diff --git a/src/rules/maximumindependentset_maximumclique.rs b/src/rules/maximumindependentset_maximumclique.rs index 701d6ab2e..0bd89db62 100644 --- a/src/rules/maximumindependentset_maximumclique.rs +++ b/src/rules/maximumindependentset_maximumclique.rs @@ -32,6 +32,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumindependentset_maximumsetpacking.rs b/src/rules/maximumindependentset_maximumsetpacking.rs index 62b575a6b..4811bb8ac 100644 --- a/src/rules/maximumindependentset_maximumsetpacking.rs +++ b/src/rules/maximumindependentset_maximumsetpacking.rs @@ -33,6 +33,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } @@ -87,6 +89,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumindependentset_triangular.rs b/src/rules/maximumindependentset_triangular.rs index d83489aef..063416825 100644 --- a/src/rules/maximumindependentset_triangular.rs +++ b/src/rules/maximumindependentset_triangular.rs @@ -31,6 +31,8 @@ impl ReductionResult for ReductionISSimpleToTriangular { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.mapping_result .map_config_back_via_centers(target_solution) diff --git a/src/rules/maximumleafspanningtree_ilp.rs b/src/rules/maximumleafspanningtree_ilp.rs index e29c034ca..eb31cf93f 100644 --- a/src/rules/maximumleafspanningtree_ilp.rs +++ b/src/rules/maximumleafspanningtree_ilp.rs @@ -43,6 +43,8 @@ impl ReductionResult for ReductionMaximumLeafSpanningTreeToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // First m variables are edge selectors target_solution[..self.num_edges].to_vec() diff --git a/src/rules/maximumlikelihoodranking_ilp.rs b/src/rules/maximumlikelihoodranking_ilp.rs index fe0525792..2a5276546 100644 --- a/src/rules/maximumlikelihoodranking_ilp.rs +++ b/src/rules/maximumlikelihoodranking_ilp.rs @@ -43,6 +43,8 @@ impl ReductionResult for ReductionMaximumLikelihoodRankingToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.n; if n == 0 { diff --git a/src/rules/maximummatching_ilp.rs b/src/rules/maximummatching_ilp.rs index 840b817fe..a806a5716 100644 --- a/src/rules/maximummatching_ilp.rs +++ b/src/rules/maximummatching_ilp.rs @@ -39,6 +39,8 @@ impl ReductionResult for ReductionMatchingToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximummatching_maximumsetpacking.rs b/src/rules/maximummatching_maximumsetpacking.rs index da3161860..f2c58a57a 100644 --- a/src/rules/maximummatching_maximumsetpacking.rs +++ b/src/rules/maximummatching_maximumsetpacking.rs @@ -34,6 +34,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumsetpacking_ilp.rs b/src/rules/maximumsetpacking_ilp.rs index c464fc9a8..975cc4428 100644 --- a/src/rules/maximumsetpacking_ilp.rs +++ b/src/rules/maximumsetpacking_ilp.rs @@ -33,6 +33,8 @@ impl ReductionResult for ReductionSPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/maximumsetpacking_qubo.rs b/src/rules/maximumsetpacking_qubo.rs index 901d7f7f2..4e13970a4 100644 --- a/src/rules/maximumsetpacking_qubo.rs +++ b/src/rules/maximumsetpacking_qubo.rs @@ -29,6 +29,8 @@ impl ReductionResult for ReductionSPToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumcapacitatedspanningtree_ilp.rs b/src/rules/minimumcapacitatedspanningtree_ilp.rs index 55854846b..60f748f0a 100644 --- a/src/rules/minimumcapacitatedspanningtree_ilp.rs +++ b/src/rules/minimumcapacitatedspanningtree_ilp.rs @@ -46,6 +46,8 @@ impl ReductionResult for ReductionMinimumCapacitatedSpanningTreeToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // First m variables are edge selectors target_solution[..self.num_edges].to_vec() diff --git a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs index 7f90a1780..6a52e05c2 100644 --- a/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs +++ b/src/rules/minimumcostmaximumflow_minimumcostcirculation.rs @@ -47,6 +47,8 @@ impl ReductionResult for ReductionMCMFToMCC { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_original_arcs].to_vec()) } } diff --git a/src/rules/minimumcoveringbycliques_ilp.rs b/src/rules/minimumcoveringbycliques_ilp.rs index 7f9fcf584..96e54b719 100644 --- a/src/rules/minimumcoveringbycliques_ilp.rs +++ b/src/rules/minimumcoveringbycliques_ilp.rs @@ -43,21 +43,21 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - if self.num_edges == 0 { - return Ok(vec![]); - } - - (0..self.num_edges) - .map(|edge_idx| { - (0..self.num_edges) - .find(|&slot| { - target_solution[self.y_offset + edge_idx * self.num_edges + slot] == 1 - }) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + (0..self.num_edges) + .map(|edge| { + (0..self.num_edges) + .find(|&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" + )) + }) + }) + .collect() } } diff --git a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs index cd905db87..c6f6ba7a1 100644 --- a/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs +++ b/src/rules/minimumcoveringbycliques_minimumintersectiongraphbasis.rs @@ -84,6 +84,8 @@ impl ReductionResult for ReductionMinimumCoveringByCliquesToMinimumIntersectionG &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + 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( diff --git a/src/rules/minimumcutintoboundedsets_ilp.rs b/src/rules/minimumcutintoboundedsets_ilp.rs index 44c29cd66..654642c15 100644 --- a/src/rules/minimumcutintoboundedsets_ilp.rs +++ b/src/rules/minimumcutintoboundedsets_ilp.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionMinCutBSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs index e99f9817d..17e793f45 100644 --- a/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs +++ b/src/rules/minimumdiscreteplanarinversekinematics_qubo.rs @@ -43,18 +43,28 @@ impl ReductionResult for ReductionMinimumDiscretePlanarInverseKinematicsToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - self.block_offsets - .iter() - .zip(&self.block_sizes) - .map(|(&start, &size)| { - target_solution[start..start + size] - .iter() - .position(|&bit| bit == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + self.block_offsets + .iter() + .zip(&self.block_sizes) + .enumerate() + .map(|(link, (&start, &size))| { + let mut selected = target_solution[start..start + size] + .iter() + .enumerate() + .filter_map(|(orientation, &bit)| (bit == 1).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" + ))), + } + }) + .collect() } } diff --git a/src/rules/minimumdominatingset_ilp.rs b/src/rules/minimumdominatingset_ilp.rs index 4d46d094c..78a891024 100644 --- a/src/rules/minimumdominatingset_ilp.rs +++ b/src/rules/minimumdominatingset_ilp.rs @@ -40,6 +40,8 @@ impl ReductionResult for ReductionDSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumedgecostflow_ilp.rs b/src/rules/minimumedgecostflow_ilp.rs index 206a1ec33..e1fe7557d 100644 --- a/src/rules/minimumedgecostflow_ilp.rs +++ b/src/rules/minimumedgecostflow_ilp.rs @@ -47,6 +47,8 @@ impl ReductionResult for ReductionMECFToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/minimumexternalmacrodatacompression_ilp.rs b/src/rules/minimumexternalmacrodatacompression_ilp.rs index 042943139..745bc9458 100644 --- a/src/rules/minimumexternalmacrodatacompression_ilp.rs +++ b/src/rules/minimumexternalmacrodatacompression_ilp.rs @@ -125,6 +125,8 @@ impl ReductionResult for ReductionEMDCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.layout.n; let k = self.alphabet_size; @@ -133,13 +135,27 @@ 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 { - for c in 0..k { - if target_solution[self.layout.d_var(j, c)] == 1 { - d_slots[j] = c; - break; + 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" + ))); } } @@ -148,37 +164,35 @@ impl ReductionResult for ReductionEMDCToILP { let mut c_pos = 0; let mut pos = 0; while pos < n { - // Check if lit[pos] = 1 + let pointers: Vec<_> = (1..=(n - pos)) + .flat_map(|length| { + (0..=(n - length)).filter_map(move |start| { + (target_solution[self.layout.ptr_var(pos, length, start)] == 1) + .then_some((start, length)) + }) + }) + .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; } - // Check for an active pointer starting at pos - let mut found = false; - for l in 1..=(n - pos) { - for d_start in 0..=(n - l) { - let var_idx = self.layout.ptr_var(pos, l, d_start); - if target_solution[var_idx] == 1 { - // Encode pointer (d_start, l) as EMDC pointer index - let ptr_idx = encode_pointer(n, d_start, l); - c_slots[c_pos] = k + 1 + ptr_idx; - c_pos += 1; - pos += l; - found = true; - break; - } - } - if found { - break; - } - } - if !found { - // Should not happen with a valid ILP solution - pos += 1; - } + 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); + c_slots[c_pos] = k + 1 + ptr_idx; + c_pos += 1; + pos += length; } // Combine D-slots and C-slots diff --git a/src/rules/minimumfaultdetectiontestset_ilp.rs b/src/rules/minimumfaultdetectiontestset_ilp.rs index d412ff0e3..489a1e00a 100644 --- a/src/rules/minimumfaultdetectiontestset_ilp.rs +++ b/src/rules/minimumfaultdetectiontestset_ilp.rs @@ -29,6 +29,8 @@ impl ReductionResult for ReductionMFDTSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumfeedbackarcset_ilp.rs b/src/rules/minimumfeedbackarcset_ilp.rs index 58d65af24..bd36b5d4c 100644 --- a/src/rules/minimumfeedbackarcset_ilp.rs +++ b/src/rules/minimumfeedbackarcset_ilp.rs @@ -45,6 +45,8 @@ impl ReductionResult for ReductionFASToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_arcs].to_vec()) } } diff --git a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs index e5e493698..29b5cfa35 100644 --- a/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs +++ b/src/rules/minimumfeedbackarcset_maximumlikelihoodranking.rs @@ -52,6 +52,8 @@ impl ReductionResult for ReductionFASToMLR { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.source_arcs .iter() diff --git a/src/rules/minimumfeedbackvertexset_ilp.rs b/src/rules/minimumfeedbackvertexset_ilp.rs index 5ceaac91d..8393c97c2 100644 --- a/src/rules/minimumfeedbackvertexset_ilp.rs +++ b/src/rules/minimumfeedbackvertexset_ilp.rs @@ -42,6 +42,8 @@ impl ReductionResult for ReductionMFVSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs index 397d5dfe0..08d5be031 100644 --- a/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs +++ b/src/rules/minimumfeedbackvertexset_minimumcodegenerationunlimitedregisters.rs @@ -41,6 +41,8 @@ impl ReductionResult for ReductionFVSToCodeGen { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_source_vertices; let mut source_config = vec![0usize; n]; diff --git a/src/rules/minimumgraphbandwidth_ilp.rs b/src/rules/minimumgraphbandwidth_ilp.rs index 33e0c6aaa..cdfd3a5a9 100644 --- a/src/rules/minimumgraphbandwidth_ilp.rs +++ b/src/rules/minimumgraphbandwidth_ilp.rs @@ -38,16 +38,14 @@ impl ReductionResult for ReductionMGBToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n = self.num_vertices; - (0..n) - .map(|v| { - (0..n) - .find(|&p| target_solution[v * n + p] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 3940752c4..06d81bda6 100644 --- a/src/rules/minimumhittingset_ilp.rs +++ b/src/rules/minimumhittingset_ilp.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionHSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimuminternalmacrodatacompression_ilp.rs b/src/rules/minimuminternalmacrodatacompression_ilp.rs index 9d9d68d7a..21f837e64 100644 --- a/src/rules/minimuminternalmacrodatacompression_ilp.rs +++ b/src/rules/minimuminternalmacrodatacompression_ilp.rs @@ -99,6 +99,8 @@ impl ReductionResult for ReductionIMDCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.layout.n; let k = self.alphabet_size; diff --git a/src/rules/minimummatrixcover_ilp.rs b/src/rules/minimummatrixcover_ilp.rs index bd23fbee6..7375a2beb 100644 --- a/src/rules/minimummatrixcover_ilp.rs +++ b/src/rules/minimummatrixcover_ilp.rs @@ -31,6 +31,8 @@ impl ReductionResult for ReductionMinimumMatrixCoverToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // First n variables are the sign variables x_0,...,x_{n-1} target_solution[..self.n].to_vec() diff --git a/src/rules/minimummaximalmatching_ilp.rs b/src/rules/minimummaximalmatching_ilp.rs index f99124ed4..3fe992afd 100644 --- a/src/rules/minimummaximalmatching_ilp.rs +++ b/src/rules/minimummaximalmatching_ilp.rs @@ -42,6 +42,8 @@ impl ReductionResult for ReductionMMMToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs index eda43212a..1eb8c7953 100644 --- a/src/rules/minimummaximalmatching_maximumachromaticnumber.rs +++ b/src/rules/minimummaximalmatching_maximumachromaticnumber.rs @@ -46,6 +46,8 @@ impl ReductionResult for ReductionMMMToAchromatic { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.source_edges .iter() diff --git a/src/rules/minimummaximalmatching_minimummatrixdomination.rs b/src/rules/minimummaximalmatching_minimummatrixdomination.rs index 3909625cc..88bc36e76 100644 --- a/src/rules/minimummaximalmatching_minimummatrixdomination.rs +++ b/src/rules/minimummaximalmatching_minimummatrixdomination.rs @@ -97,6 +97,8 @@ impl ReductionResult for ReductionMMMToMatrixDomination { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let graph = self.source.graph(); let edges = graph.edges(); @@ -125,12 +127,16 @@ impl ReductionResult for ReductionMMMToMatrixDomination { .zip(target_ones.iter()) .filter_map(|(&sel, &cell)| { if sel == 1 { - cell_to_source_edge.get(&cell).copied() + 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(); + .collect::>()?; // Step 2: Yannakakis-Gavril EDS -> independent EDS (maximal matching). // Loop invariants: `d` is an EDS of the source graph; each iteration @@ -145,13 +151,23 @@ impl ReductionResult for ReductionMMMToMatrixDomination { // Try dropping e1_idx or e2_idx if the remainder is still an EDS. let mut without_e1 = d.clone(); - without_e1.swap_remove(d.iter().position(|&x| x == e1_idx).unwrap()); + 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(); - without_e2.swap_remove(d.iter().position(|&x| x == e2_idx).unwrap()); + 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; @@ -173,12 +189,12 @@ impl ReductionResult for ReductionMMMToMatrixDomination { // 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) { - replace_in(&mut d, e1_idx, new_idx); + d[e1_position] = new_idx; continue; } // Symmetric swap on e2. if let Some(new_idx) = find_swap_edge(w, e2_idx, &d, &edges) { - replace_in(&mut d, e2_idx, new_idx); + d[e2_position] = new_idx; continue; } @@ -186,10 +202,9 @@ impl ReductionResult for ReductionMMMToMatrixDomination { // 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. - unreachable!( - "Yannakakis-Gavril EDS->IEDS transformation could not progress; \ - target witness must be a feasible (dominating) MMD configuration" - ); + 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. @@ -277,16 +292,6 @@ fn find_swap_edge( None } -/// Replace `old_idx` with `new_idx` inside `d` in-place. Panics if `old_idx` -/// is not present. -fn replace_in(d: &mut [usize], old_idx: usize, new_idx: usize) { - let pos = d - .iter() - .position(|&x| x == old_idx) - .expect("old_idx must be present in d"); - d[pos] = new_idx; -} - #[reduction( overhead = { num_rows = "num_vertices", diff --git a/src/rules/minimummetricdimension_ilp.rs b/src/rules/minimummetricdimension_ilp.rs index 8f0982d03..e190e5458 100644 --- a/src/rules/minimummetricdimension_ilp.rs +++ b/src/rules/minimummetricdimension_ilp.rs @@ -42,6 +42,8 @@ impl ReductionResult for ReductionMDToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimummultiwaycut_ilp.rs b/src/rules/minimummultiwaycut_ilp.rs index bb130dd7f..bb6002b41 100644 --- a/src/rules/minimummultiwaycut_ilp.rs +++ b/src/rules/minimummultiwaycut_ilp.rs @@ -46,6 +46,8 @@ impl ReductionResult for ReductionMMCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let offset = self.k * self.n; (0..self.m).map(|e| target_solution[offset + e]).collect() diff --git a/src/rules/minimummultiwaycut_qubo.rs b/src/rules/minimummultiwaycut_qubo.rs index e29b0c45a..d384602fb 100644 --- a/src/rules/minimummultiwaycut_qubo.rs +++ b/src/rules/minimummultiwaycut_qubo.rs @@ -40,18 +40,15 @@ impl ReductionResult for ReductionMinimumMultiwayCutToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + 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(|u| { - (0..k) - .find(|&t| target_solution[u * k + t] == 1) - .unwrap_or(0) - }) - .collect(); + let assignments = + crate::rules::ilp_helpers::one_hot_decode_rows(target_solution, n, k, 0)?; // For each edge, output 1 (cut) if endpoints differ, 0 (keep) otherwise self.edges diff --git a/src/rules/minimumsetcovering_ilp.rs b/src/rules/minimumsetcovering_ilp.rs index 2b17f517e..1305e7910 100644 --- a/src/rules/minimumsetcovering_ilp.rs +++ b/src/rules/minimumsetcovering_ilp.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionSCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumsummulticenter_ilp.rs b/src/rules/minimumsummulticenter_ilp.rs index 9e78166df..5bfa28403 100644 --- a/src/rules/minimumsummulticenter_ilp.rs +++ b/src/rules/minimumsummulticenter_ilp.rs @@ -45,6 +45,8 @@ impl ReductionResult for ReductionMSMCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/minimumtardinesssequencing_ilp.rs b/src/rules/minimumtardinesssequencing_ilp.rs index 0c4335ede..5fdeccdbd 100644 --- a/src/rules/minimumtardinesssequencing_ilp.rs +++ b/src/rules/minimumtardinesssequencing_ilp.rs @@ -30,9 +30,11 @@ impl ReductionResult for ReductionMTSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); + let schedule = one_hot_decode(target_solution, n, n, 0)?; permutation_to_lehmer(&schedule) }) } @@ -57,9 +59,11 @@ impl ReductionResult for ReductionMTSWeightedToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); + let schedule = one_hot_decode(target_solution, n, n, 0)?; permutation_to_lehmer(&schedule) }) } diff --git a/src/rules/minimumvertexcover_comparativecontainment.rs b/src/rules/minimumvertexcover_comparativecontainment.rs index 898f1eae9..3b1e13333 100644 --- a/src/rules/minimumvertexcover_comparativecontainment.rs +++ b/src/rules/minimumvertexcover_comparativecontainment.rs @@ -49,14 +49,15 @@ impl ReductionResult for ReductionDecisionMVCToComparativeContainment { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ if let Some(witness) = &self.trivial_yes { return Ok(witness.clone()); } let mut cover = vec![0; self.num_source_vertices]; - for (vertex, &selected) in target_solution + for (vertex, &selected) in target_solution[..self.num_source_vertices] .iter() - .take(self.num_source_vertices) .enumerate() { cover[vertex] = selected; diff --git a/src/rules/minimumvertexcover_ensemblecomputation.rs b/src/rules/minimumvertexcover_ensemblecomputation.rs index 292a57245..c486169fb 100644 --- a/src/rules/minimumvertexcover_ensemblecomputation.rs +++ b/src/rules/minimumvertexcover_ensemblecomputation.rs @@ -49,6 +49,8 @@ impl ReductionResult for ReductionVCToEC { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ use crate::traits::Problem; use crate::types::Min; diff --git a/src/rules/minimumvertexcover_longestcommonsubsequence.rs b/src/rules/minimumvertexcover_longestcommonsubsequence.rs index 324fd5692..d326f12cd 100644 --- a/src/rules/minimumvertexcover_longestcommonsubsequence.rs +++ b/src/rules/minimumvertexcover_longestcommonsubsequence.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionVCToLCS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let mut cover = vec![1; self.num_vertices]; for &symbol in target_solution { diff --git a/src/rules/minimumvertexcover_maximumindependentset.rs b/src/rules/minimumvertexcover_maximumindependentset.rs index 3ed74e3be..791779d9b 100644 --- a/src/rules/minimumvertexcover_maximumindependentset.rs +++ b/src/rules/minimumvertexcover_maximumindependentset.rs @@ -31,6 +31,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.iter().map(|&x| 1 - x).collect()) } } @@ -75,6 +77,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.iter().map(|&x| 1 - x).collect()) } } diff --git a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs index f8a45f664..6dc9240a0 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackarcset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackarcset.rs @@ -35,6 +35,8 @@ impl ReductionResult for ReductionVCToFAS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_source_vertices].to_vec()) } } diff --git a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs index e8af6b26f..b39ef35d6 100644 --- a/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs +++ b/src/rules/minimumvertexcover_minimumfeedbackvertexset.rs @@ -30,6 +30,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumvertexcover_minimumhittingset.rs b/src/rules/minimumvertexcover_minimumhittingset.rs index 57e9b6ed2..c306a8ca2 100644 --- a/src/rules/minimumvertexcover_minimumhittingset.rs +++ b/src/rules/minimumvertexcover_minimumhittingset.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionVCToHS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumvertexcover_minimumsetcovering.rs b/src/rules/minimumvertexcover_minimumsetcovering.rs index bbff2c664..e7f945fde 100644 --- a/src/rules/minimumvertexcover_minimumsetcovering.rs +++ b/src/rules/minimumvertexcover_minimumsetcovering.rs @@ -33,6 +33,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/minimumvertexcover_minimumweightandorgraph.rs b/src/rules/minimumvertexcover_minimumweightandorgraph.rs index 5628d979f..dc518f161 100644 --- a/src/rules/minimumvertexcover_minimumweightandorgraph.rs +++ b/src/rules/minimumvertexcover_minimumweightandorgraph.rs @@ -27,9 +27,11 @@ impl ReductionResult for ReductionVCToAndOrGraph { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.num_source_vertices) - .map(|j| usize::from(target_solution.get(self.sink_arc_start + j) == Some(&1))) + .map(|j| usize::from(target_solution[self.sink_arc_start + j] == 1)) .collect() }) } diff --git a/src/rules/minimumweightdecoding_ilp.rs b/src/rules/minimumweightdecoding_ilp.rs index daf35aa05..df4698183 100644 --- a/src/rules/minimumweightdecoding_ilp.rs +++ b/src/rules/minimumweightdecoding_ilp.rs @@ -44,6 +44,8 @@ impl ReductionResult for ReductionMinimumWeightDecodingToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_cols].to_vec()) } } diff --git a/src/rules/minmaxmulticenter_ilp.rs b/src/rules/minmaxmulticenter_ilp.rs index eb9ccd79e..e6b67cdcd 100644 --- a/src/rules/minmaxmulticenter_ilp.rs +++ b/src/rules/minmaxmulticenter_ilp.rs @@ -49,6 +49,8 @@ impl ReductionResult for ReductionMMCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/mixedchinesepostman_ilp.rs b/src/rules/mixedchinesepostman_ilp.rs index 173fa5a94..d96c86471 100644 --- a/src/rules/mixedchinesepostman_ilp.rs +++ b/src/rules/mixedchinesepostman_ilp.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionMCPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // Return the orientation bits d_k in source edge order target_solution[..self.num_undirected_edges].to_vec() diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 7a9dafa97..b6ed58db3 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -417,7 +417,7 @@ pub use search::{ ApproximationPolicy, LimitReached, SearchCompleteness, SearchLimits, SearchMode, SearchOutcome, SearchStats, }; -pub(crate) use traits::DynReductionResult; +pub(crate) use traits::{validate_target_solution, DynReductionResult}; pub use traits::{ AggregateReductionResult, ExtractionError, ExtractionResult, ReduceTo, ReduceToAggregate, ReductionAutoCast, ReductionResult, diff --git a/src/rules/monochromatictriangle_ilp.rs b/src/rules/monochromatictriangle_ilp.rs index 4da4805c3..9485e25a4 100644 --- a/src/rules/monochromatictriangle_ilp.rs +++ b/src/rules/monochromatictriangle_ilp.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionMonochromaticTriangleToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/multiplecopyfileallocation_ilp.rs b/src/rules/multiplecopyfileallocation_ilp.rs index 87c238c6c..8d0194dc0 100644 --- a/src/rules/multiplecopyfileallocation_ilp.rs +++ b/src/rules/multiplecopyfileallocation_ilp.rs @@ -40,6 +40,8 @@ impl ReductionResult for ReductionMCFAToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/multiprocessorscheduling_ilp.rs b/src/rules/multiprocessorscheduling_ilp.rs index 9487a8a2b..1217c42e3 100644 --- a/src/rules/multiprocessorscheduling_ilp.rs +++ b/src/rules/multiprocessorscheduling_ilp.rs @@ -37,16 +37,14 @@ impl ReductionResult for ReductionMSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let num_processors = self.num_processors; - (0..self.num_tasks) - .map(|j| { - (0..num_processors) - .find(|&p| target_solution[j * num_processors + p] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 bed2ca447..199ba9508 100644 --- a/src/rules/naesatisfiability_ilp.rs +++ b/src/rules/naesatisfiability_ilp.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionNAESATToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/naesatisfiability_maxcut.rs b/src/rules/naesatisfiability_maxcut.rs index eda476a4c..aad896f2f 100644 --- a/src/rules/naesatisfiability_maxcut.rs +++ b/src/rules/naesatisfiability_maxcut.rs @@ -40,6 +40,8 @@ impl ReductionResult for ReductionNAESATToMaxCut { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.source_num_vars) .map(|i| target_solution[2 * i]) diff --git a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs index 447f73832..346b1328d 100644 --- a/src/rules/naesatisfiability_partitionintoperfectmatchings.rs +++ b/src/rules/naesatisfiability_partitionintoperfectmatchings.rs @@ -69,6 +69,8 @@ impl ReductionResult for ReductionNAESATToPartitionIntoPerfectMatchings { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.layout .variables diff --git a/src/rules/naesatisfiability_setsplitting.rs b/src/rules/naesatisfiability_setsplitting.rs index 915df3614..7d8d5818c 100644 --- a/src/rules/naesatisfiability_setsplitting.rs +++ b/src/rules/naesatisfiability_setsplitting.rs @@ -29,15 +29,9 @@ impl ReductionResult for ReductionNAESATToSetSplitting { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - assert!( - target_solution.len() >= self.num_source_variables, - "SetSplitting solution has {} variables but source requires {}", - target_solution.len(), - self.num_source_variables, - ); - target_solution[..self.num_source_variables].to_vec() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.num_source_variables].to_vec()) } } diff --git a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs index 982048245..1d505df98 100644 --- a/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs +++ b/src/rules/numerical3dimensionalmatching_numericalmatchingwithtargetsums.rs @@ -30,12 +30,18 @@ impl ReductionResult for ReductionN3DMToNMTS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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]) - .expect("NMTS witness must not overflow i64 pair sums"); + .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() @@ -49,7 +55,11 @@ impl ReductionResult for ReductionN3DMToNMTS { let x_index = x_indices_by_pair_sum .get_mut(&target_sum) .and_then(Vec::pop) - .expect("satisfying NMTS witness must realize every target complement"); + .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]); } diff --git a/src/rules/numericalmatchingwithtargetsums_ilp.rs b/src/rules/numericalmatchingwithtargetsums_ilp.rs index ae04dcc03..c5b19c695 100644 --- a/src/rules/numericalmatchingwithtargetsums_ilp.rs +++ b/src/rules/numericalmatchingwithtargetsums_ilp.rs @@ -48,6 +48,8 @@ impl ReductionResult for ReductionNMTSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 b12c18fc9..4a8393998 100644 --- a/src/rules/openshopscheduling_ilp.rs +++ b/src/rules/openshopscheduling_ilp.rs @@ -92,6 +92,8 @@ impl ReductionResult for ReductionOSSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_jobs; let m = self.num_machines; @@ -99,7 +101,7 @@ impl ReductionResult for ReductionOSSToILP { // Read start times s_{j,i} for each (j, i) let start = |j: usize, i: usize| -> usize { let idx = self.num_order_vars + j * m + i; - target_solution.get(idx).copied().unwrap_or(0) + target_solution[idx] }; // For each machine, sort jobs by their start time on that machine diff --git a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 443b1df09..834f51e56 100644 --- a/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -49,6 +49,8 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ match &self.construction { // No edges: any arrangement has total length 0 <= k, so emit the @@ -63,16 +65,10 @@ impl ReductionResult for ReductionOptimalLinearArrangementToConsecutiveOnesMatri // `position`. The OLA arrangement is `f(vertex) = position`, i.e. // the inverse permutation. let n = *num_vertices; - if target_solution.len() != n { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected a permutation of {n} columns, got {} entries", - target_solution.len() - ))); - } let mut arrangement = vec![0usize; n]; let mut seen = vec![false; n]; for (position, &vertex) in target_solution.iter().enumerate() { - if vertex >= n || seen[vertex] { + if seen[vertex] { return Err(crate::rules::ExtractionError::invalid( "target column order is not a permutation", )); diff --git a/src/rules/optimallineararrangement_ilp.rs b/src/rules/optimallineararrangement_ilp.rs index afb80feac..14d9b9fa3 100644 --- a/src/rules/optimallineararrangement_ilp.rs +++ b/src/rules/optimallineararrangement_ilp.rs @@ -38,16 +38,14 @@ impl ReductionResult for ReductionOLAToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n = self.num_vertices; - (0..n) - .map(|v| { - (0..n) - .find(|&p| target_solution[v * n + p] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 96c280645..017425120 100644 --- a/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs +++ b/src/rules/optimallineararrangement_sequencingtominimizeweightedcompletiontime.rs @@ -36,10 +36,16 @@ impl ReductionResult for ReductionOLAToSequencingToMinimizeWeightedCompletionTim &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let schedule = crate::models::misc::decode_lehmer(target_solution, self.target.num_tasks()) - .expect("target solution must be a valid Lehmer code"); + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target configuration is not a Lehmer code", + ) + })?; let mut arrangement = vec![0usize; self.num_vertices]; let mut next_position = 0usize; diff --git a/src/rules/optimumcommunicationspanningtree_ilp.rs b/src/rules/optimumcommunicationspanningtree_ilp.rs index 0470e0359..7f98ad847 100644 --- a/src/rules/optimumcommunicationspanningtree_ilp.rs +++ b/src/rules/optimumcommunicationspanningtree_ilp.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionOptimumCommunicationSpanningTreeToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/paintshop_ilp.rs b/src/rules/paintshop_ilp.rs index 146cf6979..370e6b49e 100644 --- a/src/rules/paintshop_ilp.rs +++ b/src/rules/paintshop_ilp.rs @@ -28,6 +28,8 @@ impl ReductionResult for ReductionPaintShopToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_cars].to_vec()) } } diff --git a/src/rules/paintshop_qubo.rs b/src/rules/paintshop_qubo.rs index 9cb719e51..105bedb8f 100644 --- a/src/rules/paintshop_qubo.rs +++ b/src/rules/paintshop_qubo.rs @@ -32,6 +32,8 @@ impl ReductionResult for ReductionPaintShopToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partiallyorderedknapsack_ilp.rs b/src/rules/partiallyorderedknapsack_ilp.rs index 5fe35bed5..5352058c1 100644 --- a/src/rules/partiallyorderedknapsack_ilp.rs +++ b/src/rules/partiallyorderedknapsack_ilp.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionPOKToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_binpacking.rs b/src/rules/partition_binpacking.rs index 715c8e926..e070fc5d0 100644 --- a/src/rules/partition_binpacking.rs +++ b/src/rules/partition_binpacking.rs @@ -34,6 +34,8 @@ impl ReductionResult for ReductionPartitionToBinPacking { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 b5c262481..b449cb8b7 100644 --- a/src/rules/partition_cosineproductintegration.rs +++ b/src/rules/partition_cosineproductintegration.rs @@ -32,6 +32,8 @@ impl ReductionResult for ReductionPartitionToCPI { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_integralflowwithmultipliers.rs b/src/rules/partition_integralflowwithmultipliers.rs index 39cf06143..ac590f3e7 100644 --- a/src/rules/partition_integralflowwithmultipliers.rs +++ b/src/rules/partition_integralflowwithmultipliers.rs @@ -36,13 +36,7 @@ impl ReductionResult for ReductionPartitionToIntegralFlowWithMultipliers { "the fixed infeasible target instance has no extractable witness", ) })?; - if target_solution.len() < item_arc_count { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected at least {} flow values, got {}", - item_arc_count, - target_solution.len() - ))); - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; target_solution[..item_arc_count].to_vec() }) diff --git a/src/rules/partition_knapsack.rs b/src/rules/partition_knapsack.rs index 9bddbef27..d2539f60f 100644 --- a/src/rules/partition_knapsack.rs +++ b/src/rules/partition_knapsack.rs @@ -22,6 +22,8 @@ impl ReductionResult for ReductionPartitionToKnapsack { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_multiprocessorscheduling.rs b/src/rules/partition_multiprocessorscheduling.rs index f1e54a355..0793a191e 100644 --- a/src/rules/partition_multiprocessorscheduling.rs +++ b/src/rules/partition_multiprocessorscheduling.rs @@ -36,6 +36,8 @@ impl ReductionResult for ReductionPartitionToMPS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partition_openshopscheduling.rs b/src/rules/partition_openshopscheduling.rs index 6bd8a5193..68bb46050 100644 --- a/src/rules/partition_openshopscheduling.rs +++ b/src/rules/partition_openshopscheduling.rs @@ -21,8 +21,10 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ - let num_elements = self.target.num_jobs().saturating_sub(1); + let num_elements = self.target.num_jobs() - 1; let mut source_config = vec![0; num_elements]; let Some(orders) = self.target.decode_orders(target_solution) else { return Err(crate::rules::ExtractionError::invalid( @@ -60,9 +62,17 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { } } } - let (start, mi, job) = best.expect("schedule incomplete"); + let (start, mi, job) = best.ok_or_else(|| { + crate::rules::ExtractionError::invalid("target schedule is incomplete") + })?; start_times[job][mi] = start; - let end = start + self.target.processing_times()[job][mi]; + let end = start + .checked_add(self.target.processing_times()[job][mi]) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target schedule time overflows usize", + ) + })?; machine_avail[mi] = end; job_avail[job] = end; cursor[mi] += 1; @@ -71,16 +81,21 @@ impl ReductionResult for ReductionPartitionToOpenShopScheduling { // 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) - .unwrap_or_else(|| { - let mut machines: Vec = (0..m).collect(); - machines.sort_by_key(|&machine| (start_times[special_job][machine], machine)); - machines[m / 2] - }); + .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] - + self.target.processing_times()[job][middle_machine]; + .checked_add(self.target.processing_times()[job][middle_machine]) + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target schedule time overflows usize", + ) + })?; if completion <= pivot { *slot = 1; } diff --git a/src/rules/partition_productionplanning.rs b/src/rules/partition_productionplanning.rs index 6a1f0c6fd..b18798007 100644 --- a/src/rules/partition_productionplanning.rs +++ b/src/rules/partition_productionplanning.rs @@ -21,13 +21,12 @@ impl ReductionResult for ReductionPartitionToProductionPlanning { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - target_solution - .iter() - .take(self.target.num_periods().saturating_sub(1)) - .map(|&production| usize::from(production > 0)) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + Ok(target_solution[..self.target.num_periods() - 1] + .iter() + .map(|&production| usize::from(production > 0)) + .collect()) } } diff --git a/src/rules/partition_sequencingtominimizetardytaskweight.rs b/src/rules/partition_sequencingtominimizetardytaskweight.rs index 05c6409e6..9c4259ab5 100644 --- a/src/rules/partition_sequencingtominimizetardytaskweight.rs +++ b/src/rules/partition_sequencingtominimizetardytaskweight.rs @@ -10,21 +10,6 @@ pub struct ReductionPartitionToSequencingToMinimizeTardyTaskWeight { target: SequencingToMinimizeTardyTaskWeight, } -impl ReductionPartitionToSequencingToMinimizeTardyTaskWeight { - fn decode_schedule(&self, target_solution: &[usize]) -> Vec { - let n = self.target.num_tasks(); - assert_eq!( - target_solution.len(), - n, - "target solution length must equal target num_tasks" - ); - - // The target model uses direct permutation encoding (dims = [n; n]). - // Each position is a task index; the solver returns a valid permutation. - target_solution.to_vec() - } -} - impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { type Source = Partition; type Target = SequencingToMinimizeTardyTaskWeight; @@ -37,15 +22,29 @@ impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ - let schedule = self.decode_schedule(target_solution); + let mut seen = vec![false; self.target.num_tasks()]; + for &task in target_solution { + if std::mem::replace(&mut seen[task], true) { + return Err(crate::rules::ExtractionError::invalid(format!( + "target schedule contains task {task} more than once" + ))); + } + } + let mut source_config = vec![1; self.target.num_tasks()]; let mut completion_time = 0u64; - for task in schedule { + for &task in target_solution { completion_time = completion_time .checked_add(self.target.lengths()[task]) - .expect("completion time overflowed u64"); + .ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target schedule completion time overflows u64", + ) + })?; if completion_time <= self.target.deadlines()[task] { source_config[task] = 0; } diff --git a/src/rules/partition_subsetsum.rs b/src/rules/partition_subsetsum.rs index 26526461a..3c6011ced 100644 --- a/src/rules/partition_subsetsum.rs +++ b/src/rules/partition_subsetsum.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionPartitionToSubsetSum { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + 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 {}", diff --git a/src/rules/partition_sumofsquarespartition.rs b/src/rules/partition_sumofsquarespartition.rs index 1d6b642ce..e095626ee 100644 --- a/src/rules/partition_sumofsquarespartition.rs +++ b/src/rules/partition_sumofsquarespartition.rs @@ -49,13 +49,8 @@ impl ReductionResult for ReductionPartitionToSumOfSquaresPartition { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - let expected = self.target.num_elements(); - if target_solution.len() != expected { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {expected} group assignments, got {}", - target_solution.len() - ))); - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.source_n].to_vec()) } } diff --git a/src/rules/partitionintocliques_minimumcoveringbycliques.rs b/src/rules/partitionintocliques_minimumcoveringbycliques.rs index 6b4367928..2c13d29cc 100644 --- a/src/rules/partitionintocliques_minimumcoveringbycliques.rs +++ b/src/rules/partitionintocliques_minimumcoveringbycliques.rs @@ -108,17 +108,11 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.source_graph.num_vertices(); let target_edges = self.target.graph().edges(); - if target_solution.len() != target_edges.len() { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {} edge labels, got {}", - target_edges.len(), - target_solution.len() - ))); - } - 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 { @@ -134,21 +128,19 @@ impl ReductionResult for ReductionPartitionIntoCliquesToMinimumCoveringByCliques } } - if matching_labels.iter().any(Option::is_none) { - return Err(crate::rules::ExtractionError::invalid( - "target cover does not label every matching gadget edge", - )); - } - let mut label_map = BTreeMap::new(); let extracted = matching_labels .into_iter() .map(|label| { - let label = label.expect("checked above"); + let label = label.ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target cover does not label every matching gadget edge", + ) + })?; let next = label_map.len(); - *label_map.entry(label).or_insert(next) + Ok(*label_map.entry(label).or_insert(next)) }) - .collect::>(); + .collect::>>()?; if label_map.len() > self.source_num_cliques { return Err(crate::rules::ExtractionError::invalid(format!( diff --git a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs index 7e856e100..bb8d149c3 100644 --- a/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs +++ b/src/rules/partitionintopathsoflength2_boundedcomponentspanningforest.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionPPL2ToBCSF { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/partitionintopathsoflength2_ilp.rs b/src/rules/partitionintopathsoflength2_ilp.rs index 1c5540d36..fcaee2e58 100644 --- a/src/rules/partitionintopathsoflength2_ilp.rs +++ b/src/rules/partitionintopathsoflength2_ilp.rs @@ -47,19 +47,14 @@ impl ReductionResult for ReductionPIPL2ToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let num_groups = self.num_groups; - (0..self.num_vertices) - .map(|v| { - (0..num_groups) - .find(|&g| { - let idx = v * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 dc83de3bc..cb31412f1 100644 --- a/src/rules/partitionintotriangles_ilp.rs +++ b/src/rules/partitionintotriangles_ilp.rs @@ -41,19 +41,14 @@ impl ReductionResult for ReductionPITToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let num_groups = self.num_groups; - (0..self.num_vertices) - .map(|v| { - (0..num_groups) - .find(|&g| { - let idx = v * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 30f787a49..aab353b21 100644 --- a/src/rules/pathconstrainednetworkflow_ilp.rs +++ b/src/rules/pathconstrainednetworkflow_ilp.rs @@ -26,6 +26,8 @@ impl ReductionResult for ReductionPCNFToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/precedenceconstrainedscheduling_ilp.rs b/src/rules/precedenceconstrainedscheduling_ilp.rs index 86fcb73d5..351c37021 100644 --- a/src/rules/precedenceconstrainedscheduling_ilp.rs +++ b/src/rules/precedenceconstrainedscheduling_ilp.rs @@ -42,16 +42,14 @@ impl ReductionResult for ReductionPCSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let d = self.deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 37a55560d..b5a2b6203 100644 --- a/src/rules/preemptivescheduling_ilp.rs +++ b/src/rules/preemptivescheduling_ilp.rs @@ -55,9 +55,11 @@ impl ReductionResult for ReductionPSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let nd = self.num_tasks * self.d_max; - target_solution[..nd.min(target_solution.len())].to_vec() + target_solution[..nd].to_vec() }) } } diff --git a/src/rules/prizecollectingsteinerforest_steinertree.rs b/src/rules/prizecollectingsteinerforest_steinertree.rs index fed6438a9..67c05cda4 100644 --- a/src/rules/prizecollectingsteinerforest_steinertree.rs +++ b/src/rules/prizecollectingsteinerforest_steinertree.rs @@ -73,6 +73,8 @@ impl ReductionResult for ReductionPCSFToSteinerTree { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_source_vertices; let m = self.num_source_edges; @@ -97,7 +99,7 @@ impl ReductionResult for ReductionPCSFToSteinerTree { // (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.get(target_idx).copied() != Some(1) { + if target_solution[target_idx] != 1 { continue; } if let Some(src_edge) = self.target_to_source_edge[target_idx] { diff --git a/src/rules/quadraticassignment_ilp.rs b/src/rules/quadraticassignment_ilp.rs index fc5f9bfcc..2e8736902 100644 --- a/src/rules/quadraticassignment_ilp.rs +++ b/src/rules/quadraticassignment_ilp.rs @@ -38,16 +38,14 @@ impl ReductionResult for ReductionQAPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let loc = self.num_locations; - (0..self.num_facilities) - .map(|i| { - (0..loc) - .find(|&p| target_solution[i * loc + p] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_facilities, + self.num_locations, + 0, + ) } } diff --git a/src/rules/qubo_ilp.rs b/src/rules/qubo_ilp.rs index 75b1e7792..799d15388 100644 --- a/src/rules/qubo_ilp.rs +++ b/src/rules/qubo_ilp.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionQUBOToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_original].to_vec()) } } diff --git a/src/rules/rectilinearpicturecompression_ilp.rs b/src/rules/rectilinearpicturecompression_ilp.rs index 94ff40d3c..934fd4edc 100644 --- a/src/rules/rectilinearpicturecompression_ilp.rs +++ b/src/rules/rectilinearpicturecompression_ilp.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionRPCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/registersufficiency_ilp.rs b/src/rules/registersufficiency_ilp.rs index 788c3cba6..ed6615d47 100644 --- a/src/rules/registersufficiency_ilp.rs +++ b/src/rules/registersufficiency_ilp.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionRegisterSufficiencyToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_vertices].to_vec()) } } diff --git a/src/rules/resourceconstrainedscheduling_ilp.rs b/src/rules/resourceconstrainedscheduling_ilp.rs index 2301d357b..e525d1b9e 100644 --- a/src/rules/resourceconstrainedscheduling_ilp.rs +++ b/src/rules/resourceconstrainedscheduling_ilp.rs @@ -33,16 +33,14 @@ impl ReductionResult for ReductionRCSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let d = self.deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 9ec80e2f4..779527a6f 100644 --- a/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs +++ b/src/rules/rootedtreearrangement_rootedtreestorageassignment.rs @@ -40,6 +40,8 @@ impl ReductionResult for ReductionRootedTreeArrangementToRootedTreeStorageAssign &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 d60fc2c65..6019fdd8b 100644 --- a/src/rules/rootedtreestorageassignment_ilp.rs +++ b/src/rules/rootedtreestorageassignment_ilp.rs @@ -7,6 +7,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::set::RootedTreeStorageAssignment; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode_rows; use crate::rules::traits::{ReduceTo, ReductionResult}; // Index helpers @@ -75,16 +76,9 @@ impl ReductionResult for ReductionRTSAToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n = self.n; - (0..n) - .map(|v| { - (0..n) - .find(|&u| target_solution[idx_p(n, v, u)] == 1) - .unwrap_or(v) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + one_hot_decode_rows(target_solution, self.n, self.n, 0) } } diff --git a/src/rules/ruralpostman_ilp.rs b/src/rules/ruralpostman_ilp.rs index 01785f301..e892f67f1 100644 --- a/src/rules/ruralpostman_ilp.rs +++ b/src/rules/ruralpostman_ilp.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionRPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // Output the traversal multiplicities t_e target_solution[..self.num_edges].to_vec() diff --git a/src/rules/sat_circuitsat.rs b/src/rules/sat_circuitsat.rs index a2236d72c..d3ae3a7d3 100644 --- a/src/rules/sat_circuitsat.rs +++ b/src/rules/sat_circuitsat.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionSATToCircuit { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.source_var_indices .iter() diff --git a/src/rules/sat_coloring.rs b/src/rules/sat_coloring.rs index be2273643..47bb3d305 100644 --- a/src/rules/sat_coloring.rs +++ b/src/rules/sat_coloring.rs @@ -244,22 +244,20 @@ impl ReductionResult for ReductionSATToColoring { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // First determine which color is TRUE, FALSE, and AUX // Vertices 0, 1, 2 are TRUE, FALSE, AUX respectively - assert!( - target_solution.len() >= 3, - "Invalid solution: coloring must have at least 3 vertices" - ); let true_color = target_solution[0]; let false_color = target_solution[1]; let aux_color = target_solution[2]; - // Sanity checks - assert!( - true_color != false_color && true_color != aux_color, - "Invalid coloring solution: special vertices must have distinct colors" - ); + 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![0usize; self.num_source_variables]; @@ -267,10 +265,11 @@ impl ReductionResult for ReductionSATToColoring { let vertex_color = target_solution[pos_vertex]; // Sanity check: variable vertices should not have AUX color - assert!( - vertex_color != aux_color, - "Invalid coloring solution: variable vertex has auxiliary 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) diff --git a/src/rules/sat_ksat.rs b/src/rules/sat_ksat.rs index ea73fa1a2..2bf711699 100644 --- a/src/rules/sat_ksat.rs +++ b/src/rules/sat_ksat.rs @@ -35,6 +35,8 @@ impl ReductionResult for ReductionSATToKSAT { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ // Only return the original variables, discarding ancillas target_solution[..self.source_num_vars].to_vec() @@ -171,6 +173,8 @@ impl ReductionResult for ReductionKSATToSAT { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 6d8ba24e3..09cdc61c0 100644 --- a/src/rules/sat_maximumindependentset.rs +++ b/src/rules/sat_maximumindependentset.rs @@ -80,6 +80,8 @@ impl ReductionResult for ReductionSATToIS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let mut assignment = vec![0usize; self.num_source_variables]; let mut covered = vec![false; self.num_source_variables]; diff --git a/src/rules/sat_minimumdominatingset.rs b/src/rules/sat_minimumdominatingset.rs index dd3bccbf6..3f78049e9 100644 --- a/src/rules/sat_minimumdominatingset.rs +++ b/src/rules/sat_minimumdominatingset.rs @@ -58,50 +58,31 @@ impl ReductionResult for ReductionSATToDS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let selected_count: usize = target_solution.iter().sum(); - - // If more vertices selected than variables, not a minimal dominating set - // corresponding to a satisfying assignment - if selected_count > self.num_literals { - return Err(crate::rules::ExtractionError::invalid(format!( - "selected {selected_count} dominating-set vertices for {} source variables", - self.num_literals - ))); - } - - let mut assignment = vec![0usize; self.num_literals]; - - for (i, &value) in target_solution.iter().enumerate() { - if value == 1 { - // Only consider variable gadget vertices (first 3*num_literals vertices) - if i >= 3 * self.num_literals { - continue; // Skip clause vertices - } - - let var_index = i / 3; - let vertex_type = i % 3; - - match vertex_type { - 0 => { - // Positive literal selected: x_i = true - assignment[var_index] = 1; - } - 1 => { - // Negative literal selected: x_i = false - assignment[var_index] = 0; - } - 2 => { - // Dummy vertex selected: variable is unconstrained - // Default to false (already 0), but could be anything - } - _ => unreachable!(), - } - } - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + let assignment = target_solution[..3 * self.num_literals] + .chunks_exact(3) + .enumerate() + .map(|(variable, gadget)| match gadget { + [1, 0, 0] => Ok(1), + [0, 1, 0] | [0, 0, 1] => Ok(0), + _ => Err(crate::rules::ExtractionError::invalid(format!( + "variable {variable} gadget must select exactly one vertex, got {}", + gadget.iter().sum::() + ))), + }) + .collect::>>()?; + + if let Some(clause) = target_solution[3 * self.num_literals..] + .iter() + .position(|&selected| selected == 1) + { + return Err(crate::rules::ExtractionError::invalid(format!( + "clause vertex {clause} is selected" + ))); + } - assignment - }) + Ok(assignment) } } diff --git a/src/rules/satisfiability_integralflowhomologousarcs.rs b/src/rules/satisfiability_integralflowhomologousarcs.rs index 227a1c387..ec7e9ee87 100644 --- a/src/rules/satisfiability_integralflowhomologousarcs.rs +++ b/src/rules/satisfiability_integralflowhomologousarcs.rs @@ -106,18 +106,12 @@ impl ReductionResult for ReductionSATToIntegralFlowHomologousArcs { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ self.variable_paths .iter() - .map(|paths| { - usize::from( - target_solution - .get(paths.true_base_arc) - .copied() - .unwrap_or(0) - > 0, - ) - }) + .map(|paths| usize::from(target_solution[paths.true_base_arc] > 0)) .collect() }) } diff --git a/src/rules/satisfiability_maximum2satisfiability.rs b/src/rules/satisfiability_maximum2satisfiability.rs index d959c8e1b..8b375f917 100644 --- a/src/rules/satisfiability_maximum2satisfiability.rs +++ b/src/rules/satisfiability_maximum2satisfiability.rs @@ -23,6 +23,8 @@ impl ReductionResult for ReductionSatisfiabilityToMaximum2Satisfiability { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.source_num_vars].to_vec()) } } diff --git a/src/rules/satisfiability_naesatisfiability.rs b/src/rules/satisfiability_naesatisfiability.rs index c0073d313..0f90f23bb 100644 --- a/src/rules/satisfiability_naesatisfiability.rs +++ b/src/rules/satisfiability_naesatisfiability.rs @@ -32,25 +32,9 @@ impl ReductionResult for ReductionSATToNAESAT { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - let n = self.source_num_vars; - let expected = n + 1; - if target_solution.len() != expected { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {expected} values including the sentinel, got {}", - target_solution.len() - ))); - } - if let Some((index, value)) = target_solution - .iter() - .copied() - .enumerate() - .find(|(_, value)| *value > 1) - { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected a binary value at position {index}, got {value}" - ))); - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + let n = self.source_num_vars; 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 385891290..696c1896b 100644 --- a/src/rules/satisfiability_nontautology.rs +++ b/src/rules/satisfiability_nontautology.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionSATToNonTautology { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs index 8ad396270..379fece7a 100644 --- a/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/schedulingtominimizeweightedcompletiontime_ilp.rs @@ -8,6 +8,7 @@ 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}; /// Result of reducing SchedulingToMinimizeWeightedCompletionTime to ILP. @@ -55,15 +56,9 @@ impl ReductionResult for ReductionSMWCTToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - (0..self.num_tasks) - .map(|t| { - (0..self.num_processors) - .find(|&p| target_solution[self.x_var(t, p)] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 850c52348..3d3582039 100644 --- a/src/rules/schedulingwithindividualdeadlines_ilp.rs +++ b/src/rules/schedulingwithindividualdeadlines_ilp.rs @@ -14,6 +14,7 @@ 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}; /// Result of reducing SchedulingWithIndividualDeadlines to ILP. @@ -42,16 +43,9 @@ impl ReductionResult for ReductionSWIDToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let d = self.max_deadline; - (0..self.num_tasks) - .map(|j| { - (0..d) - .find(|&t| target_solution.get(j * d + t).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 d6545b30a..6e788ad2e 100644 --- a/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs +++ b/src/rules/sequencingtominimizemaximumcumulativecost_ilp.rs @@ -35,9 +35,11 @@ impl ReductionResult for ReductionSTMMCCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_tasks; - let schedule = one_hot_decode(target_solution, n, n, 0); + let schedule = one_hot_decode(target_solution, n, n, 0)?; permutation_to_lehmer(&schedule) }) } diff --git a/src/rules/sequencingtominimizetardytaskweight_ilp.rs b/src/rules/sequencingtominimizetardytaskweight_ilp.rs index 5c4a88110..2648134ef 100644 --- a/src/rules/sequencingtominimizetardytaskweight_ilp.rs +++ b/src/rules/sequencingtominimizetardytaskweight_ilp.rs @@ -29,12 +29,14 @@ impl ReductionResult for ReductionSTMTTWToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 655154fe2..134e66b49 100644 --- a/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs +++ b/src/rules/sequencingtominimizeweightedcompletiontime_ilp.rs @@ -55,9 +55,11 @@ impl ReductionResult for ReductionSTMWCTToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let mut schedule: Vec = (0..self.num_tasks).collect(); - schedule.sort_by_key(|&task| (target_solution.get(task).copied().unwrap_or(0), task)); + schedule.sort_by_key(|&task| (target_solution[task], task)); Self::encode_schedule_as_lehmer(&schedule) }) } diff --git a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs index e8e5bb1ee..747c7846c 100644 --- a/src/rules/sequencingtominimizeweightedtardiness_ilp.rs +++ b/src/rules/sequencingtominimizeweightedtardiness_ilp.rs @@ -53,11 +53,13 @@ impl ReductionResult for ReductionSTMWTToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_tasks; let c_offset = self.num_order_vars; let mut jobs: Vec = (0..n).collect(); - jobs.sort_by_key(|&j| (target_solution.get(c_offset + j).copied().unwrap_or(0), j)); + jobs.sort_by_key(|&j| (target_solution[c_offset + j], j)); Self::encode_schedule_as_lehmer(&jobs) }) } diff --git a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs index 5c63a7e61..9af0f5db9 100644 --- a/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs +++ b/src/rules/sequencingwithdeadlinesandsetuptimes_ilp.rs @@ -40,10 +40,12 @@ impl ReductionResult for ReductionSWDSTToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 8457f2444..8424562ab 100644 --- a/src/rules/sequencingwithinintervals_ilp.rs +++ b/src/rules/sequencingwithinintervals_ilp.rs @@ -47,16 +47,24 @@ impl ReductionResult for ReductionSWIToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - self.task_layout - .iter() - .map(|&(base, count)| { - (0..count) - .find(|&k| target_solution.get(base + k).copied().unwrap_or(0) == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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" + ))), + } + }) + .collect() } } diff --git a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs index 3dfca7126..21013774e 100644 --- a/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs +++ b/src/rules/sequencingwithreleasetimesanddeadlines_ilp.rs @@ -50,18 +50,15 @@ impl ReductionResult for ReductionSWRTDToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_tasks; let horizon = self.time_horizon; // For each task, find the start time - let mut start_times: Vec<(usize, usize)> = (0..n) - .map(|j| { - let start = (0..horizon) - .find(|&t| target_solution.get(j * horizon + t).copied().unwrap_or(0) == 1) - .unwrap_or(0); - (j, start) - }) - .collect(); + let starts = + 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)); let schedule: Vec = start_times.iter().map(|&(j, _)| j).collect(); diff --git a/src/rules/setsplitting_betweenness.rs b/src/rules/setsplitting_betweenness.rs index 280e6acc6..a64fee966 100644 --- a/src/rules/setsplitting_betweenness.rs +++ b/src/rules/setsplitting_betweenness.rs @@ -32,26 +32,13 @@ impl ReductionResult for ReductionSetSplittingToBetweenness { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - assert!( - target_solution.len() > self.pole, - "Betweenness solution has {} positions but pole index is {}", - target_solution.len(), - self.pole - ); - assert!( - target_solution.len() >= self.source_universe_size, - "Betweenness solution has {} positions but source requires {} elements", - target_solution.len(), - self.source_universe_size - ); + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; - let pole_position = target_solution[self.pole]; - target_solution[..self.source_universe_size] - .iter() - .map(|&position| usize::from(position > pole_position)) - .collect() - }) + let pole_position = target_solution[self.pole]; + Ok(target_solution[..self.source_universe_size] + .iter() + .map(|&position| usize::from(position > pole_position)) + .collect()) } } diff --git a/src/rules/setsplitting_ilp.rs b/src/rules/setsplitting_ilp.rs index 67c737081..b7191f939 100644 --- a/src/rules/setsplitting_ilp.rs +++ b/src/rules/setsplitting_ilp.rs @@ -32,6 +32,8 @@ impl ReductionResult for ReductionSetSplittingToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/shortestcommonsupersequence_ilp.rs b/src/rules/shortestcommonsupersequence_ilp.rs index fe002c0b1..2a284afd3 100644 --- a/src/rules/shortestcommonsupersequence_ilp.rs +++ b/src/rules/shortestcommonsupersequence_ilp.rs @@ -31,17 +31,14 @@ impl ReductionResult for ReductionSCSToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let b = self.max_length; - let k = self.alphabet_size + 1; // includes padding symbol - (0..b) - .map(|p| { - (0..k) - .find(|&a| target_solution[p * k + a] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.max_length, + self.alphabet_size + 1, + 0, + ) } } diff --git a/src/rules/shortestweightconstrainedpath_ilp.rs b/src/rules/shortestweightconstrainedpath_ilp.rs index a45fea85e..d43b7bc7f 100644 --- a/src/rules/shortestweightconstrainedpath_ilp.rs +++ b/src/rules/shortestweightconstrainedpath_ilp.rs @@ -44,20 +44,14 @@ impl ReductionResult for ReductionSWCPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ (0..self.num_edges) .map(|edge_idx| { usize::from( - target_solution - .get(Self::arc_var(edge_idx, 0)) - .copied() - .unwrap_or(0) - > 0 - || target_solution - .get(Self::arc_var(edge_idx, 1)) - .copied() - .unwrap_or(0) - > 0, + target_solution[Self::arc_var(edge_idx, 0)] > 0 + || target_solution[Self::arc_var(edge_idx, 1)] > 0, ) }) .collect() diff --git a/src/rules/sparsematrixcompression_ilp.rs b/src/rules/sparsematrixcompression_ilp.rs index 209378a1d..a406f0580 100644 --- a/src/rules/sparsematrixcompression_ilp.rs +++ b/src/rules/sparsematrixcompression_ilp.rs @@ -26,16 +26,14 @@ impl ReductionResult for ReductionSMCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - // For each row r, output the unique zero-based shift g with x_{r,g} = 1 - (0..self.num_rows) - .map(|r| { - (0..self.bound_k) - .find(|&g| target_solution[r * self.bound_k + g] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + crate::rules::ilp_helpers::one_hot_decode_rows( + target_solution, + self.num_rows, + self.bound_k, + 0, + ) } } diff --git a/src/rules/spinglass_maxcut.rs b/src/rules/spinglass_maxcut.rs index c237cf4cc..ac6e3a610 100644 --- a/src/rules/spinglass_maxcut.rs +++ b/src/rules/spinglass_maxcut.rs @@ -40,6 +40,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } @@ -119,6 +121,8 @@ where &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ match self.ancilla { None => target_solution.to_vec(), diff --git a/src/rules/spinglass_qubo.rs b/src/rules/spinglass_qubo.rs index bf29ea5c0..f77b3353b 100644 --- a/src/rules/spinglass_qubo.rs +++ b/src/rules/spinglass_qubo.rs @@ -30,6 +30,8 @@ impl ReductionResult for ReductionQUBOToSG { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } @@ -108,6 +110,8 @@ impl ReductionResult for ReductionSGToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/stackercrane_ilp.rs b/src/rules/stackercrane_ilp.rs index 3937557fb..7277f6bc6 100644 --- a/src/rules/stackercrane_ilp.rs +++ b/src/rules/stackercrane_ilp.rs @@ -35,9 +35,11 @@ impl ReductionResult for ReductionSCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 496be693a..c6ab0162d 100644 --- a/src/rules/steinertree_ilp.rs +++ b/src/rules/steinertree_ilp.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionSteinerTreeToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/steinertreeingraphs_ilp.rs b/src/rules/steinertreeingraphs_ilp.rs index 67219a73a..1404ea117 100644 --- a/src/rules/steinertreeingraphs_ilp.rs +++ b/src/rules/steinertreeingraphs_ilp.rs @@ -37,6 +37,8 @@ impl ReductionResult for ReductionSTIGToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_edges].to_vec()) } } diff --git a/src/rules/stringtostringcorrection_ilp.rs b/src/rules/stringtostringcorrection_ilp.rs index a476702fb..ab0ca3b6d 100644 --- a/src/rules/stringtostringcorrection_ilp.rs +++ b/src/rules/stringtostringcorrection_ilp.rs @@ -58,6 +58,8 @@ impl ReductionResult for ReductionSTSCToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.n; let k = self.bound; @@ -76,28 +78,27 @@ impl ReductionResult for ReductionSTSCToILP { .filter(|&p| target_solution[idx_e(n, k, t - 1, p)] == 0) .count(); + let mut selected = Vec::new(); if target_solution[idx_nu(n, k, t)] == 1 { - ops.push(noop_code); - } else { - let mut found = false; - for j in 0..n { - if target_solution[idx_d(n, k, t, j)] == 1 { - ops.push(j); - found = true; - break; - } + selected.push(noop_code); + } + selected.extend((0..n).filter(|&j| target_solution[idx_d(n, k, t, j)] == 1)); + selected.extend( + (0..nm1) + .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" + ))) } - if !found { - for j in 0..nm1 { - if target_solution[idx_s(n, k, t, j)] == 1 { - ops.push(current_len + j); - found = true; - break; - } - } - if !found { - ops.push(noop_code); - } + _ => { + return Err(crate::rules::ExtractionError::invalid(format!( + "edit step {t} has multiple selected operations" + ))) } } } diff --git a/src/rules/strongconnectivityaugmentation_ilp.rs b/src/rules/strongconnectivityaugmentation_ilp.rs index 81727c373..66638ae19 100644 --- a/src/rules/strongconnectivityaugmentation_ilp.rs +++ b/src/rules/strongconnectivityaugmentation_ilp.rs @@ -27,6 +27,8 @@ impl ReductionResult for ReductionSCAToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.num_candidates].to_vec()) } } diff --git a/src/rules/subgraphisomorphism_ilp.rs b/src/rules/subgraphisomorphism_ilp.rs index 5839bae85..d4241e263 100644 --- a/src/rules/subgraphisomorphism_ilp.rs +++ b/src/rules/subgraphisomorphism_ilp.rs @@ -10,7 +10,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::SubgraphIsomorphism; use crate::reduction; -use crate::rules::ilp_helpers::one_hot_assignment_constraints; +use crate::rules::ilp_helpers::{one_hot_assignment_constraints, one_hot_decode_rows}; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::Graph; @@ -38,16 +38,14 @@ impl ReductionResult for ReductionSubIsoToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let n_host = self.num_host_vertices; - (0..self.num_pattern_vertices) - .map(|v| { - (0..n_host) - .find(|&u| target_solution[v * n_host + u] == 1) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 0799edee4..7fa9986c4 100644 --- a/src/rules/subsetsum_closestvectorproblem.rs +++ b/src/rules/subsetsum_closestvectorproblem.rs @@ -25,6 +25,8 @@ impl ReductionResult for ReductionSubsetSumToClosestVectorProblem { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/subsetsum_integerexpressionmembership.rs b/src/rules/subsetsum_integerexpressionmembership.rs index 5244b4af1..ba0f37461 100644 --- a/src/rules/subsetsum_integerexpressionmembership.rs +++ b/src/rules/subsetsum_integerexpressionmembership.rs @@ -21,6 +21,8 @@ impl ReductionResult for ReductionSubsetSumToIntegerExpressionMembership { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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_partition.rs b/src/rules/subsetsum_partition.rs index baf58dbb3..60f213de1 100644 --- a/src/rules/subsetsum_partition.rs +++ b/src/rules/subsetsum_partition.rs @@ -34,6 +34,8 @@ impl ReductionResult for ReductionSubsetSumToPartition { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 48f47f8f1..7259c96b8 100644 --- a/src/rules/sumofsquarespartition_ilp.rs +++ b/src/rules/sumofsquarespartition_ilp.rs @@ -60,19 +60,14 @@ impl ReductionResult for ReductionSSPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - Ok({ - let num_groups = self.num_groups; - (0..self.num_elements) - .map(|i| { - (0..num_groups) - .find(|&g| { - let idx = i * num_groups + g; - idx < target_solution.len() && target_solution[idx] == 1 - }) - .unwrap_or(0) - }) - .collect() - }) + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + + 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 ef7e066bc..cb95999c3 100644 --- a/src/rules/test_helpers.rs +++ b/src/rules/test_helpers.rs @@ -297,6 +297,8 @@ mod tests { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } @@ -317,6 +319,8 @@ mod tests { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } @@ -337,6 +341,8 @@ mod tests { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } @@ -357,6 +363,8 @@ mod tests { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_ilp.rs b/src/rules/threedimensionalmatching_ilp.rs index 444838dc7..cf5bcb7ae 100644 --- a/src/rules/threedimensionalmatching_ilp.rs +++ b/src/rules/threedimensionalmatching_ilp.rs @@ -22,6 +22,8 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_minimumweightdecoding.rs b/src/rules/threedimensionalmatching_minimumweightdecoding.rs index d7a7e097f..89328ccf2 100644 --- a/src/rules/threedimensionalmatching_minimumweightdecoding.rs +++ b/src/rules/threedimensionalmatching_minimumweightdecoding.rs @@ -51,13 +51,8 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToMinimumWeightDecodin &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { - let expected = self.target.num_cols(); - if target_solution.len() != expected { - return Err(crate::rules::ExtractionError::invalid(format!( - "expected {expected} codeword values, got {}", - target_solution.len() - ))); - } + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..self.source_num_triples].to_vec()) } } diff --git a/src/rules/threedimensionalmatching_threematroidintersection.rs b/src/rules/threedimensionalmatching_threematroidintersection.rs index 4a6438dc0..2bcd603e5 100644 --- a/src/rules/threedimensionalmatching_threematroidintersection.rs +++ b/src/rules/threedimensionalmatching_threematroidintersection.rs @@ -24,6 +24,8 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreeMatroidIntersec &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threedimensionalmatching_threepartition.rs b/src/rules/threedimensionalmatching_threepartition.rs index b3ff5f9a2..b94a13004 100644 --- a/src/rules/threedimensionalmatching_threepartition.rs +++ b/src/rules/threedimensionalmatching_threepartition.rs @@ -298,6 +298,8 @@ impl ReductionResult for ReductionThreeDimensionalMatchingToThreePartition { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + 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() { diff --git a/src/rules/threepartition_resourceconstrainedscheduling.rs b/src/rules/threepartition_resourceconstrainedscheduling.rs index 7cf07c2d5..5881866f1 100644 --- a/src/rules/threepartition_resourceconstrainedscheduling.rs +++ b/src/rules/threepartition_resourceconstrainedscheduling.rs @@ -42,6 +42,8 @@ impl ReductionResult for ReductionThreePartitionToRCS { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs index 39e9227c5..6c8c14222 100644 --- a/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs +++ b/src/rules/threepartition_sequencingwithreleasetimesanddeadlines.rs @@ -52,11 +52,17 @@ impl ReductionResult for ReductionThreePartitionToSRTD { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.target.num_tasks(); // Decode Lehmer code to permutation - let schedule = crate::models::misc::decode_lehmer(target_solution, n) - .expect("target_solution must be a valid Lehmer code"); + let schedule = + crate::models::misc::decode_lehmer(target_solution, n).ok_or_else(|| { + crate::rules::ExtractionError::invalid( + "target configuration is not a Lehmer code", + ) + })?; // Simulate the schedule to find start times let mut current_time: u64 = 0; diff --git a/src/rules/timetabledesign_ilp.rs b/src/rules/timetabledesign_ilp.rs index db2882ef4..8a7033f73 100644 --- a/src/rules/timetabledesign_ilp.rs +++ b/src/rules/timetabledesign_ilp.rs @@ -32,6 +32,8 @@ impl ReductionResult for ReductionTDToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/traits.rs b/src/rules/traits.rs index f6403f5e3..9465cc139 100644 --- a/src/rules/traits.rs +++ b/src/rules/traits.rs @@ -38,6 +38,34 @@ impl ExtractionError { pub type ExtractionResult = std::result::Result; +/// Validate that a target configuration matches its declared discrete space. +pub(crate) fn validate_target_solution( + target: &P, + solution: &[usize], +) -> ExtractionResult<()> { + let dims = target.dims(); + if solution.len() != dims.len() { + return Err(ExtractionError::invalid(format!( + "expected {} target values, got {}", + dims.len(), + solution.len() + ))); + } + + if let Some((index, (&value, &dimension))) = solution + .iter() + .zip(&dims) + .enumerate() + .find(|(_, (value, dimension))| value >= dimension) + { + return Err(ExtractionError::invalid(format!( + "target value {value} at position {index} is outside dimension {dimension}" + ))); + } + + Ok(()) +} + /// Result of reducing a source problem to a target problem. /// /// This trait encapsulates the target problem and provides methods @@ -157,6 +185,8 @@ impl ReductionResult for ReductionAutoCast { } fn extract_solution(&self, target_solution: &[usize]) -> ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution.to_vec()) } } diff --git a/src/rules/travelingsalesman_ilp.rs b/src/rules/travelingsalesman_ilp.rs index 022b946f6..308f786a2 100644 --- a/src/rules/travelingsalesman_ilp.rs +++ b/src/rules/travelingsalesman_ilp.rs @@ -8,6 +8,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::TravelingSalesman; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; @@ -21,13 +22,6 @@ pub struct ReductionTSPToILP { source_edges: Vec<(usize, usize)>, } -impl ReductionTSPToILP { - /// Variable index for x_{v,k}: vertex v at position k. - fn x_index(&self, v: usize, k: usize) -> usize { - v * self.num_vertices + k - } -} - impl ReductionResult for ReductionTSPToILP { type Source = TravelingSalesman; type Target = ILP; @@ -42,32 +36,28 @@ impl ReductionResult for ReductionTSPToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_vertices; - // Read tour: for each position k, find vertex v with x_{v,k} = 1 - let mut tour = vec![0usize; n]; - for k in 0..n { - for v in 0..n { - if target_solution[self.x_index(v, k)] == 1 { - tour[k] = v; - break; - } - } - } + let tour = one_hot_decode(target_solution, n, n, 0)?; // Map tour to edge selection let mut edge_selection = vec![0usize; self.source_edges.len()]; for k in 0..n { let u = tour[k]; let v = tour[(k + 1) % n]; - // Find the edge index for (u, v) or (v, u) - for (idx, &(a, b)) in self.source_edges.iter().enumerate() { - if (a == u && b == v) || (a == v && b == u) { - edge_selection[idx] = 1; - break; - } - } + let edge = 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] = 1; } edge_selection diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs index d61795290..20093c505 100644 --- a/src/rules/travelingsalesman_qubo.rs +++ b/src/rules/travelingsalesman_qubo.rs @@ -9,6 +9,7 @@ use crate::models::algebraic::QUBO; use crate::models::graph::TravelingSalesman; use crate::reduction; +use crate::rules::ilp_helpers::one_hot_decode; use crate::rules::traits::{ReduceTo, ReductionResult}; use crate::topology::{Graph, SimpleGraph}; use std::collections::HashMap; @@ -38,19 +39,12 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok({ let n = self.num_vertices; - // For each position p, find the vertex v where x_{v,p} == 1 - let mut tour = vec![0usize; n]; - for p in 0..n { - for v in 0..n { - if target_solution[v * n + p] == 1 { - tour[p] = v; - break; - } - } - } + let tour = one_hot_decode(target_solution, n, n, 0)?; // Build edge-based config: for each consecutive pair in the tour, mark the edge let mut config = vec![0usize; self.num_edges]; @@ -58,9 +52,12 @@ impl ReductionResult for ReductionTravelingSalesmanToQUBO { let u = tour[p]; let v = tour[(p + 1) % n]; let key = (u.min(v), u.max(v)); - if let Some(&idx) = self.edge_index.get(&key) { - config[idx] = 1; - } + 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] = 1; } config diff --git a/src/rules/undirectedflowlowerbounds_ilp.rs b/src/rules/undirectedflowlowerbounds_ilp.rs index 00b9afe3b..81b3d13a1 100644 --- a/src/rules/undirectedflowlowerbounds_ilp.rs +++ b/src/rules/undirectedflowlowerbounds_ilp.rs @@ -58,6 +58,8 @@ impl ReductionResult for ReductionUFLBToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_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 2521dcd13..5238299d8 100644 --- a/src/rules/undirectedtwocommodityintegralflow_ilp.rs +++ b/src/rules/undirectedtwocommodityintegralflow_ilp.rs @@ -55,6 +55,8 @@ impl ReductionResult for ReductionU2CIFToILP { &self, target_solution: &[usize], ) -> crate::rules::ExtractionResult> { + crate::rules::traits::validate_target_solution(self.target_problem(), target_solution)?; + Ok(target_solution[..4 * self.num_edges].to_vec()) } } diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 053ec6f23..6b7fdd95c 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -697,6 +697,29 @@ fn rule_specs_solution_pairs_are_consistent() { (extracted: {:?}, stored: {:?})", extracted_val, source_val, extracted, pair.source_config ); + + let mut wrong_length = pair.target_config.clone(); + if wrong_length.is_empty() { + wrong_length.push(0); + } else { + wrong_length.pop(); + } + assert!( + chain.extract_solution(&wrong_length).is_err(), + "Rule {label}: extraction accepted a target configuration with the wrong length" + ); + + let target_dims = target.dims_dyn(); + if let Some((&dimension, value)) = + target_dims.first().zip(pair.target_config.first()) + { + let mut out_of_domain = pair.target_config.clone(); + out_of_domain[0] = dimension; + assert!( + chain.extract_solution(&out_of_domain).is_err(), + "Rule {label}: extraction accepted out-of-domain value {dimension} in place of {value}" + ); + } } } } diff --git a/src/unit_tests/rules/ilp_helpers.rs b/src/unit_tests/rules/ilp_helpers.rs index 40eda271b..7e157ba08 100644 --- a/src/unit_tests/rules/ilp_helpers.rs +++ b/src/unit_tests/rules/ilp_helpers.rs @@ -126,7 +126,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); + let decoded = one_hot_decode(&solution, 3, 3, 0).unwrap(); assert_eq!(decoded, vec![1, 2, 0]); // slot 0 gets item 1, slot 1 gets item 2, slot 2 gets item 0 } @@ -137,10 +137,27 @@ 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); + let decoded = one_hot_decode(&solution, 3, 3, 5).unwrap(); 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(), + 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] fn test_permutation_to_lehmer() { // Identity permutation [0,1,2] -> Lehmer [0,0,0] diff --git a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs index 157e3ccf5..cfc642a1f 100644 --- a/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs +++ b/src/unit_tests/rules/ksatisfiability_acyclicpartition.rs @@ -25,6 +25,17 @@ fn test_ksatisfiability_to_acyclicpartition_closed_loop() { } } +#[test] +fn test_partition_to_acyclicpartition_rejects_malformed_target_configuration() { + let source = KSatisfiability::::new(1, vec![CNFClause::new(vec![1, 1, 1])]); + let reduction = ReduceTo::>::reduce_to(&source); + + assert!(reduction + .partition_to_acyclic + .extract_solution(&[]) + .is_err()); +} + #[test] fn test_ksatisfiability_to_acyclicpartition_unsatisfiable() { let source = KSatisfiability::::new( diff --git a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs index fe01aa0c9..36088f494 100644 --- a/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_quadraticcongruences.rs @@ -102,6 +102,15 @@ fn test_ksatisfiability_to_quadraticcongruences_extracts_assignment_from_constru ); } +#[test] +fn test_ksatisfiability_to_quadraticcongruences_rejects_missing_variable_signs() { + let source = yes_source(); + let reduction = ReduceTo::::reduce_to(&source); + let target_config = vec![0; reduction.target_problem().dims().len()]; + + assert!(reduction.extract_solution(&target_config).is_err()); +} + #[test] fn test_ksatisfiability_to_quadraticcongruences_closed_loop() { let source = KSatisfiability::::new(3, vec![CNFClause::new(vec![1, 2, -3])]); diff --git a/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs b/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs index 0dd22474b..a110ce603 100644 --- a/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs +++ b/src/unit_tests/rules/optimallineararrangement_consecutiveonesmatrixaugmentation.rs @@ -98,6 +98,7 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_edgeless_s let arrangement = reduction.extract_solution(&witness).unwrap(); assert_eq!(arrangement.len(), 3); assert_eq!(source.evaluate(&arrangement), Or(true)); + assert!(reduction.extract_solution(&[]).is_err()); } #[test] @@ -128,6 +129,7 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_negative_b BruteForce::new().find_witness(&source).is_none(), "P_6 has no arrangement of length <= 4" ); + assert!(reduction.extract_solution(&[]).is_err()); } #[test] @@ -140,7 +142,7 @@ fn test_optimallineararrangement_to_consecutiveonesmatrixaugmentation_extract_in .extract_solution(&[0, 1, 2]) .unwrap_err() .to_string(), - "expected a permutation of 6 columns, got 3 entries" + "expected 6 target values, got 3" ); assert_eq!( reduction diff --git a/src/unit_tests/rules/sat_minimumdominatingset.rs b/src/unit_tests/rules/sat_minimumdominatingset.rs index 824d2d3c9..0dc10fd3e 100644 --- a/src/unit_tests/rules/sat_minimumdominatingset.rs +++ b/src/unit_tests/rules/sat_minimumdominatingset.rs @@ -136,10 +136,38 @@ fn test_extract_solution_too_many_selected() { let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); let reduction = ReduceTo::>::reduce_to(&sat); - let ds_sol = vec![1, 1, 1, 1]; + let ds_sol = vec![1, 1, 0, 0]; assert_eq!( reduction.extract_solution(&ds_sol).unwrap_err().to_string(), - "selected 4 dominating-set vertices for 1 source variables" + "variable 0 gadget must select exactly one vertex, got 2" + ); +} + +#[test] +fn test_extract_solution_rejects_unselected_variable_gadget() { + let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); + let reduction = ReduceTo::>::reduce_to(&sat); + + assert_eq!( + reduction + .extract_solution(&[0, 0, 0, 0]) + .unwrap_err() + .to_string(), + "variable 0 gadget must select exactly one vertex, got 0" + ); +} + +#[test] +fn test_extract_solution_rejects_selected_clause_vertex() { + let sat = Satisfiability::new(1, vec![CNFClause::new(vec![1])]); + let reduction = ReduceTo::>::reduce_to(&sat); + + assert_eq!( + reduction + .extract_solution(&[1, 0, 0, 1]) + .unwrap_err() + .to_string(), + "clause vertex 0 is selected" ); } diff --git a/src/unit_tests/rules/satisfiability_naesatisfiability.rs b/src/unit_tests/rules/satisfiability_naesatisfiability.rs index 53c60a1ec..6d0964346 100644 --- a/src/unit_tests/rules/satisfiability_naesatisfiability.rs +++ b/src/unit_tests/rules/satisfiability_naesatisfiability.rs @@ -76,10 +76,7 @@ fn test_solution_extraction_distinguishes_zero_assignment_from_malformed_input() assert_eq!(reduction.extract_solution(&[0, 0, 0]).unwrap(), vec![0, 0]); let error = reduction.extract_solution(&[0, 0]).unwrap_err(); - assert_eq!( - error.to_string(), - "expected 3 values including the sentinel, got 2" - ); + assert_eq!(error.to_string(), "expected 3 target values, got 2"); assert!(reduction.extract_solution(&[0, 0, 0, 0]).is_err()); assert!(reduction.extract_solution(&[0, 2, 0]).is_err()); } diff --git a/src/unit_tests/rules/traits.rs b/src/unit_tests/rules/traits.rs index b26e3c30a..becdf7b29 100644 --- a/src/unit_tests/rules/traits.rs +++ b/src/unit_tests/rules/traits.rs @@ -4,8 +4,8 @@ fn test_traits_compile() { } use crate::rules::traits::{ - AggregateReductionResult, DynAggregateReductionResult, ReduceTo, ReduceToAggregate, - ReductionResult, + validate_target_solution, AggregateReductionResult, DynAggregateReductionResult, ReduceTo, + ReduceToAggregate, ReductionResult, }; use crate::traits::Problem; use crate::types::Sum; @@ -81,6 +81,16 @@ fn test_reduction() { assert_eq!(result.extract_solution(&[1, 0]).unwrap(), vec![1, 0]); } +#[test] +fn target_solution_validation_rejects_shape_and_domain_errors() { + let target = TargetProblem; + + assert!(validate_target_solution(&target, &[1, 0]).is_ok()); + assert!(validate_target_solution(&target, &[1]).is_err()); + assert!(validate_target_solution(&target, &[1, 0, 0]).is_err()); + assert!(validate_target_solution(&target, &[1, 2]).is_err()); +} + #[derive(Clone)] struct AggregateSourceProblem;