From 4f10a15781c95495176c25dc7e580af7eb505097 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 6 Apr 2026 23:11:33 +0800 Subject: [PATCH 01/18] docs: add Decision wrapper design spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-06-decision-wrapper-design.md | 341 ++++++ .../plans/2026-04-06-decision-wrapper-plan.md | 1073 +++++++++++++++++ 2 files changed, 1414 insertions(+) create mode 100644 docs/plans/2026-04-06-decision-wrapper-design.md create mode 100644 docs/plans/2026-04-06-decision-wrapper-plan.md diff --git a/docs/plans/2026-04-06-decision-wrapper-design.md b/docs/plans/2026-04-06-decision-wrapper-design.md new file mode 100644 index 00000000..27917424 --- /dev/null +++ b/docs/plans/2026-04-06-decision-wrapper-design.md @@ -0,0 +1,341 @@ +# Decision Wrapper Design Spec + +**Issue:** #998 +**Date:** 2026-04-06 +**Status:** Draft (revised after Codex review) + +## Motivation + +Many classical NP-completeness reductions (Garey & Johnson) operate between decision +versions of problems, but the codebase models are optimization problems. This blocks +reductions where the source is `Min` or `Max` but the target expects `Or`. + +Blocked rules (case A — optimization-to-decision only): +- #379: MinimumDominatingSet → MinMaxMulticenter +- #198: MinimumVertexCover → HamiltonianCircuit +- #894: MinimumVertexCover → PartialFeedbackEdgeSet + +Out of scope: case (B) cross-sense (`Max → Min`) and case (C) hidden-parameter +reductions from issue #998. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Scope | Case (A) only | Cases B and C are independent problems | +| Location | `src/models/decision.rs` (generic struct); concrete variants in optimization model files | Keeps decision + optimization together | +| Comparison sense | Inferred from `P::Value` via `OptimizationValue` trait | No invalid states; `Min` always means ≤, `Max` always means ≥ | +| NAME resolution | `DecisionProblemMeta` trait + `decision_problem_meta!` macro | `const_format::concatcp!` doesn't support generic associated consts; this avoids the dependency while keeping shared generic logic | +| Registry | Explicit `declare_variants!` per concrete type, in the optimization model file | Consistent with existing patterns | +| Naming | `"Decision"` prefix (e.g., `"DecisionMinimumVertexCover"`) | Clear, no collision | +| Hand-written decision models | Replace with `Decision

` | Eliminates duplication; `VertexCover` → `Decision` | +| Alias migration | Register `VertexCover`/`VC` as aliases on `DecisionMinimumVertexCover` schema entry | Preserves CLI/catalog backward compatibility | +| Opt→Decision reduction | `Decision

→ P` as `ReduceToAggregate` only | Solve inner optimization, compare to bound — fits one-shot aggregate model | +| Decision→Opt solver | Golden-section search utility in `src/solvers/` | Multi-query algorithm, not a `ReduceTo` edge; included for testing | +| Initial concrete types | `Decision>`, `Decision>` | Minimum to unblock #379, #198, #894 | + +## Architecture + +### 1. `OptimizationValue` Trait (`src/types.rs`) + +```rust +/// Trait for aggregate values that represent optimization objectives (Min or Max). +/// Enables generic conversion to decision problems via a bound parameter. +pub trait OptimizationValue: Aggregate { + /// The inner numeric type (e.g., `i32` for `Min`). + type Inner: Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned; + + /// Does this evaluation result satisfy the decision bound? + /// - For `Min`: true iff value is Some(v) where v ≤ bound + /// - For `Max`: true iff value is Some(v) where v ≥ bound + fn meets_bound(value: &Self, bound: &Self::Inner) -> bool; +} +``` + +Implementations for `Min` (checks `≤`) and `Max` (checks `≥`). +`Min(None)` and `Max(None)` (infeasible configs) always return `false`. + +### 2. `DecisionProblemMeta` Trait + `Decision

` Struct (`src/models/decision.rs`) + +`const_format::concatcp!` does not support generic associated consts (`P::NAME`). +Instead, use a metadata trait that each concrete inner problem implements: + +```rust +/// Metadata trait providing the decision problem name for each inner problem type. +pub trait DecisionProblemMeta: Problem +where + Self::Value: OptimizationValue, +{ + const DECISION_NAME: &'static str; +} + +/// Helper macro to register a concrete inner problem's decision name. +#[macro_export] +macro_rules! decision_problem_meta { + ($inner:ty, $name:literal) => { + impl crate::models::decision::DecisionProblemMeta for $inner { + const DECISION_NAME: &'static str = $name; + } + }; +} +``` + +The generic struct and `Problem` impl: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Decision +where + P::Value: OptimizationValue, +{ + inner: P, + bound: ::Inner, +} + +impl

Problem for Decision

+where + P: DecisionProblemMeta, + P::Value: OptimizationValue, +{ + const NAME: &'static str = P::DECISION_NAME; + type Value = Or; + + fn dims(&self) -> Vec { self.inner.dims() } + + fn evaluate(&self, config: &[usize]) -> Or { + Or(::meets_bound( + &self.inner.evaluate(config), + &self.bound, + )) + } + + fn variant() -> Vec<(&'static str, &'static str)> { P::variant() } +} +``` + +Accessor methods: `new()`, `inner()`, `bound()`. + +### 3. `Decision

→ P` Aggregate Reduction (`src/models/decision.rs`) + +Only the `Decision

→ P` direction is a valid reduction (solve inner optimization, +compare to bound). The reverse (bisection) is not a one-shot reduction — it requires +multiple adaptive queries and belongs as a separate solver utility (out of scope). + +```rust +#[derive(Debug, Clone)] +pub struct DecisionToOptimizationResult

+where + P: Problem, + P::Value: OptimizationValue, +{ + target: P, + bound: ::Inner, +} + +impl

AggregateReductionResult for DecisionToOptimizationResult

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

; + type Target = P; + + fn target_problem(&self) -> &P { &self.target } + + fn extract_value(&self, target_value: P::Value) -> Or { + Or(::meets_bound(&target_value, &self.bound)) + } +} + +impl

ReduceToAggregate

for Decision

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

; + + fn reduce_to_aggregate(&self) -> Self::Result { + DecisionToOptimizationResult { + target: self.inner().clone(), + bound: self.bound().clone(), + } + } +} +``` + +**Note:** `#[reduction]` currently only populates `reduce_fn` (witness), not +`reduce_aggregate_fn`. Concrete aggregate edges must be registered manually via +`inventory::submit!(ReductionEntry { ... })` in the optimization model files. + +### 4. Golden-Section Search Solver (`src/solvers/golden_section.rs`) + +Finds the optimal value of an optimization problem `P` by querying its decision +version `Decision

`. Uses the [golden-section search](https://en.wikipedia.org/wiki/Golden-section_search) +algorithm — not a reduction (it requires multiple adaptive queries), but a solver +utility that exercises the `Decision

` wrapper end-to-end. + +```rust +/// Solve an optimization problem by golden-section search on its decision version. +/// +/// Given an optimization problem P with Value = Min or Max, constructs +/// Decision

instances with varying bounds and narrows the search interval +/// using the golden ratio φ = (1 + √5) / 2. +/// +/// For Min: searches for the smallest bound where Decision

is satisfiable. +/// For Max: searches for the largest bound where Decision

is satisfiable. +pub fn solve_via_decision

(problem: &P, lower: V, upper: V) -> Option +where + P: DecisionProblemMeta + Clone, + P::Value: OptimizationValue, + V: /* numeric bounds */, +{ + // Golden ratio narrowing: + // 1. Evaluate Decision

at two interior probe points + // 2. Narrow interval based on which probe is feasible/infeasible + // 3. Repeat until convergence (for f64) or interval width ≤ 1 (for integers) +} +``` + +**Integer specialization:** For `V = i32` (discrete), golden-section search degrades +to a narrowing strategy that converges in O(log n) decision queries, where n is the +value range. Each query constructs a `Decision

` and solves it with `BruteForce`. + +**Purpose:** Primarily a testing utility — validates that `Decision

` correctly +wraps the optimization problem by recovering the same optimum that `BruteForce` +finds directly. Also demonstrates the decision↔optimization duality. + +### 5. Proc Macro: `extract_type_name()` Fix (`problemreductions-macros/src/lib.rs`) + + +The current `extract_type_name()` takes only the last path segment identifier. +For `Decision>`, it produces `"Decision"` — +losing the inner type. All `Decision<...>` variants would collapse to one name. + +**Fix:** Special-case `"Decision"` and recurse into the first type argument: + +```rust +fn extract_type_name(ty: &syn::Type) -> Option { + match ty { + syn::Type::Path(type_path) => { + let segment = type_path.path.segments.last()?; + let ident = segment.ident.to_string(); + + if ident == "Decision" { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return Some(ident); + }; + let inner_ty = args.args.iter().find_map(|arg| match arg { + syn::GenericArgument::Type(ty) => Some(ty), + _ => None, + })?; + let inner_name = extract_type_name(inner_ty)?; + return Some(format!("Decision{inner_name}")); + } + + Some(ident) + } + _ => None, + } +} +``` + +After this change: +- `MinimumVertexCover` → `"MinimumVertexCover"` (unchanged) +- `Decision>` → `"DecisionMinimumVertexCover"` + +This affects both `#[reduction]` name extraction and `declare_variants!` default grouping. + +### 5. Concrete Variants (in optimization model files) + +Each optimization model that needs a decision version adds to its own file: + +```rust +// In src/models/graph/minimum_vertex_cover.rs + +// --- Decision name registration --- +crate::decision_problem_meta!( + MinimumVertexCover, + "DecisionMinimumVertexCover" +); + +// --- Delegated getters for overhead expressions --- +impl Decision> { + pub fn num_vertices(&self) -> usize { self.inner().num_vertices() } + pub fn num_edges(&self) -> usize { self.inner().num_edges() } + pub fn k(&self) -> usize { self.bound() as usize } +} + +// --- Variant registration --- +declare_variants! { + default Decision> => "1.1996^num_vertices", +} + +// --- Manual aggregate reduction registration --- +// inventory::submit!(ReductionEntry { ... }) for Decision → MVC aggregate edge +``` + +Same pattern for `minimum_dominating_set.rs`. + +### 6. Migration: Remove Hand-Written Decision Models + +**Remove:** +- `src/models/graph/vertex_cover.rs` — replaced by `Decision` +- `src/unit_tests/models/graph/vertex_cover.rs` + +**Alias migration:** Register `VertexCover` and `VC` as aliases on the new +`DecisionMinimumVertexCover` `ProblemSchemaEntry`, so `pred show VertexCover` +and `pred show VC` continue to work. + +**No existing reductions reference `VertexCover`** — all reduction files use +`MinimumVertexCover` (the optimization version). Zero reduction file changes needed. + +**Additional files to update:** +- `src/models/graph/mod.rs` — remove `VertexCover` export +- `src/example_db/model_builders.rs` — update or remove VertexCover canonical example +- `src/example_db/specs.rs` — migrate VertexCover spec to DecisionMinimumVertexCover +- `docs/paper/reductions.typ` — update `problem-def("VertexCover")` entry and `display-name` dict + +### 7. Testing + +**Generic tests** in `src/unit_tests/models/decision.rs`: +1. `test_decision_min_creation` — construct, verify accessors +2. `test_decision_min_evaluate_feasible` — cost ≤ bound → `Or(true)` +3. `test_decision_min_evaluate_infeasible_cost` — cost > bound → `Or(false)` +4. `test_decision_min_evaluate_infeasible_config` — invalid config → `Or(false)` +5. `test_decision_max_evaluate` — ≥ bound semantics with a Max-valued problem +6. `test_decision_solver` — BruteForce witness recovery +7. `test_decision_serialization` — round-trip serde +8. `test_decision_dims` — delegates to inner + +**Golden-section search tests** in `src/unit_tests/solvers/golden_section.rs`: +9. `test_golden_section_min` — recover MinimumVertexCover optimum via Decision queries +10. `test_golden_section_max` — recover MaximumIndependentSet optimum via Decision queries +11. `test_golden_section_matches_brute_force` — verify golden-section result equals BruteForce result + +**Per-problem tests** in existing model test files gain decision variant coverage. + +## File Changes Summary + +| File | Change | +|------|--------| +| `src/types.rs` | Add `OptimizationValue` trait + `Min`/`Max` impls | +| `src/models/decision.rs` | **New** — `DecisionProblemMeta` trait, `decision_problem_meta!` macro, `Decision

` struct, generic `Problem` impl, `ReduceToAggregate` impl | +| `src/solvers/golden_section.rs` | **New** — golden-section search solver: finds optimum by querying `Decision

` | +| `src/models/mod.rs` | Add `pub mod decision` | +| `src/models/graph/minimum_vertex_cover.rs` | Add `decision_problem_meta!`, decision getters, `declare_variants!`, `ProblemSchemaEntry` (with VC/VertexCover aliases), manual aggregate `ReductionEntry` | +| `src/models/graph/minimum_dominating_set.rs` | Same pattern | +| `src/models/graph/vertex_cover.rs` | **Remove** | +| `src/models/graph/mod.rs` | Remove `VertexCover` export | +| `problemreductions-macros/src/lib.rs` | Fix `extract_type_name()` to recurse into `Decision` | +| `src/example_db/model_builders.rs` | Migrate VertexCover example → DecisionMinimumVertexCover | +| `src/example_db/specs.rs` | Migrate VertexCover spec | +| `docs/paper/reductions.typ` | Update VertexCover problem-def and display-name | +| `src/unit_tests/models/decision.rs` | **New** — generic decision tests | +| `src/unit_tests/models/graph/vertex_cover.rs` | **Remove** | + +## Not In Scope + +- Implementing blocked reductions (#379, #198, #894) — separate PRs +- Cases (B) cross-sense and (C) hidden-parameter from issue #998 +- CLI `pred create` support for Decision types +- `const_format` dependency (replaced by `DecisionProblemMeta` trait) diff --git a/docs/plans/2026-04-06-decision-wrapper-plan.md b/docs/plans/2026-04-06-decision-wrapper-plan.md new file mode 100644 index 00000000..e4814c7d --- /dev/null +++ b/docs/plans/2026-04-06-decision-wrapper-plan.md @@ -0,0 +1,1073 @@ +# Decision Wrapper Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a generic `Decision

` wrapper that converts optimization problems (`Min`/`Max`) to decision problems (`Or`), replace hand-written `VertexCover`, and provide a golden-section search solver. + +**Architecture:** `OptimizationValue` trait abstracts over `Min`/`Max`. `Decision

` is a generic struct with `Problem` impl delegated via `DecisionProblemMeta` trait. Concrete variants registered in optimization model files. Golden-section search solver recovers optima via decision queries. + +**Tech Stack:** Rust, `inventory` crate (existing), `serde` (existing), proc-macro (`syn`/`quote`) + +**Spec:** `docs/plans/2026-04-06-decision-wrapper-design.md` + +--- + +### Task 1: `OptimizationValue` Trait + +**Files:** +- Modify: `src/types.rs` (append after `Min` impls, ~line 299) + +- [ ] **Step 1: Write failing tests for `OptimizationValue`** + +Create `src/unit_tests/types_optimization_value.rs`: + +```rust +use crate::types::{Max, Min, OptimizationValue}; + +#[test] +fn test_min_meets_bound_feasible() { + // Min(Some(3)) <= 5 → true + assert!(Min::::meets_bound(&Min(Some(3)), &5)); +} + +#[test] +fn test_min_meets_bound_exact() { + // Min(Some(5)) <= 5 → true + assert!(Min::::meets_bound(&Min(Some(5)), &5)); +} + +#[test] +fn test_min_meets_bound_exceeds() { + // Min(Some(7)) <= 5 → false + assert!(!Min::::meets_bound(&Min(Some(7)), &5)); +} + +#[test] +fn test_min_meets_bound_infeasible() { + // Min(None) → false (infeasible config) + assert!(!Min::::meets_bound(&Min(None), &5)); +} + +#[test] +fn test_max_meets_bound_feasible() { + // Max(Some(7)) >= 5 → true + assert!(Max::::meets_bound(&Max(Some(7)), &5)); +} + +#[test] +fn test_max_meets_bound_exact() { + // Max(Some(5)) >= 5 → true + assert!(Max::::meets_bound(&Max(Some(5)), &5)); +} + +#[test] +fn test_max_meets_bound_below() { + // Max(Some(3)) >= 5 → false + assert!(!Max::::meets_bound(&Max(Some(3)), &5)); +} + +#[test] +fn test_max_meets_bound_infeasible() { + // Max(None) → false + assert!(!Max::::meets_bound(&Max(None), &5)); +} +``` + +Add test module link in `src/types.rs` at the bottom: + +```rust +#[cfg(test)] +#[path = "unit_tests/types_optimization_value.rs"] +mod optimization_value_tests; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test optimization_value -- --nocapture 2>&1 | head -30` +Expected: FAIL — `OptimizationValue` trait not found. + +- [ ] **Step 3: Implement `OptimizationValue` trait** + +Add to `src/types.rs` after the `Min` impl block (after line 299): + +```rust +/// Trait for aggregate values that represent optimization objectives (Min or Max). +/// Enables generic conversion to decision problems via a bound parameter. +pub trait OptimizationValue: Aggregate { + /// The inner numeric type (e.g., `i32` for `Min`). + type Inner: Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned; + + /// Does this evaluation result satisfy the decision bound? + /// - For `Min`: true iff value is Some(v) where v ≤ bound + /// - For `Max`: true iff value is Some(v) where v ≥ bound + fn meets_bound(value: &Self, bound: &Self::Inner) -> bool; +} + +impl OptimizationValue + for Min +{ + type Inner = V; + + fn meets_bound(value: &Self, bound: &V) -> bool { + matches!(&value.0, Some(v) if *v <= *bound) + } +} + +impl OptimizationValue + for Max +{ + type Inner = V; + + fn meets_bound(value: &Self, bound: &V) -> bool { + matches!(&value.0, Some(v) if *v >= *bound) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test optimization_value -- --nocapture` +Expected: All 8 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/types.rs src/unit_tests/types_optimization_value.rs +git commit -m "feat: add OptimizationValue trait for Min/Max decision conversion" +``` + +--- + +### Task 2: `Decision

` Struct + `Problem` Impl + +**Files:** +- Create: `src/models/decision.rs` +- Modify: `src/models/mod.rs` (add `pub mod decision`) + +- [ ] **Step 1: Write failing tests for `Decision

`** + +Create `src/unit_tests/models/decision.rs`: + +```rust +use crate::models::decision::Decision; +use crate::models::graph::MinimumVertexCover; +use crate::models::graph::MaximumIndependentSet; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::Or; + +fn triangle_mvc() -> MinimumVertexCover { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + MinimumVertexCover::new(graph, vec![1; 3]) +} + +#[test] +fn test_decision_min_creation() { + let mvc = triangle_mvc(); + let decision = Decision::new(mvc, 2); + assert_eq!(decision.bound(), &2); + assert_eq!(decision.inner().num_vertices(), 3); +} + +#[test] +fn test_decision_min_evaluate_feasible() { + let decision = Decision::new(triangle_mvc(), 2); + // Config [1,1,0]: covers all edges, cost=2 ≤ 2 → Or(true) + assert_eq!(decision.evaluate(&[1, 1, 0]), Or(true)); +} + +#[test] +fn test_decision_min_evaluate_infeasible_cost() { + let decision = Decision::new(triangle_mvc(), 1); + // Config [1,1,0]: covers all edges, cost=2 > 1 → Or(false) + assert_eq!(decision.evaluate(&[1, 1, 0]), Or(false)); +} + +#[test] +fn test_decision_min_evaluate_infeasible_config() { + let decision = Decision::new(triangle_mvc(), 3); + // Config [1,0,0]: does NOT cover edge (1,2) → Min(None) → Or(false) + assert_eq!(decision.evaluate(&[1, 0, 0]), Or(false)); +} + +#[test] +fn test_decision_max_evaluate() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let mis = MaximumIndependentSet::new(graph, vec![1; 4]); + let decision = Decision::new(mis, 2); + // Config [1,0,1,0]: independent, weight=2 ≥ 2 → Or(true) + assert_eq!(decision.evaluate(&[1, 0, 1, 0]), Or(true)); + // Config [1,0,0,0]: independent, weight=1 < 2 → Or(false) + assert_eq!(decision.evaluate(&[1, 0, 0, 0]), Or(false)); +} + +#[test] +fn test_decision_dims() { + let decision = Decision::new(triangle_mvc(), 2); + assert_eq!(decision.dims(), vec![2, 2, 2]); +} + +#[test] +fn test_decision_solver() { + use crate::solvers::BruteForce; + use crate::Solver; + let decision = Decision::new(triangle_mvc(), 2); + let solver = BruteForce::new(); + let witness = solver.find_witness(&decision); + assert!(witness.is_some()); + let config = witness.unwrap(); + // Verify: it's a valid cover with cost ≤ 2 + assert_eq!(decision.evaluate(&config), Or(true)); +} + +#[test] +fn test_decision_serialization() { + let decision = Decision::new(triangle_mvc(), 2); + let json = serde_json::to_string(&decision).unwrap(); + let deserialized: Decision> = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.bound(), &2); + assert_eq!(deserialized.evaluate(&[1, 1, 0]), Or(true)); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test test_decision_min -- --nocapture 2>&1 | head -20` +Expected: FAIL — `decision` module not found. + +- [ ] **Step 3: Create `src/models/decision.rs`** + +```rust +//! Generic Decision wrapper for optimization problems. +//! +//! Converts any optimization problem P (with Value = Min or Max) +//! into a decision problem (Value = Or) by adding a bound parameter. + +use crate::traits::Problem; +use crate::types::{OptimizationValue, Or}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Metadata trait providing the decision problem name for each inner problem type. +/// +/// Implement this for each concrete optimization problem that needs a Decision version. +/// Use the [`decision_problem_meta!`] macro for convenient registration. +pub trait DecisionProblemMeta: Problem +where + Self::Value: OptimizationValue, +{ + /// The NAME constant for the Decision problem type. + const DECISION_NAME: &'static str; +} + +/// Helper macro to register a concrete inner problem's decision name. +/// +/// # Example +/// ```ignore +/// crate::decision_problem_meta!( +/// MinimumVertexCover, +/// "DecisionMinimumVertexCover" +/// ); +/// ``` +#[macro_export] +macro_rules! decision_problem_meta { + ($inner:ty, $name:literal) => { + impl $crate::models::decision::DecisionProblemMeta for $inner { + const DECISION_NAME: &'static str = $name; + } + }; +} + +/// Decision version of an optimization problem. +/// +/// Given an optimization problem P with `Value = Min` or `Value = Max`, +/// `Decision

` asks: "does there exist a configuration with value ≤ bound (for Min) +/// or ≥ bound (for Max)?" +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Decision +where + P::Value: OptimizationValue, +{ + inner: P, + bound: ::Inner, +} + +impl Decision

+where + P::Value: OptimizationValue, +{ + /// Create a new Decision problem from an inner optimization problem and a bound. + pub fn new(inner: P, bound: ::Inner) -> Self { + Self { inner, bound } + } + + /// Get a reference to the inner optimization problem. + pub fn inner(&self) -> &P { + &self.inner + } + + /// Get a reference to the decision bound. + pub fn bound(&self) -> &::Inner { + &self.bound + } +} + +impl

Problem for Decision

+where + P: DecisionProblemMeta + Clone, + P::Value: OptimizationValue + + Clone + + fmt::Debug + + Serialize + + DeserializeOwned, + ::Inner: + Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned, +{ + const NAME: &'static str = P::DECISION_NAME; + type Value = Or; + + fn dims(&self) -> Vec { + self.inner.dims() + } + + fn evaluate(&self, config: &[usize]) -> Or { + Or(::meets_bound( + &self.inner.evaluate(config), + &self.bound, + )) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + P::variant() + } +} +``` + +- [ ] **Step 4: Add module to `src/models/mod.rs`** + +Add after `pub mod set;`: + +```rust +pub mod decision; +``` + +Add to the re-export block in `src/models/mod.rs`: + +```rust +pub use decision::Decision; +``` + +- [ ] **Step 5: Add `DecisionProblemMeta` impls for MVC and MIS** + +In `src/models/graph/minimum_vertex_cover.rs`, add after the `declare_variants!` block: + +```rust +crate::decision_problem_meta!( + MinimumVertexCover, + "DecisionMinimumVertexCover" +); +``` + +In `src/models/graph/maximum_independent_set.rs`, add after the `declare_variants!` block: + +```rust +crate::decision_problem_meta!( + MaximumIndependentSet, + "DecisionMaximumIndependentSet" +); +``` + +- [ ] **Step 6: Wire up test file** + +Add to the bottom of `src/models/decision.rs`: + +```rust +#[cfg(test)] +#[path = "../unit_tests/models/decision.rs"] +mod tests; +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cargo test test_decision -- --nocapture` +Expected: All 8 tests PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/models/decision.rs src/models/mod.rs src/unit_tests/models/decision.rs \ + src/models/graph/minimum_vertex_cover.rs src/models/graph/maximum_independent_set.rs +git commit -m "feat: add Decision

generic wrapper with Problem impl" +``` + +--- + +### Task 3: `ReduceToAggregate` for `Decision

→ P` + +**Files:** +- Modify: `src/models/decision.rs` (append aggregate reduction) + +- [ ] **Step 1: Write failing test** + +Add to `src/unit_tests/models/decision.rs`: + +```rust +#[test] +fn test_decision_reduce_to_aggregate() { + use crate::rules::ReduceToAggregate; + let mvc = triangle_mvc(); + let decision = Decision::new(mvc, 2); + let result = decision.reduce_to_aggregate(); + let target = result.target_problem(); + // Target is the inner MinimumVertexCover + assert_eq!(target.num_vertices(), 3); + + // Config [1,1,0] on target gives Min(Some(2)) + // extract_value maps Min(Some(2)) to Or(true) since 2 ≤ 2 + use crate::rules::AggregateReductionResult; + let target_val = target.evaluate(&[1, 1, 0]); + let source_val = result.extract_value(target_val); + assert_eq!(source_val, Or(true)); + + // Config [1,1,1] on target gives Min(Some(3)) + // extract_value maps Min(Some(3)) to Or(false) since 3 > 2 + let target_val = target.evaluate(&[1, 1, 1]); + let source_val = result.extract_value(target_val); + assert_eq!(source_val, Or(false)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test test_decision_reduce_to_aggregate -- --nocapture 2>&1 | head -20` +Expected: FAIL — `ReduceToAggregate` not implemented. + +- [ ] **Step 3: Implement aggregate reduction in `src/models/decision.rs`** + +Add after the `Problem` impl: + +```rust +use crate::rules::traits::{AggregateReductionResult, ReduceToAggregate}; + +/// Result of reducing Decision

to P (aggregate value extraction). +#[derive(Debug, Clone)] +pub struct DecisionToOptimizationResult

+where + P: Problem, + P::Value: OptimizationValue, +{ + target: P, + bound: ::Inner, +} + +impl

AggregateReductionResult for DecisionToOptimizationResult

+where + P: Problem + 'static, + P::Value: OptimizationValue + Serialize + DeserializeOwned, + ::Inner: Clone + PartialOrd, +{ + type Source = Decision

; + type Target = P; + + fn target_problem(&self) -> &P { + &self.target + } + + fn extract_value(&self, target_value: P::Value) -> Or { + Or(::meets_bound( + &target_value, + &self.bound, + )) + } +} + +impl

ReduceToAggregate

for Decision

+where + P: DecisionProblemMeta + Clone + 'static, + P::Value: OptimizationValue + Clone + fmt::Debug + Serialize + DeserializeOwned, + ::Inner: + Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned, +{ + type Result = DecisionToOptimizationResult

; + + fn reduce_to_aggregate(&self) -> Self::Result { + DecisionToOptimizationResult { + target: self.inner().clone(), + bound: self.bound().clone(), + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test test_decision -- --nocapture` +Expected: All 9 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/models/decision.rs src/unit_tests/models/decision.rs +git commit -m "feat: add ReduceToAggregate for Decision

→ P" +``` + +--- + +### Task 4: Proc Macro `extract_type_name()` Fix + +**Files:** +- Modify: `problemreductions-macros/src/lib.rs` (~line 131) + +- [ ] **Step 1: Fix `extract_type_name()` to handle `Decision`** + +Replace the existing `extract_type_name` function at line 131 of `problemreductions-macros/src/lib.rs`: + +```rust +/// Extract the base type name from a Type (e.g., "IndependentSet" from "IndependentSet"). +/// Special-cases "Decision" to produce "DecisionT" (e.g., "DecisionMinimumVertexCover"). +fn extract_type_name(ty: &Type) -> Option { + match ty { + Type::Path(type_path) => { + let segment = type_path.path.segments.last()?; + let ident = segment.ident.to_string(); + + if ident == "Decision" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + let inner_ty = args.args.iter().find_map(|arg| { + if let GenericArgument::Type(ty) = arg { + Some(ty) + } else { + None + } + })?; + let inner_name = extract_type_name(inner_ty)?; + return Some(format!("Decision{inner_name}")); + } + } + + Some(ident) + } + _ => None, + } +} +``` + +- [ ] **Step 2: Run existing tests to verify no regressions** + +Run: `cargo test -p problemreductions-macros` +Run: `make check` +Expected: All tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add problemreductions-macros/src/lib.rs +git commit -m "fix: extract_type_name handles Decision nested generics" +``` + +--- + +### Task 5: Concrete Decision Variants for MVC and MDS + +**Files:** +- Modify: `src/models/graph/minimum_vertex_cover.rs` +- Modify: `src/models/graph/minimum_dominating_set.rs` + +- [ ] **Step 1: Add Decision variant registration to `minimum_vertex_cover.rs`** + +Add after the existing `declare_variants!` block and the `decision_problem_meta!` (already added in Task 2): + +```rust +// Decision variant: delegates getters to inner problem +impl Decision> { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } + + /// Decision bound as usize (for overhead expressions). + pub fn k(&self) -> usize + where + W::Sum: Into, + { + let b: i64 = self.bound().clone().into(); + b as usize + } +} + +crate::declare_variants! { + default Decision> => "1.1996^num_vertices", +} + +inventory::submit! { + ProblemSchemaEntry { + name: "DecisionMinimumVertexCover", + display_name: "Decision Minimum Vertex Cover", + aliases: &["VertexCover", "VC"], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Decision version: does a vertex cover of cost ≤ bound exist?", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (max cost)" }, + ], + } +} +``` + +Add the necessary import at the top of the file: + +```rust +use crate::models::decision::{Decision, DecisionProblemMeta}; +``` + +- [ ] **Step 2: Add Decision variant registration to `minimum_dominating_set.rs`** + +Add `decision_problem_meta!` and variant registration (same pattern): + +```rust +use crate::models::decision::{Decision, DecisionProblemMeta}; + +crate::decision_problem_meta!( + MinimumDominatingSet, + "DecisionMinimumDominatingSet" +); + +impl Decision> { + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } + + pub fn k(&self) -> usize + where + W::Sum: Into, + { + let b: i64 = self.bound().clone().into(); + b as usize + } +} + +crate::declare_variants! { + default Decision> => "2^num_vertices", +} + +inventory::submit! { + ProblemSchemaEntry { + name: "DecisionMinimumDominatingSet", + display_name: "Decision Minimum Dominating Set", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Decision version: does a dominating set of cost ≤ bound exist?", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (max cost)" }, + ], + } +} +``` + +- [ ] **Step 3: Build and test** + +Run: `make check` +Expected: All tests and clippy PASS. The new `DecisionMinimumVertexCover` and `DecisionMinimumDominatingSet` appear in the registry. + +- [ ] **Step 4: Verify registry** + +Run: `cargo run --bin pred -- list | grep -i decision` +Expected: Shows `DecisionMinimumVertexCover` and `DecisionMinimumDominatingSet`. + +- [ ] **Step 5: Commit** + +```bash +git add src/models/graph/minimum_vertex_cover.rs src/models/graph/minimum_dominating_set.rs +git commit -m "feat: register Decision variants for MVC and MDS" +``` + +--- + +### Task 6: Remove Hand-Written `VertexCover` + +**Files:** +- Remove: `src/models/graph/vertex_cover.rs` +- Remove: `src/unit_tests/models/graph/vertex_cover.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` + +- [ ] **Step 1: Remove `VertexCover` from `src/models/graph/mod.rs`** + +Remove the `pub mod vertex_cover;` line and the `pub use vertex_cover::VertexCover;` re-export. + +- [ ] **Step 2: Remove `VertexCover` from `src/models/mod.rs` re-exports** + +Remove `VertexCover,` from the `pub use graph::{...}` block. + +- [ ] **Step 3: Delete the files** + +```bash +rm src/models/graph/vertex_cover.rs +rm src/unit_tests/models/graph/vertex_cover.rs +``` + +- [ ] **Step 4: Fix any remaining references** + +Run: `cargo build 2>&1 | head -40` + +If there are compile errors from other files referencing `VertexCover`, update them to use `Decision>` instead. Based on our research, no reduction files reference `VertexCover` directly, so this should compile cleanly. + +- [ ] **Step 5: Run full test suite** + +Run: `make check` +Expected: PASS. The alias `VertexCover`/`VC` now resolves to `DecisionMinimumVertexCover` via the schema entry. + +- [ ] **Step 6: Commit** + +```bash +git add -A # stages removals + modifications +git commit -m "refactor: remove hand-written VertexCover, replaced by Decision" +``` + +--- + +### Task 7: Golden-Section Search Solver + +**Files:** +- Create: `src/solvers/golden_section.rs` +- Modify: `src/solvers/mod.rs` + +- [ ] **Step 1: Write failing tests** + +Create `src/unit_tests/solvers/golden_section.rs`: + +```rust +use crate::models::decision::Decision; +use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; +use crate::solvers::golden_section::solve_via_decision; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::Aggregate; +use crate::Solver; + +#[test] +fn test_golden_section_min() { + // Path graph 0-1-2: MVC optimum = 1 (just vertex 1) + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumVertexCover::new(graph, vec![1i32; 3]); + let result = solve_via_decision(&problem, 0, 3); + assert_eq!(result, Some(1)); +} + +#[test] +fn test_golden_section_max() { + // Path graph 0-1-2: MIS optimum = 2 (vertices 0 and 2) + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); + let result = solve_via_decision(&problem, 0, 3); + assert_eq!(result, Some(2)); +} + +#[test] +fn test_golden_section_matches_brute_force() { + // Pentagon graph: compare golden-section vs brute-force + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); + let problem = MinimumVertexCover::new(graph, vec![1i32; 5]); + + let solver = BruteForce::new(); + let bf_value = solver.solve(&problem); + + let gs_value = solve_via_decision(&problem, 0, 5); + assert_eq!(gs_value, bf_value.size().copied()); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test test_golden_section -- --nocapture 2>&1 | head -20` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement golden-section search solver** + +Create `src/solvers/golden_section.rs`: + +```rust +//! Golden-section search solver for optimization via decision queries. +//! +//! Given an optimization problem P, finds the optimal value by querying +//! Decision

with varying bounds using the golden ratio. +//! +//! Reference: + +use crate::models::decision::{Decision, DecisionProblemMeta}; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::{Aggregate, OptimizationValue, Or, Min, Max}; +use crate::Solver; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::fmt; + +/// Whether a decision problem is satisfiable (any config evaluates to Or(true)). +fn is_satisfiable

(problem: &P) -> bool +where + P: Problem, + Or: Aggregate, +{ + let solver = BruteForce::new(); + let value = solver.solve(problem); + value.0 +} + +/// Solve a Min-valued optimization problem by golden-section search on its +/// decision version. Returns the minimum feasible value. +/// +/// Searches the integer range [lower, upper] for the smallest bound where +/// Decision

is satisfiable. +pub fn solve_via_decision_min

(problem: &P, lower: i32, upper: i32) -> Option +where + P: DecisionProblemMeta + Clone, + P::Value: OptimizationValue + + Clone + fmt::Debug + Serialize + DeserializeOwned, +{ + // First check if any solution exists at all + let decision_upper = Decision::new(problem.clone(), upper); + if !is_satisfiable(&decision_upper) { + return None; + } + + // Binary search for the minimum feasible bound + let mut lo = lower; + let mut hi = upper; + while lo < hi { + let mid = lo + (hi - lo) / 2; + let decision = Decision::new(problem.clone(), mid); + if is_satisfiable(&decision) { + hi = mid; + } else { + lo = mid + 1; + } + } + Some(lo) +} + +/// Solve a Max-valued optimization problem by golden-section search on its +/// decision version. Returns the maximum feasible value. +/// +/// Searches the integer range [lower, upper] for the largest bound where +/// Decision

is satisfiable. +pub fn solve_via_decision_max

(problem: &P, lower: i32, upper: i32) -> Option +where + P: DecisionProblemMeta + Clone, + P::Value: OptimizationValue + + Clone + fmt::Debug + Serialize + DeserializeOwned, +{ + // First check if any solution exists at all + let decision_lower = Decision::new(problem.clone(), lower); + if !is_satisfiable(&decision_lower) { + // For Max: if not satisfiable at lowest bound, check higher + let decision_upper = Decision::new(problem.clone(), upper); + if !is_satisfiable(&decision_upper) { + return None; + } + } + + // Binary search for the maximum feasible bound + let mut lo = lower; + let mut hi = upper; + while lo < hi { + let mid = lo + (hi - lo + 1) / 2; // round up to avoid infinite loop + let decision = Decision::new(problem.clone(), mid); + if is_satisfiable(&decision) { + lo = mid; + } else { + hi = mid - 1; + } + } + + // Verify the found bound is actually feasible + let decision = Decision::new(problem.clone(), lo); + if is_satisfiable(&decision) { + Some(lo) + } else { + None + } +} + +/// Solve an optimization problem by searching its decision version. +/// +/// Dispatches to `solve_via_decision_min` or `solve_via_decision_max` based on +/// the problem's value type. +pub fn solve_via_decision

(problem: &P, lower: i32, upper: i32) -> Option +where + P: DecisionProblemMeta + Clone, + P::Value: OptimizationValue + + Clone + fmt::Debug + Serialize + DeserializeOwned, +{ + // Check if this is a Min or Max problem by evaluating identity properties + // We use a type-based dispatch via the OptimizationValue trait + solve_via_decision_dispatch::

(problem, lower, upper) +} + +/// Internal dispatch — determines Min vs Max at compile time. +fn solve_via_decision_dispatch

(problem: &P, lower: i32, upper: i32) -> Option +where + P: DecisionProblemMeta + Clone, + P::Value: OptimizationValue + + Clone + fmt::Debug + Serialize + DeserializeOwned, +{ + // Try Min direction: search for smallest feasible bound + // If Decision

at upper bound is satisfiable, it's a Min problem + let decision_upper = Decision::new(problem.clone(), upper); + if is_satisfiable(&decision_upper) { + // Could be Min (satisfiable at upper bound) — search down + let decision_lower = Decision::new(problem.clone(), lower); + if !is_satisfiable(&decision_lower) { + // Definitely Min: satisfiable at upper, not at lower + return solve_via_decision_min(problem, lower, upper); + } + // Satisfiable at both bounds — return lower for Min + return solve_via_decision_min(problem, lower, upper); + } + + // Not satisfiable at upper — try Max direction + solve_via_decision_max(problem, lower, upper) +} +``` + +- [ ] **Step 4: Wire up module in `src/solvers/mod.rs`** + +Add after the `pub use customized::CustomizedSolver;` line: + +```rust +pub mod golden_section; +``` + +- [ ] **Step 5: Add test module link in `src/solvers/golden_section.rs`** + +At the bottom of the file: + +```rust +#[cfg(test)] +#[path = "../unit_tests/solvers/golden_section.rs"] +mod tests; +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cargo test test_golden_section -- --nocapture` +Expected: All 3 tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/solvers/golden_section.rs src/solvers/mod.rs \ + src/unit_tests/solvers/golden_section.rs +git commit -m "feat: add golden-section search solver via Decision queries" +``` + +--- + +### Task 8: Paper and Example DB Migration + +**Files:** +- Modify: `docs/paper/reductions.typ` (lines 237, 652-667) +- Modify: `src/models/graph/minimum_vertex_cover.rs` (example_db spec) + +- [ ] **Step 1: Update paper display-name dict** + +In `docs/paper/reductions.typ`, change line 237: + +From: `"VertexCover": [Vertex Cover],` +To: `"DecisionMinimumVertexCover": [Decision Minimum Vertex Cover],` + +- [ ] **Step 2: Update paper problem-def for VertexCover** + +Replace the `VertexCover` problem-def block (around lines 652-670) to reference `DecisionMinimumVertexCover`. Update the `load-model-example` call and `problem-def` name accordingly. + +- [ ] **Step 3: Add canonical example for DecisionMinimumVertexCover** + +In `src/models/graph/minimum_vertex_cover.rs`, update the `canonical_model_example_specs()` to include a Decision variant example: + +```rust +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "decision_minimum_vertex_cover_simplegraph_i32", + instance: Box::new(Decision::new( + MinimumVertexCover::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), + vec![1i32; 4], + ), + 2, + )), + optimal_config: vec![1, 0, 1, 0], + optimal_value: serde_json::json!(true), + }] +} +``` + +Wire this into the example_db collection system following the existing pattern. + +- [ ] **Step 4: Build and test** + +Run: `make check` +Run: `make paper` (if Typst is available) +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add docs/paper/reductions.typ src/models/graph/minimum_vertex_cover.rs +git commit -m "docs: migrate VertexCover paper entry and example_db to DecisionMinimumVertexCover" +``` + +--- + +### Task 9: Final Integration Check + +- [ ] **Step 1: Run full test suite** + +Run: `make check` +Expected: All tests, clippy, and fmt pass. + +- [ ] **Step 2: Run coverage check** + +Run: `make coverage` +Expected: >95% coverage on new code. + +- [ ] **Step 3: Verify CLI alias backward compatibility** + +Run: `cargo run --bin pred -- show VertexCover` +Run: `cargo run --bin pred -- show VC` +Expected: Both resolve to `DecisionMinimumVertexCover`. + +- [ ] **Step 4: Verify new problems appear in catalog** + +Run: `cargo run --bin pred -- list | grep -i decision` +Expected: Shows both `DecisionMinimumVertexCover` and `DecisionMinimumDominatingSet`. + +- [ ] **Step 5: Final commit if any fixups needed** + +```bash +git add -A +git commit -m "chore: integration fixups for Decision wrapper" +``` From 7468899a4bae5f3bc4948e3e0e6b75f9b0dcbb2a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 6 Apr 2026 23:20:38 +0800 Subject: [PATCH 02/18] feat: add OptimizationValue trait for Min/Max decision conversion --- src/types.rs | 29 +++++++++++++++ src/unit_tests/types_optimization_value.rs | 41 ++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/unit_tests/types_optimization_value.rs diff --git a/src/types.rs b/src/types.rs index 4d14f6ca..3ff8089d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -298,6 +298,31 @@ impl Min { } } +/// Trait for aggregate values that represent optimization objectives. +pub trait OptimizationValue: Aggregate { + /// The inner numeric type used for comparisons with decision bounds. + type Inner: Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned; + + /// Whether this aggregate value satisfies the provided decision bound. + fn meets_bound(value: &Self, bound: &Self::Inner) -> bool; +} + +impl OptimizationValue for Min { + type Inner = V; + + fn meets_bound(value: &Self, bound: &V) -> bool { + matches!(&value.0, Some(v) if *v <= *bound) + } +} + +impl OptimizationValue for Max { + type Inner = V; + + fn meets_bound(value: &Self, bound: &V) -> bool { + matches!(&value.0, Some(v) if *v >= *bound) + } +} + /// Sum aggregate for value-only problems. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Sum(pub W); @@ -557,3 +582,7 @@ impl_variant_param!(One, "weight", parent: i32, cast: |_| 1i32); #[cfg(test)] #[path = "unit_tests/types.rs"] mod tests; + +#[cfg(test)] +#[path = "unit_tests/types_optimization_value.rs"] +mod optimization_value_tests; diff --git a/src/unit_tests/types_optimization_value.rs b/src/unit_tests/types_optimization_value.rs new file mode 100644 index 00000000..2c4a6900 --- /dev/null +++ b/src/unit_tests/types_optimization_value.rs @@ -0,0 +1,41 @@ +use crate::types::{Max, Min, OptimizationValue}; + +#[test] +fn test_min_meets_bound_feasible() { + assert!(Min::::meets_bound(&Min(Some(3)), &5)); +} + +#[test] +fn test_min_meets_bound_exact() { + assert!(Min::::meets_bound(&Min(Some(5)), &5)); +} + +#[test] +fn test_min_meets_bound_exceeds() { + assert!(!Min::::meets_bound(&Min(Some(7)), &5)); +} + +#[test] +fn test_min_meets_bound_infeasible() { + assert!(!Min::::meets_bound(&Min(None), &5)); +} + +#[test] +fn test_max_meets_bound_feasible() { + assert!(Max::::meets_bound(&Max(Some(7)), &5)); +} + +#[test] +fn test_max_meets_bound_exact() { + assert!(Max::::meets_bound(&Max(Some(5)), &5)); +} + +#[test] +fn test_max_meets_bound_below() { + assert!(!Max::::meets_bound(&Max(Some(3)), &5)); +} + +#[test] +fn test_max_meets_bound_infeasible() { + assert!(!Max::::meets_bound(&Max(None), &5)); +} From b46fa3bc5234c0570c00c979a2bcab104c850ab0 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 6 Apr 2026 23:24:25 +0800 Subject: [PATCH 03/18] feat: add Decision

generic wrapper with Problem impl Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/decision.rs | 82 +++++++++++++++++++++ src/models/graph/maximum_independent_set.rs | 5 ++ src/models/graph/minimum_vertex_cover.rs | 5 ++ src/models/mod.rs | 2 + src/unit_tests/models/decision.rs | 72 ++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 src/models/decision.rs create mode 100644 src/unit_tests/models/decision.rs diff --git a/src/models/decision.rs b/src/models/decision.rs new file mode 100644 index 00000000..e1273326 --- /dev/null +++ b/src/models/decision.rs @@ -0,0 +1,82 @@ +//! Generic decision wrapper for optimization problems. + +use crate::traits::Problem; +use crate::types::{OptimizationValue, Or}; +use serde::{Deserialize, Serialize}; + +/// Metadata for concrete optimization problems that expose a decision wrapper. +pub trait DecisionProblemMeta: Problem +where + Self::Value: OptimizationValue, +{ + /// Problem name used by the corresponding `Decision` variant. + const DECISION_NAME: &'static str; +} + +/// Register the decision problem name for a concrete optimization problem. +#[macro_export] +macro_rules! decision_problem_meta { + ($inner:ty, $name:literal) => { + impl $crate::models::decision::DecisionProblemMeta for $inner { + const DECISION_NAME: &'static str = $name; + } + }; +} + +/// Decision version of an optimization problem with a fixed objective bound. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Decision +where + P::Value: OptimizationValue, +{ + inner: P, + bound: ::Inner, +} + +impl Decision

+where + P::Value: OptimizationValue, +{ + /// Create a decision wrapper around `inner` with the provided bound. + pub fn new(inner: P, bound: ::Inner) -> Self { + Self { inner, bound } + } + + /// Borrow the wrapped optimization problem. + pub fn inner(&self) -> &P { + &self.inner + } + + /// Borrow the decision bound. + pub fn bound(&self) -> &::Inner { + &self.bound + } +} + +impl

Problem for Decision

+where + P: DecisionProblemMeta, + P::Value: OptimizationValue, +{ + const NAME: &'static str = P::DECISION_NAME; + type Value = Or; + + fn dims(&self) -> Vec { + self.inner.dims() + } + + fn evaluate(&self, config: &[usize]) -> Or { + Or(::meets_bound( + &self.inner.evaluate(config), + &self.bound, + )) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + P::variant() + } +} + +#[cfg(test)] +#[path = "../unit_tests/models/decision.rs"] +mod tests; diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index f72ab367..6568057d 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -163,6 +163,11 @@ crate::declare_variants! { MaximumIndependentSet => "2^sqrt(num_vertices)", } +crate::decision_problem_meta!( + MaximumIndependentSet, + "DecisionMaximumIndependentSet" +); + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![ diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index d10a9eac..fcc035f1 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -155,6 +155,11 @@ crate::declare_variants! { MinimumVertexCover => "1.1996^num_vertices", } +crate::decision_problem_meta!( + MinimumVertexCover, + "DecisionMinimumVertexCover" +); + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { diff --git a/src/models/mod.rs b/src/models/mod.rs index b072878a..249a0e94 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -3,6 +3,7 @@ //! Each sub-module groups related problem types by input structure. pub mod algebraic; +pub mod decision; pub mod formula; pub mod graph; pub mod misc; @@ -17,6 +18,7 @@ pub use algebraic::{ QuadraticDiophantineEquations, SimultaneousIncongruences, SparseMatrixCompression, BMF, ILP, QUBO, }; +pub use decision::Decision; pub use formula::{ CNFClause, CircuitSAT, KSatisfiability, Maximum2Satisfiability, NAESatisfiability, NonTautology, OneInThreeSatisfiability, Planar3Satisfiability, QuantifiedBooleanFormulas, diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs new file mode 100644 index 00000000..124870ff --- /dev/null +++ b/src/unit_tests/models/decision.rs @@ -0,0 +1,72 @@ +use crate::models::decision::Decision; +use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::Or; + +fn triangle_mvc() -> MinimumVertexCover { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + MinimumVertexCover::new(graph, vec![1; 3]) +} + +#[test] +fn test_decision_min_creation() { + let mvc = triangle_mvc(); + let decision = Decision::new(mvc, 2); + assert_eq!(decision.bound(), &2); + assert_eq!(decision.inner().num_vertices(), 3); +} + +#[test] +fn test_decision_min_evaluate_feasible() { + let decision = Decision::new(triangle_mvc(), 2); + assert_eq!(decision.evaluate(&[1, 1, 0]), Or(true)); +} + +#[test] +fn test_decision_min_evaluate_infeasible_cost() { + let decision = Decision::new(triangle_mvc(), 1); + assert_eq!(decision.evaluate(&[1, 1, 0]), Or(false)); +} + +#[test] +fn test_decision_min_evaluate_infeasible_config() { + let decision = Decision::new(triangle_mvc(), 3); + assert_eq!(decision.evaluate(&[1, 0, 0]), Or(false)); +} + +#[test] +fn test_decision_max_evaluate() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); + let mis = MaximumIndependentSet::new(graph, vec![1; 4]); + let decision = Decision::new(mis, 2); + assert_eq!(decision.evaluate(&[1, 0, 1, 0]), Or(true)); + assert_eq!(decision.evaluate(&[1, 0, 0, 0]), Or(false)); +} + +#[test] +fn test_decision_dims() { + let decision = Decision::new(triangle_mvc(), 2); + assert_eq!(decision.dims(), vec![2, 2, 2]); +} + +#[test] +fn test_decision_solver() { + let decision = Decision::new(triangle_mvc(), 2); + let solver = BruteForce::new(); + let witness = solver.find_witness(&decision); + assert!(witness.is_some()); + let config = witness.unwrap(); + assert_eq!(decision.evaluate(&config), Or(true)); +} + +#[test] +fn test_decision_serialization() { + let decision = Decision::new(triangle_mvc(), 2); + let json = serde_json::to_string(&decision).unwrap(); + let deserialized: Decision> = + serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.bound(), &2); + assert_eq!(deserialized.evaluate(&[1, 1, 0]), Or(true)); +} From 63abe4e49ab4dab90e99194e9a92506434fd133b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 6 Apr 2026 23:41:49 +0800 Subject: [PATCH 04/18] feat: add ReduceToAggregate

impl for Decision

Implements the aggregate reduction from Decision

to P that extracts the optimization value by comparing against the threshold. This is Task 3 of the decision-wrapper plan. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/decision.rs | 48 +++++++++++++++++++++++++++++++ src/unit_tests/models/decision.rs | 18 ++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/models/decision.rs b/src/models/decision.rs index e1273326..3aa83014 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -1,7 +1,9 @@ //! Generic decision wrapper for optimization problems. +use crate::rules::{AggregateReductionResult, ReduceToAggregate}; use crate::traits::Problem; use crate::types::{OptimizationValue, Or}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; /// Metadata for concrete optimization problems that expose a decision wrapper. @@ -77,6 +79,52 @@ where } } +/// Aggregate reduction result for `Decision

-> P`. +#[derive(Debug, Clone)] +pub struct DecisionToOptimizationResult

+where + P: Problem, + P::Value: OptimizationValue, +{ + target: P, + bound: ::Inner, +} + +impl

AggregateReductionResult for DecisionToOptimizationResult

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

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

ReduceToAggregate

for Decision

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

; + + fn reduce_to_aggregate(&self) -> Self::Result { + DecisionToOptimizationResult { + target: self.inner.clone(), + bound: self.bound.clone(), + } + } +} + #[cfg(test)] #[path = "../unit_tests/models/decision.rs"] mod tests; diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs index 124870ff..af19b1d0 100644 --- a/src/unit_tests/models/decision.rs +++ b/src/unit_tests/models/decision.rs @@ -70,3 +70,21 @@ fn test_decision_serialization() { assert_eq!(deserialized.bound(), &2); assert_eq!(deserialized.evaluate(&[1, 1, 0]), Or(true)); } + +#[test] +fn test_decision_reduce_to_aggregate() { + use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + + let decision = Decision::new(triangle_mvc(), 2); + let result = decision.reduce_to_aggregate(); + let target = result.target_problem(); + assert_eq!(target.num_vertices(), 3); + + let target_val = target.evaluate(&[1, 1, 0]); + let source_val = result.extract_value(target_val); + assert_eq!(source_val, Or(true)); + + let target_val = target.evaluate(&[1, 1, 1]); + let source_val = result.extract_value(target_val); + assert_eq!(source_val, Or(false)); +} From 52ffb59f61aa7f8353e154a0178142f26f1e0953 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 6 Apr 2026 23:50:18 +0800 Subject: [PATCH 05/18] fix: extract_type_name handles Decision nested generics --- problemreductions-macros/src/lib.rs | 37 +++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index e8298328..8cdcda05 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -127,12 +127,26 @@ fn parse_overhead_content(content: syn::parse::ParseStream) -> syn::Result") +/// Extract the base type name from a Type (e.g., "IndependentSet" from "IndependentSet"). +/// Special-cases `Decision` to produce `DecisionT`. fn extract_type_name(ty: &Type) -> Option { match ty { Type::Path(type_path) => { let segment = type_path.path.segments.last()?; - Some(segment.ident.to_string()) + let ident = segment.ident.to_string(); + + if ident == "Decision" { + if let PathArguments::AngleBracketed(args) = &segment.arguments { + let inner_ty = args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(ty) => Some(ty), + _ => None, + })?; + let inner_name = extract_type_name(inner_ty)?; + return Some(format!("Decision{inner_name}")); + } + } + + Some(ident) } _ => None, } @@ -652,6 +666,25 @@ fn generate_complexity_eval_fn( #[cfg(test)] mod tests { use super::*; + use syn::{parse_str, Type}; + + #[test] + fn extract_type_name_strips_non_decision_generics() { + let ty: Type = parse_str("MinimumVertexCover").unwrap(); + assert_eq!( + extract_type_name(&ty).as_deref(), + Some("MinimumVertexCover") + ); + } + + #[test] + fn extract_type_name_unwraps_decision_inner_type() { + let ty: Type = parse_str("Decision>").unwrap(); + assert_eq!( + extract_type_name(&ty).as_deref(), + Some("DecisionMinimumVertexCover") + ); + } #[test] fn declare_variants_accepts_single_default() { From 7436969fa12d302b134e9999cd418f74de0e9b0f Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 00:02:23 +0800 Subject: [PATCH 06/18] feat: register Decision variants for MVC and MDS --- src/models/graph/minimum_dominating_set.rs | 87 +++++++++++++++++++++- src/models/graph/minimum_vertex_cover.rs | 82 +++++++++++++++++++- src/unit_tests/reduction_graph.rs | 32 ++++++++ src/unit_tests/registry/schema.rs | 40 ++++++++++ 4 files changed, 239 insertions(+), 2 deletions(-) diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index 8b3f69e5..0e8bc5c9 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -3,10 +3,12 @@ //! The Dominating Set problem asks for a minimum weight subset of vertices //! such that every vertex is either in the set or adjacent to a vertex in the set. +use crate::models::decision::Decision; use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::rules::{EdgeCapabilities, ReduceToAggregate, ReductionEntry, ReductionOverhead}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, WeightElement}; +use crate::types::{Min, ProblemSize, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -167,6 +169,89 @@ crate::declare_variants! { default MinimumDominatingSet => "1.4969^num_vertices", } +crate::decision_problem_meta!( + MinimumDominatingSet, + "DecisionMinimumDominatingSet" +); + +impl Decision> { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } + + /// Decision bound as a nonnegative integer. + pub fn k(&self) -> usize { + (*self.bound()).try_into().unwrap_or(0) + } +} + +crate::declare_variants! { + default Decision> => "1.4969^num_vertices", +} + +inventory::submit! { + ProblemSchemaEntry { + name: "DecisionMinimumDominatingSet", + display_name: "Decision Minimum Dominating Set", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Decision version: does a dominating set of cost <= bound exist?", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (maximum allowed dominating-set cost)" }, + ], + } +} + +inventory::submit! { + ReductionEntry { + source_name: "DecisionMinimumDominatingSet", + target_name: "MinimumDominatingSet", + source_variant_fn: > as Problem>::variant, + target_variant_fn: as Problem>::variant, + overhead_fn: || ReductionOverhead::identity(&["num_vertices", "num_edges"]), + module_path: module_path!(), + reduce_fn: None, + reduce_aggregate_fn: Some(|any| { + let source = any + .downcast_ref::>>() + .expect("DecisionMinimumDominatingSet aggregate reduction source type mismatch"); + Box::new(source.reduce_to_aggregate()) + }), + capabilities: EdgeCapabilities::aggregate_only(), + overhead_eval_fn: |any| { + let source = any + .downcast_ref::>>() + .expect("DecisionMinimumDominatingSet overhead source type mismatch"); + ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ]) + }, + source_size_fn: |any| { + let source = any + .downcast_ref::>>() + .expect("DecisionMinimumDominatingSet size source type mismatch"); + ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ("k", source.k()), + ]) + }, + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index fcc035f1..acc80a36 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -3,10 +3,12 @@ //! The Vertex Cover problem asks for a minimum weight subset of vertices //! such that every edge has at least one endpoint in the subset. +use crate::models::decision::Decision; use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::rules::{EdgeCapabilities, ReduceToAggregate, ReductionEntry, ReductionOverhead}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, One, WeightElement}; +use crate::types::{Min, One, ProblemSize, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; @@ -160,6 +162,84 @@ crate::decision_problem_meta!( "DecisionMinimumVertexCover" ); +impl Decision> { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } + + /// Decision bound as a nonnegative integer. + pub fn k(&self) -> usize { + (*self.bound()).try_into().unwrap_or(0) + } +} + +crate::declare_variants! { + default Decision> => "1.1996^num_vertices", +} + +inventory::submit! { + ProblemSchemaEntry { + name: "DecisionMinimumVertexCover", + display_name: "Decision Minimum Vertex Cover", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Decision version: does a vertex cover of cost <= bound exist?", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (maximum allowed cover cost)" }, + ], + } +} + +inventory::submit! { + ReductionEntry { + source_name: "DecisionMinimumVertexCover", + target_name: "MinimumVertexCover", + source_variant_fn: > as Problem>::variant, + target_variant_fn: as Problem>::variant, + overhead_fn: || ReductionOverhead::identity(&["num_vertices", "num_edges"]), + module_path: module_path!(), + reduce_fn: None, + reduce_aggregate_fn: Some(|any| { + let source = any + .downcast_ref::>>() + .expect("DecisionMinimumVertexCover aggregate reduction source type mismatch"); + Box::new(source.reduce_to_aggregate()) + }), + capabilities: EdgeCapabilities::aggregate_only(), + overhead_eval_fn: |any| { + let source = any + .downcast_ref::>>() + .expect("DecisionMinimumVertexCover overhead source type mismatch"); + ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ]) + }, + source_size_fn: |any| { + let source = any + .downcast_ref::>>() + .expect("DecisionMinimumVertexCover size source type mismatch"); + ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ("k", source.k()), + ]) + }, + } +} + #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 105344d5..231886ae 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -733,3 +733,35 @@ fn test_find_all_paths_mode_aggregate_rejects_witness_only() { ); assert!(paths.is_empty()); } + +#[test] +fn test_decision_minimum_vertex_cover_has_direct_aggregate_edge() { + let graph = ReductionGraph::new(); + + assert!(graph.has_direct_reduction_by_name_mode( + "DecisionMinimumVertexCover", + "MinimumVertexCover", + ReductionMode::Aggregate, + )); + assert!(!graph.has_direct_reduction_by_name_mode( + "DecisionMinimumVertexCover", + "MinimumVertexCover", + ReductionMode::Witness, + )); +} + +#[test] +fn test_decision_minimum_dominating_set_has_direct_aggregate_edge() { + let graph = ReductionGraph::new(); + + assert!(graph.has_direct_reduction_by_name_mode( + "DecisionMinimumDominatingSet", + "MinimumDominatingSet", + ReductionMode::Aggregate, + )); + assert!(!graph.has_direct_reduction_by_name_mode( + "DecisionMinimumDominatingSet", + "MinimumDominatingSet", + ReductionMode::Witness, + )); +} diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 675b2ddf..42e5ed52 100644 --- a/src/unit_tests/registry/schema.rs +++ b/src/unit_tests/registry/schema.rs @@ -1,4 +1,6 @@ use super::*; +use crate::registry::find_variant_entry; +use std::collections::BTreeMap; #[test] fn test_collect_schemas_returns_all_problems() { @@ -84,3 +86,41 @@ fn test_field_info_json_fields() { assert!(!f.description.is_empty()); } } + +#[test] +fn test_decision_problem_schema_entries_registered() { + let entries: Vec<_> = inventory::iter::().collect(); + + let mvc = entries + .iter() + .find(|entry| entry.name == "DecisionMinimumVertexCover") + .expect("DecisionMinimumVertexCover schema should be registered"); + assert!(mvc.aliases.is_empty()); + assert!(mvc.fields.iter().any(|field| field.name == "bound")); + assert_eq!(mvc.dimensions.len(), 2); + + let mds = entries + .iter() + .find(|entry| entry.name == "DecisionMinimumDominatingSet") + .expect("DecisionMinimumDominatingSet schema should be registered"); + assert!(mds.aliases.is_empty()); + assert!(mds.fields.iter().any(|field| field.name == "bound")); + assert_eq!(mds.dimensions.len(), 2); +} + +#[test] +fn test_decision_problem_variants_registered() { + let simple_weighted_variant = BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]); + + assert!( + find_variant_entry("DecisionMinimumVertexCover", &simple_weighted_variant).is_some(), + "DecisionMinimumVertexCover default variant should be registered" + ); + assert!( + find_variant_entry("DecisionMinimumDominatingSet", &simple_weighted_variant).is_some(), + "DecisionMinimumDominatingSet default variant should be registered" + ); +} From ec9cf85c4d30b9f7d972fad2ef733309b590a1a7 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 00:15:47 +0800 Subject: [PATCH 07/18] refactor: remove hand-written VertexCover, replaced by Decision --- docs/paper/reductions.typ | 25 ---- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 93 ++++++++---- .../src/commands/create/schema_support.rs | 13 +- problemreductions-cli/src/mcp/prompts.rs | 2 +- problemreductions-cli/src/problem_name.rs | 6 +- src/models/graph/minimum_vertex_cover.rs | 2 +- src/models/graph/mod.rs | 6 - src/models/graph/vertex_cover.rs | 139 ------------------ src/models/mod.rs | 2 +- src/unit_tests/models/graph/vertex_cover.rs | 100 ------------- src/unit_tests/registry/schema.rs | 6 +- 12 files changed, 92 insertions(+), 304 deletions(-) delete mode 100644 src/models/graph/vertex_cover.rs delete mode 100644 src/unit_tests/models/graph/vertex_cover.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index d6244171..7ea7a082 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -234,7 +234,6 @@ "MinimumDisjunctiveNormalForm": [Minimum Disjunctive Normal Form], "MinimumGraphBandwidth": [Minimum Graph Bandwidth], "MinimumMetricDimension": [Minimum Metric Dimension], - "VertexCover": [Vertex Cover], "MinimumCodeGenerationUnlimitedRegisters": [Minimum Code Generation (Unlimited Registers)], "RegisterSufficiency": [Register Sufficiency], "ResourceConstrainedScheduling": [Resource Constrained Scheduling], @@ -648,30 +647,6 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } -#{ - let x = load-model-example("VertexCover") - let nv = graph-num-vertices(x.instance) - let ne = graph-num-edges(x.instance) - let k = x.instance.k - let sol = x.optimal_config - let cover = sol.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) - [ - #problem-def("VertexCover")[ - Given an undirected graph $G = (V, E)$ and a positive integer $k <= |V|$, determine whether there exists a vertex cover of size at most $k$: a subset $V' subset.eq V$ with $|V'| <= k$ such that for each edge ${u, v} in E$, at least one of $u, v$ belongs to $V'$. - ][ - Vertex Cover is one of Karp's 21 NP-complete problems @karp1972 and the decision version of Minimum Vertex Cover @garey1979. The best known exact algorithm runs in $O^*(1.1996^n)$ time (Chen, Kanj, and Xia, 2010). - - *Example.* Consider a graph on $n = #nv$ vertices and $|E| = #ne$ edges with threshold $k = #k$. The cover $V' = {#cover.map(i => $v_#i$).join(", ")}$ with $|V'| = #cover.len() <= k$ is a valid vertex cover. - - #pred-commands( - "pred create --example VertexCover -o vc.json", - "pred solve vc.json", - "pred evaluate vc.json --config " + sol.map(str).join(","), - ) - ] - ] -} - #{ let x = load-model-example("MaxCut", variant: (graph: "SimpleGraph", weight: "i32")) let nv = graph-num-vertices(x.instance) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 0c5aa367..b7437de2 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -230,7 +230,7 @@ Flags by problem type: SpinGlass --graph, --couplings, --fields KColoring --graph, --k KClique --graph, --k - VertexCover (VC) --graph, --k + DecisionMinimumVertexCover --graph, --weights, --bound MinimumMultiwayCut --graph, --terminals, --edge-weights MonochromaticTriangle --graph PartitionIntoTriangles --graph diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 85b13712..d66e4fda 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,11 +13,12 @@ use problemreductions::models::algebraic::{ SparseMatrixCompression, }; use problemreductions::models::formula::Quantifier; +use problemreductions::models::Decision; use problemreductions::models::graph::{ GeneralizedHex, HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumDummyActivitiesPert, MinimumMaximalMatching, RootedTreeArrangement, SteinerTree, - SteinerTreeInGraphs, VertexCover, + SteinerTreeInGraphs, }; use problemreductions::models::misc::{ CbqRelation, FrequencyTable, KnownValue, QueryArg, SchedulingWithIndividualDeadlines, @@ -672,6 +673,14 @@ fn ser_vertex_weight_problem_with( } } +fn ser_decision_minimum_vertex_cover_with( + graph: G, + weights: Vec, + bound: i32, +) -> Result { + ser(Decision::new(MinimumVertexCover::new(graph, weights), bound)) +} + fn ser(problem: T) -> Result { util::ser(problem) } @@ -1787,6 +1796,63 @@ fn create_random( let graph_type = resolved_graph_type(resolved_variant); let (data, variant) = match canonical { + "DecisionMinimumVertexCover" => { + let raw_bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "DecisionMinimumVertexCover requires --bound\n\n\ + Usage: pred create DecisionMinimumVertexCover --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --bound 3" + ) + })?; + anyhow::ensure!( + raw_bound >= 0, + "DecisionMinimumVertexCover: --bound must be non-negative" + ); + let bound = i32::try_from(raw_bound).map_err(|_| { + anyhow::anyhow!( + "DecisionMinimumVertexCover: --bound must fit in a 32-bit signed integer, got {raw_bound}" + ) + })?; + let weights = vec![1i32; num_vertices]; + match graph_type { + "KingsSubgraph" => { + let positions = util::create_random_int_positions(num_vertices, args.seed); + let graph = KingsSubgraph::new(positions); + ( + ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, + resolved_variant.clone(), + ) + } + "TriangularSubgraph" => { + let positions = util::create_random_int_positions(num_vertices, args.seed); + let graph = TriangularSubgraph::new(positions); + ( + ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, + resolved_variant.clone(), + ) + } + "UnitDiskGraph" => { + let positions = util::create_random_float_positions(num_vertices, args.seed); + let radius = args.radius.unwrap_or(1.5); + let graph = UnitDiskGraph::new(positions, radius); + ( + ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, + resolved_variant.clone(), + ) + } + _ => { + let edge_prob = args.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + bail!("--edge-prob must be between 0.0 and 1.0"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + ( + ser_decision_minimum_vertex_cover_with(graph, weights, bound)?, + resolved_variant.clone(), + ) + } + } + } + // Graph problems with vertex weights "MaximumIndependentSet" | "MinimumVertexCover" @@ -1848,29 +1914,6 @@ fn create_random( ) } - "VertexCover" => { - let edge_prob = args.edge_prob.unwrap_or(0.5); - if !(0.0..=1.0).contains(&edge_prob) { - bail!("--edge-prob must be between 0.0 and 1.0"); - } - let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); - let usage = - "Usage: pred create VertexCover --random --num-vertices 5 [--edge-prob 0.5] [--seed 42] --k 3"; - let k = args - .k - .ok_or_else(|| anyhow::anyhow!("VertexCover requires --k\n\n{usage}"))?; - if k == 0 { - bail!("VertexCover: --k must be positive"); - } - if k > graph.num_vertices() { - bail!("VertexCover: k must be <= graph num_vertices"); - } - ( - ser(VertexCover::new(graph, k))?, - variant_map(&[("graph", "SimpleGraph")]), - ) - } - // MinimumCutIntoBoundedSets (graph + edge weights + s/t/B/K) "MinimumCutIntoBoundedSets" => { let edge_prob = args.edge_prob.unwrap_or(0.5); @@ -2232,7 +2275,7 @@ fn create_random( _ => bail!( "Random generation is not supported for {canonical}. \ Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ - MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, VertexCover, TravelingSalesman, \ + MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, DecisionMinimumVertexCover, TravelingSalesman, \ BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, MaximumLeafSpanningTree, SteinerTree, \ OptimalLinearArrangement, RootedTreeArrangement, HamiltonianPath, LongestCircuit, GeneralizedHex)" ), diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 23390e6e..36a6d2d9 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -1501,8 +1501,19 @@ pub(super) fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static Some("UnitDiskGraph") => "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5", _ => "--graph 0-1,1-2,2-3 --weights 1,1,1,1", }, + "DecisionMinimumVertexCover" => match graph_type { + Some("KingsSubgraph") => { + "--positions \"0,0;1,0;1,1;0,1\" --weights 1,1,1,1 --bound 2" + } + Some("TriangularSubgraph") => { + "--positions \"0,0;0,1;1,0;1,1\" --weights 1,1,1,1 --bound 2" + } + Some("UnitDiskGraph") => { + "--positions \"0,0;1,0;0.5,0.8\" --radius 1.5 --weights 1,1,1 --bound 2" + } + _ => "--graph 0-1,1-2,0-2,2-3 --weights 1,1,1,1 --bound 2", + }, "KClique" => "--graph 0-1,0-2,1-3,2-3,2-4,3-4 --k 3", - "VertexCover" => "--graph 0-1,1-2,0-2,2-3 --k 2", "GeneralizedHex" => "--graph 0-1,0-2,0-3,1-4,2-4,3-4,4-5 --source 0 --sink 5", "IntegralFlowBundles" => { "--arcs \"0>1,0>2,1>3,2>3,1>2,2>1\" --bundles \"0,1;2,5;3,4\" --bundle-capacities 1,1,1 --source 0 --sink 3 --requirement 1 --num-vertices 4" diff --git a/problemreductions-cli/src/mcp/prompts.rs b/problemreductions-cli/src/mcp/prompts.rs index b0289060..f9c4d44f 100644 --- a/problemreductions-cli/src/mcp/prompts.rs +++ b/problemreductions-cli/src/mcp/prompts.rs @@ -142,7 +142,7 @@ pub fn get_prompt( "compare" => { let a = get("problem_a", "MIS"); - let b = get("problem_b", "VertexCover"); + let b = get("problem_b", "DecisionMinimumVertexCover"); Some(prompt_result( &format!("Compare {a} and {b}"), &format!( diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 24a51eb7..1077effc 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -309,9 +309,9 @@ mod tests { resolve_alias("biconnectivityaugmentation"), "BiconnectivityAugmentation" ); - // VertexCover alias - assert_eq!(resolve_alias("VC"), "VertexCover"); - assert_eq!(resolve_alias("VertexCover"), "VertexCover"); + // VertexCover aliases now resolve to the Decision wrapper + assert_eq!(resolve_alias("VC"), "DecisionMinimumVertexCover"); + assert_eq!(resolve_alias("VertexCover"), "DecisionMinimumVertexCover"); // Pass-through for full names assert_eq!( resolve_alias("MaximumIndependentSet"), diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index acc80a36..693d4c5d 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -187,7 +187,7 @@ inventory::submit! { ProblemSchemaEntry { name: "DecisionMinimumVertexCover", display_name: "Decision Minimum Vertex Cover", - aliases: &[], + aliases: &["VertexCover", "VC"], dimensions: &[ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), VariantDimension::new("weight", "i32", &["i32"]), diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index f5f39594..7b9e3d34 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -70,7 +70,6 @@ //! - [`IntegralFlowWithMultipliers`]: Integral flow with vertex multipliers on a directed graph //! - [`UndirectedFlowLowerBounds`]: Feasible s-t flow in an undirected graph with lower/upper bounds //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs -//! - [`VertexCover`]: Decision version of Minimum Vertex Cover (Karp's 21) //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs //! - [`DisjointConnectingPaths`]: Vertex-disjoint paths connecting prescribed terminal pairs //! - [`MinimumGraphBandwidth`]: Minimum graph bandwidth (minimize maximum edge stretch) @@ -150,8 +149,6 @@ pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; pub(crate) mod undirected_flow_lower_bounds; pub(crate) mod undirected_two_commodity_integral_flow; -pub(crate) mod vertex_cover; - pub use acyclic_partition::AcyclicPartition; pub use balanced_complete_bipartite_subgraph::BalancedCompleteBipartiteSubgraph; pub use biclique_cover::BicliqueCover; @@ -227,8 +224,6 @@ pub use subgraph_isomorphism::SubgraphIsomorphism; pub use traveling_salesman::TravelingSalesman; pub use undirected_flow_lower_bounds::UndirectedFlowLowerBounds; pub use undirected_two_commodity_integral_flow::UndirectedTwoCommodityIntegralFlow; -pub use vertex_cover::VertexCover; - #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { let mut specs = Vec::new(); @@ -295,7 +290,6 @@ pub(crate) fn canonical_model_example_specs() -> Vec { - /// The underlying graph. - graph: G, - /// Maximum cover size threshold. - k: usize, -} - -impl VertexCover { - /// Create a new VertexCover problem. - pub fn new(graph: G, k: usize) -> Self { - assert!(k > 0, "k must be positive"); - assert!(k <= graph.num_vertices(), "k must be at most num_vertices"); - Self { graph, k } - } - - /// Get a reference to the graph. - pub fn graph(&self) -> &G { - &self.graph - } - - /// Get the cover size threshold. - pub fn k(&self) -> usize { - self.k - } - - /// Get the number of vertices. - pub fn num_vertices(&self) -> usize { - self.graph.num_vertices() - } - - /// Get the number of edges. - pub fn num_edges(&self) -> usize { - self.graph.num_edges() - } - - /// Check if a configuration is a valid vertex cover of size ≤ k. - pub fn is_valid_solution(&self, config: &[usize]) -> bool { - if config.len() != self.graph.num_vertices() { - return false; - } - let count: usize = config.iter().filter(|&&v| v == 1).count(); - if count > self.k { - return false; - } - is_vertex_cover_config(&self.graph, config) - } -} - -impl Problem for VertexCover -where - G: Graph + crate::variant::VariantParam, -{ - const NAME: &'static str = "VertexCover"; - type Value = crate::types::Or; - - fn variant() -> Vec<(&'static str, &'static str)> { - crate::variant_params![G] - } - - fn dims(&self) -> Vec { - vec![2; self.graph.num_vertices()] - } - - fn evaluate(&self, config: &[usize]) -> crate::types::Or { - crate::types::Or(self.is_valid_solution(config)) - } -} - -crate::declare_variants! { - default VertexCover => "1.1996^num_vertices", -} - -#[cfg(feature = "example-db")] -pub(crate) fn canonical_model_example_specs() -> Vec { - vec![crate::example_db::specs::ModelExampleSpec { - id: "vertex_cover_simplegraph", - instance: Box::new(VertexCover::new( - SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), - 2, - )), - optimal_config: vec![1, 0, 1, 0], - optimal_value: serde_json::json!(true), - }] -} - -#[cfg(test)] -#[path = "../../unit_tests/models/graph/vertex_cover.rs"] -mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 249a0e94..22a31e9b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -45,7 +45,7 @@ pub use graph::{ PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, UndirectedFlowLowerBounds, - UndirectedTwoCommodityIntegralFlow, VertexCover, + UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/unit_tests/models/graph/vertex_cover.rs b/src/unit_tests/models/graph/vertex_cover.rs deleted file mode 100644 index 76de3efa..00000000 --- a/src/unit_tests/models/graph/vertex_cover.rs +++ /dev/null @@ -1,100 +0,0 @@ -use super::*; -use crate::solvers::BruteForce; -use crate::topology::SimpleGraph; -use crate::traits::Problem; -use crate::types::Or; - -fn issue_instance() -> VertexCover { - // Triangle {0,1,2} with pendant edge to 3 - VertexCover::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), 2) -} - -#[test] -fn test_vertex_cover_creation() { - let problem = issue_instance(); - assert_eq!(problem.num_vertices(), 4); - assert_eq!(problem.num_edges(), 4); - assert_eq!(problem.k(), 2); - assert_eq!(problem.dims(), vec![2; 4]); -} - -#[test] -fn test_vertex_cover_evaluate_valid() { - let problem = issue_instance(); - // {0, 2} covers all edges with size 2 ≤ k=2 - assert_eq!(problem.evaluate(&[1, 0, 1, 0]), Or(true)); -} - -#[test] -fn test_vertex_cover_evaluate_too_large() { - let problem = issue_instance(); - // {0, 1, 2} is a valid cover but size 3 > k=2 - assert_eq!(problem.evaluate(&[1, 1, 1, 0]), Or(false)); -} - -#[test] -fn test_vertex_cover_evaluate_not_covering() { - let problem = issue_instance(); - // {0} doesn't cover edge (1,2) - assert_eq!(problem.evaluate(&[1, 0, 0, 0]), Or(false)); -} - -#[test] -fn test_vertex_cover_evaluate_k1_impossible() { - // Same graph but k=1 — impossible for triangle - let problem = VertexCover::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), 1); - let solver = BruteForce::new(); - let witness = solver.find_witness(&problem); - assert!(witness.is_none()); -} - -#[test] -fn test_vertex_cover_solver() { - let problem = issue_instance(); - let solver = BruteForce::new(); - let witness = solver.find_witness(&problem); - assert!(witness.is_some()); - let w = witness.unwrap(); - assert_eq!(problem.evaluate(&w), Or(true)); - // Cover size should be ≤ k=2 - let count: usize = w.iter().filter(|&&v| v == 1).count(); - assert!(count <= 2); -} - -#[test] -fn test_vertex_cover_all_witnesses() { - let problem = issue_instance(); - let solver = BruteForce::new(); - let witnesses = solver.find_all_witnesses(&problem); - // For k=2 on triangle+pendant: covers of size ≤2 that cover all edges - // Valid size-2 covers: {0,2}, {1,2} (vertex 2 covers pendant edge) - // {0,1} doesn't cover (2,3) - assert!(witnesses.len() >= 2); - for w in &witnesses { - assert_eq!(problem.evaluate(w), Or(true)); - } -} - -#[test] -fn test_vertex_cover_serialization() { - let problem = issue_instance(); - let json = serde_json::to_string(&problem).unwrap(); - let restored: VertexCover = serde_json::from_str(&json).unwrap(); - assert_eq!(restored.num_vertices(), 4); - assert_eq!(restored.k(), 2); - assert_eq!(restored.evaluate(&[1, 0, 1, 0]), Or(true)); -} - -#[test] -fn test_vertex_cover_path_graph() { - // Path 0-1-2: minimum cover is {1}, size 1 - let problem = VertexCover::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 1); - assert_eq!(problem.evaluate(&[0, 1, 0]), Or(true)); - assert_eq!(problem.evaluate(&[1, 0, 0]), Or(false)); // Doesn't cover (1,2) -} - -#[test] -#[should_panic(expected = "k must be positive")] -fn test_vertex_cover_k_zero() { - VertexCover::new(SimpleGraph::new(3, vec![(0, 1)]), 0); -} diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 42e5ed52..5b856abd 100644 --- a/src/unit_tests/registry/schema.rs +++ b/src/unit_tests/registry/schema.rs @@ -95,9 +95,13 @@ fn test_decision_problem_schema_entries_registered() { .iter() .find(|entry| entry.name == "DecisionMinimumVertexCover") .expect("DecisionMinimumVertexCover schema should be registered"); - assert!(mvc.aliases.is_empty()); + assert_eq!(mvc.aliases, ["VertexCover", "VC"]); assert!(mvc.fields.iter().any(|field| field.name == "bound")); assert_eq!(mvc.dimensions.len(), 2); + assert!( + entries.iter().all(|entry| entry.name != "VertexCover"), + "legacy VertexCover schema should be removed" + ); let mds = entries .iter() From 1020d8fffa1c1c1ab9a1440e7e3b4711cf34d01e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 00:23:57 +0800 Subject: [PATCH 08/18] feat: add golden-section search solver via Decision queries --- src/solvers/golden_section.rs | 109 +++++++++++++++++++++++ src/solvers/mod.rs | 1 + src/unit_tests/solvers/golden_section.rs | 73 +++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 src/solvers/golden_section.rs create mode 100644 src/unit_tests/solvers/golden_section.rs diff --git a/src/solvers/golden_section.rs b/src/solvers/golden_section.rs new file mode 100644 index 00000000..faf43cb7 --- /dev/null +++ b/src/solvers/golden_section.rs @@ -0,0 +1,109 @@ +//! Golden-section-style search for optimization via decision queries. + +use crate::models::decision::{Decision, DecisionProblemMeta}; +use crate::solvers::{BruteForce, Solver}; +use crate::traits::Problem; +use crate::types::{Max, Min, OptimizationValue, Or}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use std::fmt; + +/// Whether a decision problem has at least one satisfying configuration. +fn is_satisfiable

(problem: &P) -> bool +where + P: Problem, +{ + BruteForce::new().solve(problem).0 +} + +fn solve_via_decision_min

(problem: &P, lower: i32, upper: i32) -> Option +where + P: DecisionProblemMeta + Problem> + Clone, +{ + if lower > upper { + return None; + } + + if !is_satisfiable(&Decision::new(problem.clone(), upper)) { + return None; + } + + let mut lo = lower; + let mut hi = upper; + while lo < hi { + let mid = lo + (hi - lo) / 2; + if is_satisfiable(&Decision::new(problem.clone(), mid)) { + hi = mid; + } else { + lo = mid + 1; + } + } + + Some(lo) +} + +fn solve_via_decision_max

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

(problem: &P, lower: i32, upper: i32) -> Option + where + P: DecisionProblemMeta + Problem + Clone; +} + +impl DecisionSearchValue for Min { + fn solve_problem

(problem: &P, lower: i32, upper: i32) -> Option + where + P: DecisionProblemMeta + Problem + Clone, + { + solve_via_decision_min(problem, lower, upper) + } +} + +impl DecisionSearchValue for Max { + fn solve_problem

(problem: &P, lower: i32, upper: i32) -> Option + where + P: DecisionProblemMeta + Problem + Clone, + { + solve_via_decision_max(problem, lower, upper) + } +} + +/// Recover an optimization value by querying the problem's decision wrapper. +pub fn solve_via_decision

(problem: &P, lower: i32, upper: i32) -> Option +where + P: DecisionProblemMeta + Clone, + P::Value: DecisionSearchValue, +{ + ::solve_problem(problem, lower, upper) +} + +#[cfg(test)] +#[path = "../unit_tests/solvers/golden_section.rs"] +mod tests; diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index b9d2f89d..86603a75 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -2,6 +2,7 @@ mod brute_force; pub mod customized; +pub mod golden_section; #[cfg(feature = "ilp-solver")] pub mod ilp; diff --git a/src/unit_tests/solvers/golden_section.rs b/src/unit_tests/solvers/golden_section.rs new file mode 100644 index 00000000..3f34aa93 --- /dev/null +++ b/src/unit_tests/solvers/golden_section.rs @@ -0,0 +1,73 @@ +use super::*; +use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::types::{Max, Min}; +use crate::Solver; + +#[test] +fn test_golden_section_min() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumVertexCover::new(graph, vec![1i32; 3]); + + assert_eq!(solve_via_decision(&problem, 0, 3), Some(1)); +} + +#[test] +fn test_golden_section_max() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); + + assert_eq!(solve_via_decision(&problem, 0, 3), Some(2)); +} + +#[test] +fn test_golden_section_matches_brute_force() { + let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); + let problem = MinimumVertexCover::new(graph, vec![1i32; 5]); + + let brute_force_value = BruteForce::new().solve(&problem); + + assert_eq!(solve_via_decision(&problem, 0, 5), brute_force_value.size().copied()); +} + +#[test] +fn test_golden_section_min_returns_none_when_upper_bound_is_too_small() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumVertexCover::new(graph, vec![1i32; 3]); + + assert_eq!(solve_via_decision(&problem, 0, 0), None); +} + +#[test] +fn test_golden_section_max_returns_none_when_interval_is_above_optimum() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); + + assert_eq!(solve_via_decision(&problem, 3, 4), None); +} + +#[test] +fn test_golden_section_invalid_interval_returns_none() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let min_problem = MinimumVertexCover::new(graph.clone(), vec![1i32; 3]); + let max_problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); + + assert_eq!(solve_via_decision(&min_problem, 2, 1), None); + assert_eq!(solve_via_decision(&max_problem, 2, 1), None); +} + +#[test] +fn test_golden_section_preserves_value_direction() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let min_problem = MinimumVertexCover::new(graph.clone(), vec![1i32; 3]); + let max_problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); + + let min_value = BruteForce::new().solve(&min_problem); + let max_value = BruteForce::new().solve(&max_problem); + + assert_eq!(min_value, Min(Some(1))); + assert_eq!(max_value, Max(Some(2))); + assert_eq!(solve_via_decision(&min_problem, 0, 3), Some(1)); + assert_eq!(solve_via_decision(&max_problem, 0, 3), Some(2)); +} From 98b3216a294d15e1b057975fdb5f509f89769c22 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 00:30:38 +0800 Subject: [PATCH 09/18] docs: migrate VertexCover paper entry and example_db to DecisionMinimumVertexCover --- docs/paper/reductions.typ | 40 ++++++++++++++++++++++-- src/models/graph/minimum_vertex_cover.rs | 17 ++++++++++ src/models/graph/mod.rs | 1 + src/unit_tests/example_db.rs | 20 ++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 7ea7a082..9199b553 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -62,8 +62,18 @@ } } -#let graph-num-vertices(instance) = instance.graph.num_vertices -#let graph-num-edges(instance) = instance.graph.edges.len() +#let graph-instance(instance) = { + if "graph" in instance { + instance + } else if "inner" in instance and "graph" in instance.inner { + instance.inner + } else { + instance + } +} + +#let graph-num-vertices(instance) = graph-instance(instance).graph.num_vertices +#let graph-num-edges(instance) = graph-instance(instance).graph.edges.len() #let spin-num-spins(instance) = instance.fields.len() #let sat-num-clauses(instance) = instance.clauses.len() #let subsetsum-num-elements(instance) = instance.sizes.len() @@ -234,6 +244,7 @@ "MinimumDisjunctiveNormalForm": [Minimum Disjunctive Normal Form], "MinimumGraphBandwidth": [Minimum Graph Bandwidth], "MinimumMetricDimension": [Minimum Metric Dimension], + "DecisionMinimumVertexCover": [Decision Minimum Vertex Cover], "MinimumCodeGenerationUnlimitedRegisters": [Minimum Code Generation (Unlimited Registers)], "RegisterSufficiency": [Register Sufficiency], "ResourceConstrainedScheduling": [Resource Constrained Scheduling], @@ -647,6 +658,31 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } +#{ + let x = load-model-example("DecisionMinimumVertexCover") + let inner = x.instance.inner + let nv = graph-num-vertices(x.instance) + let ne = graph-num-edges(x.instance) + let k = x.instance.bound + let sol = x.optimal_config + let cover = sol.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) + [ + #problem-def("DecisionMinimumVertexCover")[ + Given an undirected graph $G = (V, E)$ with vertex weights $w: V -> RR_ge 0$ and an integer bound $k$, determine whether there exists a vertex cover $S subset.eq V$ with $sum_(v in S) w(v) <= k$ such that every edge has at least one endpoint in $S$. + ][ + Decision Minimum Vertex Cover is the decision version of Minimum Vertex Cover and one of Karp's 21 NP-complete problems @karp1972 @garey1979. It asks whether the optimization objective can be achieved within a prescribed budget rather than minimizing the cover weight directly. + + *Example.* Consider a graph on $n = #nv$ vertices and $|E| = #ne$ edges with threshold $k = #k$. The cover $S = {#cover.map(i => $v_#i$).join(", ")}$ has total weight $2 <= #k$ and therefore certifies a yes-instance. + + #pred-commands( + "pred create --example DecisionMinimumVertexCover -o vc.json", + "pred solve vc.json", + "pred evaluate vc.json --config " + sol.map(str).join(","), + ) + ] + ] +} + #{ let x = load-model-example("MaxCut", variant: (graph: "SimpleGraph", weight: "i32")) let nv = graph-num-vertices(x.instance) diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 693d4c5d..07cfcfb6 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -253,6 +253,23 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "decision_minimum_vertex_cover_simplegraph_i32", + instance: Box::new(Decision::new( + MinimumVertexCover::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), + vec![1i32; 4], + ), + 2, + )), + optimal_config: vec![1, 0, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + /// Check if a set of vertices forms a vertex cover. /// /// # Arguments diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7b9e3d34..0535dc20 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -234,6 +234,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec Date: Tue, 7 Apr 2026 00:35:16 +0800 Subject: [PATCH 10/18] chore: integration fixups for Decision wrapper --- problemreductions-cli/src/commands/create.rs | 11 ++++++++--- src/models/graph/minimum_vertex_cover.rs | 4 ++-- src/types.rs | 8 ++++++-- src/unit_tests/solvers/golden_section.rs | 5 ++++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index d66e4fda..4b8b8823 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,7 +13,6 @@ use problemreductions::models::algebraic::{ SparseMatrixCompression, }; use problemreductions::models::formula::Quantifier; -use problemreductions::models::Decision; use problemreductions::models::graph::{ GeneralizedHex, HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, @@ -24,6 +23,7 @@ use problemreductions::models::misc::{ CbqRelation, FrequencyTable, KnownValue, QueryArg, SchedulingWithIndividualDeadlines, ThreePartition, }; +use problemreductions::models::Decision; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ @@ -673,12 +673,17 @@ fn ser_vertex_weight_problem_with( } } -fn ser_decision_minimum_vertex_cover_with( +fn ser_decision_minimum_vertex_cover_with< + G: Graph + Serialize + problemreductions::variant::VariantParam, +>( graph: G, weights: Vec, bound: i32, ) -> Result { - ser(Decision::new(MinimumVertexCover::new(graph, weights), bound)) + ser(Decision::new( + MinimumVertexCover::new(graph, weights), + bound, + )) } fn ser(problem: T) -> Result { diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 07cfcfb6..020284c8 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -254,8 +254,8 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec { +pub(crate) fn decision_canonical_model_example_specs( +) -> Vec { vec![crate::example_db::specs::ModelExampleSpec { id: "decision_minimum_vertex_cover_simplegraph_i32", instance: Box::new(Decision::new( diff --git a/src/types.rs b/src/types.rs index 3ff8089d..cc859423 100644 --- a/src/types.rs +++ b/src/types.rs @@ -307,7 +307,9 @@ pub trait OptimizationValue: Aggregate { fn meets_bound(value: &Self, bound: &Self::Inner) -> bool; } -impl OptimizationValue for Min { +impl OptimizationValue + for Min +{ type Inner = V; fn meets_bound(value: &Self, bound: &V) -> bool { @@ -315,7 +317,9 @@ impl Optimiza } } -impl OptimizationValue for Max { +impl OptimizationValue + for Max +{ type Inner = V; fn meets_bound(value: &Self, bound: &V) -> bool { diff --git a/src/unit_tests/solvers/golden_section.rs b/src/unit_tests/solvers/golden_section.rs index 3f34aa93..d917591b 100644 --- a/src/unit_tests/solvers/golden_section.rs +++ b/src/unit_tests/solvers/golden_section.rs @@ -28,7 +28,10 @@ fn test_golden_section_matches_brute_force() { let brute_force_value = BruteForce::new().solve(&problem); - assert_eq!(solve_via_decision(&problem, 0, 5), brute_force_value.size().copied()); + assert_eq!( + solve_via_decision(&problem, 0, 5), + brute_force_value.size().copied() + ); } #[test] From 6471863196af5785bbe34e209d5ef517bdfb96c2 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 09:20:57 +0800 Subject: [PATCH 11/18] fix: address PR #1014 review comments and quality issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add register_decision_variant! macro to reduce per-type boilerplate (~80 lines each for MVC/MDS) - Add Decision behavioral tests (creation, evaluate, reduction, solver) - Add boundary test for reduce_to_aggregate with infeasible bound - Rename golden_section.rs to decision_search.rs (binary search, not golden section) - Fix schema-driven CLI creation for Decision types (restructure flat JSON to nested {inner, bound}) - Add canonical rule example specs for Decision→Optimization aggregate edges - Update example_db test to handle aggregate-only reduction paths - Filter trivial Decision

↔P edges from paper completeness check - Remove plan files - Generalize DecisionProblemMeta to blanket impl per optimization model Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 5 + .../2026-04-06-decision-wrapper-design.md | 341 ------ .../plans/2026-04-06-decision-wrapper-plan.md | 1073 ----------------- .../src/commands/create/schema_support.rs | 15 +- src/models/decision.rs | 101 ++ src/models/graph/maximum_independent_set.rs | 12 +- src/models/graph/minimum_dominating_set.rs | 143 +-- src/models/graph/minimum_vertex_cover.rs | 145 +-- src/rules/mod.rs | 6 + .../{golden_section.rs => decision_search.rs} | 4 +- src/solvers/mod.rs | 2 +- src/unit_tests/example_db.rs | 69 +- src/unit_tests/models/decision.rs | 81 +- .../{golden_section.rs => decision_search.rs} | 14 +- 14 files changed, 390 insertions(+), 1621 deletions(-) delete mode 100644 docs/plans/2026-04-06-decision-wrapper-design.md delete mode 100644 docs/plans/2026-04-06-decision-wrapper-plan.md rename src/solvers/{golden_section.rs => decision_search.rs} (95%) rename src/unit_tests/solvers/{golden_section.rs => decision_search.rs} (85%) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 9199b553..03fc0bf6 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -13097,7 +13097,12 @@ See #link("https://github.com/CodingThrust/problem-reductions/blob/main/examples } unique } + // Skip trivial Decision

↔ P edges (solve-and-compare, no interesting proof) + let is-decision-opt-pair(src, tgt) = { + src == "Decision" + tgt or tgt == "Decision" + src + } let missing = json-edges.filter(e => { + not is-decision-opt-pair(e.at(0), e.at(1)) and covered.find(c => c.at(0) == e.at(0) and c.at(1) == e.at(1)) == none }) if missing.len() > 0 { diff --git a/docs/plans/2026-04-06-decision-wrapper-design.md b/docs/plans/2026-04-06-decision-wrapper-design.md deleted file mode 100644 index 27917424..00000000 --- a/docs/plans/2026-04-06-decision-wrapper-design.md +++ /dev/null @@ -1,341 +0,0 @@ -# Decision Wrapper Design Spec - -**Issue:** #998 -**Date:** 2026-04-06 -**Status:** Draft (revised after Codex review) - -## Motivation - -Many classical NP-completeness reductions (Garey & Johnson) operate between decision -versions of problems, but the codebase models are optimization problems. This blocks -reductions where the source is `Min` or `Max` but the target expects `Or`. - -Blocked rules (case A — optimization-to-decision only): -- #379: MinimumDominatingSet → MinMaxMulticenter -- #198: MinimumVertexCover → HamiltonianCircuit -- #894: MinimumVertexCover → PartialFeedbackEdgeSet - -Out of scope: case (B) cross-sense (`Max → Min`) and case (C) hidden-parameter -reductions from issue #998. - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Scope | Case (A) only | Cases B and C are independent problems | -| Location | `src/models/decision.rs` (generic struct); concrete variants in optimization model files | Keeps decision + optimization together | -| Comparison sense | Inferred from `P::Value` via `OptimizationValue` trait | No invalid states; `Min` always means ≤, `Max` always means ≥ | -| NAME resolution | `DecisionProblemMeta` trait + `decision_problem_meta!` macro | `const_format::concatcp!` doesn't support generic associated consts; this avoids the dependency while keeping shared generic logic | -| Registry | Explicit `declare_variants!` per concrete type, in the optimization model file | Consistent with existing patterns | -| Naming | `"Decision"` prefix (e.g., `"DecisionMinimumVertexCover"`) | Clear, no collision | -| Hand-written decision models | Replace with `Decision

` | Eliminates duplication; `VertexCover` → `Decision` | -| Alias migration | Register `VertexCover`/`VC` as aliases on `DecisionMinimumVertexCover` schema entry | Preserves CLI/catalog backward compatibility | -| Opt→Decision reduction | `Decision

→ P` as `ReduceToAggregate` only | Solve inner optimization, compare to bound — fits one-shot aggregate model | -| Decision→Opt solver | Golden-section search utility in `src/solvers/` | Multi-query algorithm, not a `ReduceTo` edge; included for testing | -| Initial concrete types | `Decision>`, `Decision>` | Minimum to unblock #379, #198, #894 | - -## Architecture - -### 1. `OptimizationValue` Trait (`src/types.rs`) - -```rust -/// Trait for aggregate values that represent optimization objectives (Min or Max). -/// Enables generic conversion to decision problems via a bound parameter. -pub trait OptimizationValue: Aggregate { - /// The inner numeric type (e.g., `i32` for `Min`). - type Inner: Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned; - - /// Does this evaluation result satisfy the decision bound? - /// - For `Min`: true iff value is Some(v) where v ≤ bound - /// - For `Max`: true iff value is Some(v) where v ≥ bound - fn meets_bound(value: &Self, bound: &Self::Inner) -> bool; -} -``` - -Implementations for `Min` (checks `≤`) and `Max` (checks `≥`). -`Min(None)` and `Max(None)` (infeasible configs) always return `false`. - -### 2. `DecisionProblemMeta` Trait + `Decision

` Struct (`src/models/decision.rs`) - -`const_format::concatcp!` does not support generic associated consts (`P::NAME`). -Instead, use a metadata trait that each concrete inner problem implements: - -```rust -/// Metadata trait providing the decision problem name for each inner problem type. -pub trait DecisionProblemMeta: Problem -where - Self::Value: OptimizationValue, -{ - const DECISION_NAME: &'static str; -} - -/// Helper macro to register a concrete inner problem's decision name. -#[macro_export] -macro_rules! decision_problem_meta { - ($inner:ty, $name:literal) => { - impl crate::models::decision::DecisionProblemMeta for $inner { - const DECISION_NAME: &'static str = $name; - } - }; -} -``` - -The generic struct and `Problem` impl: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Decision -where - P::Value: OptimizationValue, -{ - inner: P, - bound: ::Inner, -} - -impl

Problem for Decision

-where - P: DecisionProblemMeta, - P::Value: OptimizationValue, -{ - const NAME: &'static str = P::DECISION_NAME; - type Value = Or; - - fn dims(&self) -> Vec { self.inner.dims() } - - fn evaluate(&self, config: &[usize]) -> Or { - Or(::meets_bound( - &self.inner.evaluate(config), - &self.bound, - )) - } - - fn variant() -> Vec<(&'static str, &'static str)> { P::variant() } -} -``` - -Accessor methods: `new()`, `inner()`, `bound()`. - -### 3. `Decision

→ P` Aggregate Reduction (`src/models/decision.rs`) - -Only the `Decision

→ P` direction is a valid reduction (solve inner optimization, -compare to bound). The reverse (bisection) is not a one-shot reduction — it requires -multiple adaptive queries and belongs as a separate solver utility (out of scope). - -```rust -#[derive(Debug, Clone)] -pub struct DecisionToOptimizationResult

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

AggregateReductionResult for DecisionToOptimizationResult

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

; - type Target = P; - - fn target_problem(&self) -> &P { &self.target } - - fn extract_value(&self, target_value: P::Value) -> Or { - Or(::meets_bound(&target_value, &self.bound)) - } -} - -impl

ReduceToAggregate

for Decision

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

; - - fn reduce_to_aggregate(&self) -> Self::Result { - DecisionToOptimizationResult { - target: self.inner().clone(), - bound: self.bound().clone(), - } - } -} -``` - -**Note:** `#[reduction]` currently only populates `reduce_fn` (witness), not -`reduce_aggregate_fn`. Concrete aggregate edges must be registered manually via -`inventory::submit!(ReductionEntry { ... })` in the optimization model files. - -### 4. Golden-Section Search Solver (`src/solvers/golden_section.rs`) - -Finds the optimal value of an optimization problem `P` by querying its decision -version `Decision

`. Uses the [golden-section search](https://en.wikipedia.org/wiki/Golden-section_search) -algorithm — not a reduction (it requires multiple adaptive queries), but a solver -utility that exercises the `Decision

` wrapper end-to-end. - -```rust -/// Solve an optimization problem by golden-section search on its decision version. -/// -/// Given an optimization problem P with Value = Min or Max, constructs -/// Decision

instances with varying bounds and narrows the search interval -/// using the golden ratio φ = (1 + √5) / 2. -/// -/// For Min: searches for the smallest bound where Decision

is satisfiable. -/// For Max: searches for the largest bound where Decision

is satisfiable. -pub fn solve_via_decision

(problem: &P, lower: V, upper: V) -> Option -where - P: DecisionProblemMeta + Clone, - P::Value: OptimizationValue, - V: /* numeric bounds */, -{ - // Golden ratio narrowing: - // 1. Evaluate Decision

at two interior probe points - // 2. Narrow interval based on which probe is feasible/infeasible - // 3. Repeat until convergence (for f64) or interval width ≤ 1 (for integers) -} -``` - -**Integer specialization:** For `V = i32` (discrete), golden-section search degrades -to a narrowing strategy that converges in O(log n) decision queries, where n is the -value range. Each query constructs a `Decision

` and solves it with `BruteForce`. - -**Purpose:** Primarily a testing utility — validates that `Decision

` correctly -wraps the optimization problem by recovering the same optimum that `BruteForce` -finds directly. Also demonstrates the decision↔optimization duality. - -### 5. Proc Macro: `extract_type_name()` Fix (`problemreductions-macros/src/lib.rs`) - - -The current `extract_type_name()` takes only the last path segment identifier. -For `Decision>`, it produces `"Decision"` — -losing the inner type. All `Decision<...>` variants would collapse to one name. - -**Fix:** Special-case `"Decision"` and recurse into the first type argument: - -```rust -fn extract_type_name(ty: &syn::Type) -> Option { - match ty { - syn::Type::Path(type_path) => { - let segment = type_path.path.segments.last()?; - let ident = segment.ident.to_string(); - - if ident == "Decision" { - let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { - return Some(ident); - }; - let inner_ty = args.args.iter().find_map(|arg| match arg { - syn::GenericArgument::Type(ty) => Some(ty), - _ => None, - })?; - let inner_name = extract_type_name(inner_ty)?; - return Some(format!("Decision{inner_name}")); - } - - Some(ident) - } - _ => None, - } -} -``` - -After this change: -- `MinimumVertexCover` → `"MinimumVertexCover"` (unchanged) -- `Decision>` → `"DecisionMinimumVertexCover"` - -This affects both `#[reduction]` name extraction and `declare_variants!` default grouping. - -### 5. Concrete Variants (in optimization model files) - -Each optimization model that needs a decision version adds to its own file: - -```rust -// In src/models/graph/minimum_vertex_cover.rs - -// --- Decision name registration --- -crate::decision_problem_meta!( - MinimumVertexCover, - "DecisionMinimumVertexCover" -); - -// --- Delegated getters for overhead expressions --- -impl Decision> { - pub fn num_vertices(&self) -> usize { self.inner().num_vertices() } - pub fn num_edges(&self) -> usize { self.inner().num_edges() } - pub fn k(&self) -> usize { self.bound() as usize } -} - -// --- Variant registration --- -declare_variants! { - default Decision> => "1.1996^num_vertices", -} - -// --- Manual aggregate reduction registration --- -// inventory::submit!(ReductionEntry { ... }) for Decision → MVC aggregate edge -``` - -Same pattern for `minimum_dominating_set.rs`. - -### 6. Migration: Remove Hand-Written Decision Models - -**Remove:** -- `src/models/graph/vertex_cover.rs` — replaced by `Decision` -- `src/unit_tests/models/graph/vertex_cover.rs` - -**Alias migration:** Register `VertexCover` and `VC` as aliases on the new -`DecisionMinimumVertexCover` `ProblemSchemaEntry`, so `pred show VertexCover` -and `pred show VC` continue to work. - -**No existing reductions reference `VertexCover`** — all reduction files use -`MinimumVertexCover` (the optimization version). Zero reduction file changes needed. - -**Additional files to update:** -- `src/models/graph/mod.rs` — remove `VertexCover` export -- `src/example_db/model_builders.rs` — update or remove VertexCover canonical example -- `src/example_db/specs.rs` — migrate VertexCover spec to DecisionMinimumVertexCover -- `docs/paper/reductions.typ` — update `problem-def("VertexCover")` entry and `display-name` dict - -### 7. Testing - -**Generic tests** in `src/unit_tests/models/decision.rs`: -1. `test_decision_min_creation` — construct, verify accessors -2. `test_decision_min_evaluate_feasible` — cost ≤ bound → `Or(true)` -3. `test_decision_min_evaluate_infeasible_cost` — cost > bound → `Or(false)` -4. `test_decision_min_evaluate_infeasible_config` — invalid config → `Or(false)` -5. `test_decision_max_evaluate` — ≥ bound semantics with a Max-valued problem -6. `test_decision_solver` — BruteForce witness recovery -7. `test_decision_serialization` — round-trip serde -8. `test_decision_dims` — delegates to inner - -**Golden-section search tests** in `src/unit_tests/solvers/golden_section.rs`: -9. `test_golden_section_min` — recover MinimumVertexCover optimum via Decision queries -10. `test_golden_section_max` — recover MaximumIndependentSet optimum via Decision queries -11. `test_golden_section_matches_brute_force` — verify golden-section result equals BruteForce result - -**Per-problem tests** in existing model test files gain decision variant coverage. - -## File Changes Summary - -| File | Change | -|------|--------| -| `src/types.rs` | Add `OptimizationValue` trait + `Min`/`Max` impls | -| `src/models/decision.rs` | **New** — `DecisionProblemMeta` trait, `decision_problem_meta!` macro, `Decision

` struct, generic `Problem` impl, `ReduceToAggregate` impl | -| `src/solvers/golden_section.rs` | **New** — golden-section search solver: finds optimum by querying `Decision

` | -| `src/models/mod.rs` | Add `pub mod decision` | -| `src/models/graph/minimum_vertex_cover.rs` | Add `decision_problem_meta!`, decision getters, `declare_variants!`, `ProblemSchemaEntry` (with VC/VertexCover aliases), manual aggregate `ReductionEntry` | -| `src/models/graph/minimum_dominating_set.rs` | Same pattern | -| `src/models/graph/vertex_cover.rs` | **Remove** | -| `src/models/graph/mod.rs` | Remove `VertexCover` export | -| `problemreductions-macros/src/lib.rs` | Fix `extract_type_name()` to recurse into `Decision` | -| `src/example_db/model_builders.rs` | Migrate VertexCover example → DecisionMinimumVertexCover | -| `src/example_db/specs.rs` | Migrate VertexCover spec | -| `docs/paper/reductions.typ` | Update VertexCover problem-def and display-name | -| `src/unit_tests/models/decision.rs` | **New** — generic decision tests | -| `src/unit_tests/models/graph/vertex_cover.rs` | **Remove** | - -## Not In Scope - -- Implementing blocked reductions (#379, #198, #894) — separate PRs -- Cases (B) cross-sense and (C) hidden-parameter from issue #998 -- CLI `pred create` support for Decision types -- `const_format` dependency (replaced by `DecisionProblemMeta` trait) diff --git a/docs/plans/2026-04-06-decision-wrapper-plan.md b/docs/plans/2026-04-06-decision-wrapper-plan.md deleted file mode 100644 index e4814c7d..00000000 --- a/docs/plans/2026-04-06-decision-wrapper-plan.md +++ /dev/null @@ -1,1073 +0,0 @@ -# Decision Wrapper Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a generic `Decision

` wrapper that converts optimization problems (`Min`/`Max`) to decision problems (`Or`), replace hand-written `VertexCover`, and provide a golden-section search solver. - -**Architecture:** `OptimizationValue` trait abstracts over `Min`/`Max`. `Decision

` is a generic struct with `Problem` impl delegated via `DecisionProblemMeta` trait. Concrete variants registered in optimization model files. Golden-section search solver recovers optima via decision queries. - -**Tech Stack:** Rust, `inventory` crate (existing), `serde` (existing), proc-macro (`syn`/`quote`) - -**Spec:** `docs/plans/2026-04-06-decision-wrapper-design.md` - ---- - -### Task 1: `OptimizationValue` Trait - -**Files:** -- Modify: `src/types.rs` (append after `Min` impls, ~line 299) - -- [ ] **Step 1: Write failing tests for `OptimizationValue`** - -Create `src/unit_tests/types_optimization_value.rs`: - -```rust -use crate::types::{Max, Min, OptimizationValue}; - -#[test] -fn test_min_meets_bound_feasible() { - // Min(Some(3)) <= 5 → true - assert!(Min::::meets_bound(&Min(Some(3)), &5)); -} - -#[test] -fn test_min_meets_bound_exact() { - // Min(Some(5)) <= 5 → true - assert!(Min::::meets_bound(&Min(Some(5)), &5)); -} - -#[test] -fn test_min_meets_bound_exceeds() { - // Min(Some(7)) <= 5 → false - assert!(!Min::::meets_bound(&Min(Some(7)), &5)); -} - -#[test] -fn test_min_meets_bound_infeasible() { - // Min(None) → false (infeasible config) - assert!(!Min::::meets_bound(&Min(None), &5)); -} - -#[test] -fn test_max_meets_bound_feasible() { - // Max(Some(7)) >= 5 → true - assert!(Max::::meets_bound(&Max(Some(7)), &5)); -} - -#[test] -fn test_max_meets_bound_exact() { - // Max(Some(5)) >= 5 → true - assert!(Max::::meets_bound(&Max(Some(5)), &5)); -} - -#[test] -fn test_max_meets_bound_below() { - // Max(Some(3)) >= 5 → false - assert!(!Max::::meets_bound(&Max(Some(3)), &5)); -} - -#[test] -fn test_max_meets_bound_infeasible() { - // Max(None) → false - assert!(!Max::::meets_bound(&Max(None), &5)); -} -``` - -Add test module link in `src/types.rs` at the bottom: - -```rust -#[cfg(test)] -#[path = "unit_tests/types_optimization_value.rs"] -mod optimization_value_tests; -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test optimization_value -- --nocapture 2>&1 | head -30` -Expected: FAIL — `OptimizationValue` trait not found. - -- [ ] **Step 3: Implement `OptimizationValue` trait** - -Add to `src/types.rs` after the `Min` impl block (after line 299): - -```rust -/// Trait for aggregate values that represent optimization objectives (Min or Max). -/// Enables generic conversion to decision problems via a bound parameter. -pub trait OptimizationValue: Aggregate { - /// The inner numeric type (e.g., `i32` for `Min`). - type Inner: Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned; - - /// Does this evaluation result satisfy the decision bound? - /// - For `Min`: true iff value is Some(v) where v ≤ bound - /// - For `Max`: true iff value is Some(v) where v ≥ bound - fn meets_bound(value: &Self, bound: &Self::Inner) -> bool; -} - -impl OptimizationValue - for Min -{ - type Inner = V; - - fn meets_bound(value: &Self, bound: &V) -> bool { - matches!(&value.0, Some(v) if *v <= *bound) - } -} - -impl OptimizationValue - for Max -{ - type Inner = V; - - fn meets_bound(value: &Self, bound: &V) -> bool { - matches!(&value.0, Some(v) if *v >= *bound) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test optimization_value -- --nocapture` -Expected: All 8 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/types.rs src/unit_tests/types_optimization_value.rs -git commit -m "feat: add OptimizationValue trait for Min/Max decision conversion" -``` - ---- - -### Task 2: `Decision

` Struct + `Problem` Impl - -**Files:** -- Create: `src/models/decision.rs` -- Modify: `src/models/mod.rs` (add `pub mod decision`) - -- [ ] **Step 1: Write failing tests for `Decision

`** - -Create `src/unit_tests/models/decision.rs`: - -```rust -use crate::models::decision::Decision; -use crate::models::graph::MinimumVertexCover; -use crate::models::graph::MaximumIndependentSet; -use crate::topology::SimpleGraph; -use crate::traits::Problem; -use crate::types::Or; - -fn triangle_mvc() -> MinimumVertexCover { - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); - MinimumVertexCover::new(graph, vec![1; 3]) -} - -#[test] -fn test_decision_min_creation() { - let mvc = triangle_mvc(); - let decision = Decision::new(mvc, 2); - assert_eq!(decision.bound(), &2); - assert_eq!(decision.inner().num_vertices(), 3); -} - -#[test] -fn test_decision_min_evaluate_feasible() { - let decision = Decision::new(triangle_mvc(), 2); - // Config [1,1,0]: covers all edges, cost=2 ≤ 2 → Or(true) - assert_eq!(decision.evaluate(&[1, 1, 0]), Or(true)); -} - -#[test] -fn test_decision_min_evaluate_infeasible_cost() { - let decision = Decision::new(triangle_mvc(), 1); - // Config [1,1,0]: covers all edges, cost=2 > 1 → Or(false) - assert_eq!(decision.evaluate(&[1, 1, 0]), Or(false)); -} - -#[test] -fn test_decision_min_evaluate_infeasible_config() { - let decision = Decision::new(triangle_mvc(), 3); - // Config [1,0,0]: does NOT cover edge (1,2) → Min(None) → Or(false) - assert_eq!(decision.evaluate(&[1, 0, 0]), Or(false)); -} - -#[test] -fn test_decision_max_evaluate() { - let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]); - let mis = MaximumIndependentSet::new(graph, vec![1; 4]); - let decision = Decision::new(mis, 2); - // Config [1,0,1,0]: independent, weight=2 ≥ 2 → Or(true) - assert_eq!(decision.evaluate(&[1, 0, 1, 0]), Or(true)); - // Config [1,0,0,0]: independent, weight=1 < 2 → Or(false) - assert_eq!(decision.evaluate(&[1, 0, 0, 0]), Or(false)); -} - -#[test] -fn test_decision_dims() { - let decision = Decision::new(triangle_mvc(), 2); - assert_eq!(decision.dims(), vec![2, 2, 2]); -} - -#[test] -fn test_decision_solver() { - use crate::solvers::BruteForce; - use crate::Solver; - let decision = Decision::new(triangle_mvc(), 2); - let solver = BruteForce::new(); - let witness = solver.find_witness(&decision); - assert!(witness.is_some()); - let config = witness.unwrap(); - // Verify: it's a valid cover with cost ≤ 2 - assert_eq!(decision.evaluate(&config), Or(true)); -} - -#[test] -fn test_decision_serialization() { - let decision = Decision::new(triangle_mvc(), 2); - let json = serde_json::to_string(&decision).unwrap(); - let deserialized: Decision> = - serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.bound(), &2); - assert_eq!(deserialized.evaluate(&[1, 1, 0]), Or(true)); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test test_decision_min -- --nocapture 2>&1 | head -20` -Expected: FAIL — `decision` module not found. - -- [ ] **Step 3: Create `src/models/decision.rs`** - -```rust -//! Generic Decision wrapper for optimization problems. -//! -//! Converts any optimization problem P (with Value = Min or Max) -//! into a decision problem (Value = Or) by adding a bound parameter. - -use crate::traits::Problem; -use crate::types::{OptimizationValue, Or}; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -use std::fmt; - -/// Metadata trait providing the decision problem name for each inner problem type. -/// -/// Implement this for each concrete optimization problem that needs a Decision version. -/// Use the [`decision_problem_meta!`] macro for convenient registration. -pub trait DecisionProblemMeta: Problem -where - Self::Value: OptimizationValue, -{ - /// The NAME constant for the Decision problem type. - const DECISION_NAME: &'static str; -} - -/// Helper macro to register a concrete inner problem's decision name. -/// -/// # Example -/// ```ignore -/// crate::decision_problem_meta!( -/// MinimumVertexCover, -/// "DecisionMinimumVertexCover" -/// ); -/// ``` -#[macro_export] -macro_rules! decision_problem_meta { - ($inner:ty, $name:literal) => { - impl $crate::models::decision::DecisionProblemMeta for $inner { - const DECISION_NAME: &'static str = $name; - } - }; -} - -/// Decision version of an optimization problem. -/// -/// Given an optimization problem P with `Value = Min` or `Value = Max`, -/// `Decision

` asks: "does there exist a configuration with value ≤ bound (for Min) -/// or ≥ bound (for Max)?" -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Decision -where - P::Value: OptimizationValue, -{ - inner: P, - bound: ::Inner, -} - -impl Decision

-where - P::Value: OptimizationValue, -{ - /// Create a new Decision problem from an inner optimization problem and a bound. - pub fn new(inner: P, bound: ::Inner) -> Self { - Self { inner, bound } - } - - /// Get a reference to the inner optimization problem. - pub fn inner(&self) -> &P { - &self.inner - } - - /// Get a reference to the decision bound. - pub fn bound(&self) -> &::Inner { - &self.bound - } -} - -impl

Problem for Decision

-where - P: DecisionProblemMeta + Clone, - P::Value: OptimizationValue - + Clone - + fmt::Debug - + Serialize - + DeserializeOwned, - ::Inner: - Clone + PartialOrd + fmt::Debug + Serialize + DeserializeOwned, -{ - const NAME: &'static str = P::DECISION_NAME; - type Value = Or; - - fn dims(&self) -> Vec { - self.inner.dims() - } - - fn evaluate(&self, config: &[usize]) -> Or { - Or(::meets_bound( - &self.inner.evaluate(config), - &self.bound, - )) - } - - fn variant() -> Vec<(&'static str, &'static str)> { - P::variant() - } -} -``` - -- [ ] **Step 4: Add module to `src/models/mod.rs`** - -Add after `pub mod set;`: - -```rust -pub mod decision; -``` - -Add to the re-export block in `src/models/mod.rs`: - -```rust -pub use decision::Decision; -``` - -- [ ] **Step 5: Add `DecisionProblemMeta` impls for MVC and MIS** - -In `src/models/graph/minimum_vertex_cover.rs`, add after the `declare_variants!` block: - -```rust -crate::decision_problem_meta!( - MinimumVertexCover, - "DecisionMinimumVertexCover" -); -``` - -In `src/models/graph/maximum_independent_set.rs`, add after the `declare_variants!` block: - -```rust -crate::decision_problem_meta!( - MaximumIndependentSet, - "DecisionMaximumIndependentSet" -); -``` - -- [ ] **Step 6: Wire up test file** - -Add to the bottom of `src/models/decision.rs`: - -```rust -#[cfg(test)] -#[path = "../unit_tests/models/decision.rs"] -mod tests; -``` - -- [ ] **Step 7: Run tests to verify they pass** - -Run: `cargo test test_decision -- --nocapture` -Expected: All 8 tests PASS. - -- [ ] **Step 8: Commit** - -```bash -git add src/models/decision.rs src/models/mod.rs src/unit_tests/models/decision.rs \ - src/models/graph/minimum_vertex_cover.rs src/models/graph/maximum_independent_set.rs -git commit -m "feat: add Decision

generic wrapper with Problem impl" -``` - ---- - -### Task 3: `ReduceToAggregate` for `Decision

→ P` - -**Files:** -- Modify: `src/models/decision.rs` (append aggregate reduction) - -- [ ] **Step 1: Write failing test** - -Add to `src/unit_tests/models/decision.rs`: - -```rust -#[test] -fn test_decision_reduce_to_aggregate() { - use crate::rules::ReduceToAggregate; - let mvc = triangle_mvc(); - let decision = Decision::new(mvc, 2); - let result = decision.reduce_to_aggregate(); - let target = result.target_problem(); - // Target is the inner MinimumVertexCover - assert_eq!(target.num_vertices(), 3); - - // Config [1,1,0] on target gives Min(Some(2)) - // extract_value maps Min(Some(2)) to Or(true) since 2 ≤ 2 - use crate::rules::AggregateReductionResult; - let target_val = target.evaluate(&[1, 1, 0]); - let source_val = result.extract_value(target_val); - assert_eq!(source_val, Or(true)); - - // Config [1,1,1] on target gives Min(Some(3)) - // extract_value maps Min(Some(3)) to Or(false) since 3 > 2 - let target_val = target.evaluate(&[1, 1, 1]); - let source_val = result.extract_value(target_val); - assert_eq!(source_val, Or(false)); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cargo test test_decision_reduce_to_aggregate -- --nocapture 2>&1 | head -20` -Expected: FAIL — `ReduceToAggregate` not implemented. - -- [ ] **Step 3: Implement aggregate reduction in `src/models/decision.rs`** - -Add after the `Problem` impl: - -```rust -use crate::rules::traits::{AggregateReductionResult, ReduceToAggregate}; - -/// Result of reducing Decision

to P (aggregate value extraction). -#[derive(Debug, Clone)] -pub struct DecisionToOptimizationResult

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

AggregateReductionResult for DecisionToOptimizationResult

-where - P: Problem + 'static, - P::Value: OptimizationValue + Serialize + DeserializeOwned, - ::Inner: Clone + PartialOrd, -{ - type Source = Decision

; - type Target = P; - - fn target_problem(&self) -> &P { - &self.target - } - - fn extract_value(&self, target_value: P::Value) -> Or { - Or(::meets_bound( - &target_value, - &self.bound, - )) - } -} - -impl

ReduceToAggregate

for Decision

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

; - - fn reduce_to_aggregate(&self) -> Self::Result { - DecisionToOptimizationResult { - target: self.inner().clone(), - bound: self.bound().clone(), - } - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `cargo test test_decision -- --nocapture` -Expected: All 9 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/models/decision.rs src/unit_tests/models/decision.rs -git commit -m "feat: add ReduceToAggregate for Decision

→ P" -``` - ---- - -### Task 4: Proc Macro `extract_type_name()` Fix - -**Files:** -- Modify: `problemreductions-macros/src/lib.rs` (~line 131) - -- [ ] **Step 1: Fix `extract_type_name()` to handle `Decision`** - -Replace the existing `extract_type_name` function at line 131 of `problemreductions-macros/src/lib.rs`: - -```rust -/// Extract the base type name from a Type (e.g., "IndependentSet" from "IndependentSet"). -/// Special-cases "Decision" to produce "DecisionT" (e.g., "DecisionMinimumVertexCover"). -fn extract_type_name(ty: &Type) -> Option { - match ty { - Type::Path(type_path) => { - let segment = type_path.path.segments.last()?; - let ident = segment.ident.to_string(); - - if ident == "Decision" { - if let PathArguments::AngleBracketed(args) = &segment.arguments { - let inner_ty = args.args.iter().find_map(|arg| { - if let GenericArgument::Type(ty) = arg { - Some(ty) - } else { - None - } - })?; - let inner_name = extract_type_name(inner_ty)?; - return Some(format!("Decision{inner_name}")); - } - } - - Some(ident) - } - _ => None, - } -} -``` - -- [ ] **Step 2: Run existing tests to verify no regressions** - -Run: `cargo test -p problemreductions-macros` -Run: `make check` -Expected: All tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add problemreductions-macros/src/lib.rs -git commit -m "fix: extract_type_name handles Decision nested generics" -``` - ---- - -### Task 5: Concrete Decision Variants for MVC and MDS - -**Files:** -- Modify: `src/models/graph/minimum_vertex_cover.rs` -- Modify: `src/models/graph/minimum_dominating_set.rs` - -- [ ] **Step 1: Add Decision variant registration to `minimum_vertex_cover.rs`** - -Add after the existing `declare_variants!` block and the `decision_problem_meta!` (already added in Task 2): - -```rust -// Decision variant: delegates getters to inner problem -impl Decision> { - /// Number of vertices in the underlying graph. - pub fn num_vertices(&self) -> usize { - self.inner().num_vertices() - } - - /// Number of edges in the underlying graph. - pub fn num_edges(&self) -> usize { - self.inner().num_edges() - } - - /// Decision bound as usize (for overhead expressions). - pub fn k(&self) -> usize - where - W::Sum: Into, - { - let b: i64 = self.bound().clone().into(); - b as usize - } -} - -crate::declare_variants! { - default Decision> => "1.1996^num_vertices", -} - -inventory::submit! { - ProblemSchemaEntry { - name: "DecisionMinimumVertexCover", - display_name: "Decision Minimum Vertex Cover", - aliases: &["VertexCover", "VC"], - dimensions: &[ - VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("weight", "i32", &["i32"]), - ], - module_path: module_path!(), - description: "Decision version: does a vertex cover of cost ≤ bound exist?", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (max cost)" }, - ], - } -} -``` - -Add the necessary import at the top of the file: - -```rust -use crate::models::decision::{Decision, DecisionProblemMeta}; -``` - -- [ ] **Step 2: Add Decision variant registration to `minimum_dominating_set.rs`** - -Add `decision_problem_meta!` and variant registration (same pattern): - -```rust -use crate::models::decision::{Decision, DecisionProblemMeta}; - -crate::decision_problem_meta!( - MinimumDominatingSet, - "DecisionMinimumDominatingSet" -); - -impl Decision> { - pub fn num_vertices(&self) -> usize { - self.inner().num_vertices() - } - - pub fn num_edges(&self) -> usize { - self.inner().num_edges() - } - - pub fn k(&self) -> usize - where - W::Sum: Into, - { - let b: i64 = self.bound().clone().into(); - b as usize - } -} - -crate::declare_variants! { - default Decision> => "2^num_vertices", -} - -inventory::submit! { - ProblemSchemaEntry { - name: "DecisionMinimumDominatingSet", - display_name: "Decision Minimum Dominating Set", - aliases: &[], - dimensions: &[ - VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("weight", "i32", &["i32"]), - ], - module_path: module_path!(), - description: "Decision version: does a dominating set of cost ≤ bound exist?", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (max cost)" }, - ], - } -} -``` - -- [ ] **Step 3: Build and test** - -Run: `make check` -Expected: All tests and clippy PASS. The new `DecisionMinimumVertexCover` and `DecisionMinimumDominatingSet` appear in the registry. - -- [ ] **Step 4: Verify registry** - -Run: `cargo run --bin pred -- list | grep -i decision` -Expected: Shows `DecisionMinimumVertexCover` and `DecisionMinimumDominatingSet`. - -- [ ] **Step 5: Commit** - -```bash -git add src/models/graph/minimum_vertex_cover.rs src/models/graph/minimum_dominating_set.rs -git commit -m "feat: register Decision variants for MVC and MDS" -``` - ---- - -### Task 6: Remove Hand-Written `VertexCover` - -**Files:** -- Remove: `src/models/graph/vertex_cover.rs` -- Remove: `src/unit_tests/models/graph/vertex_cover.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` - -- [ ] **Step 1: Remove `VertexCover` from `src/models/graph/mod.rs`** - -Remove the `pub mod vertex_cover;` line and the `pub use vertex_cover::VertexCover;` re-export. - -- [ ] **Step 2: Remove `VertexCover` from `src/models/mod.rs` re-exports** - -Remove `VertexCover,` from the `pub use graph::{...}` block. - -- [ ] **Step 3: Delete the files** - -```bash -rm src/models/graph/vertex_cover.rs -rm src/unit_tests/models/graph/vertex_cover.rs -``` - -- [ ] **Step 4: Fix any remaining references** - -Run: `cargo build 2>&1 | head -40` - -If there are compile errors from other files referencing `VertexCover`, update them to use `Decision>` instead. Based on our research, no reduction files reference `VertexCover` directly, so this should compile cleanly. - -- [ ] **Step 5: Run full test suite** - -Run: `make check` -Expected: PASS. The alias `VertexCover`/`VC` now resolves to `DecisionMinimumVertexCover` via the schema entry. - -- [ ] **Step 6: Commit** - -```bash -git add -A # stages removals + modifications -git commit -m "refactor: remove hand-written VertexCover, replaced by Decision" -``` - ---- - -### Task 7: Golden-Section Search Solver - -**Files:** -- Create: `src/solvers/golden_section.rs` -- Modify: `src/solvers/mod.rs` - -- [ ] **Step 1: Write failing tests** - -Create `src/unit_tests/solvers/golden_section.rs`: - -```rust -use crate::models::decision::Decision; -use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; -use crate::solvers::golden_section::solve_via_decision; -use crate::solvers::BruteForce; -use crate::topology::SimpleGraph; -use crate::traits::Problem; -use crate::types::Aggregate; -use crate::Solver; - -#[test] -fn test_golden_section_min() { - // Path graph 0-1-2: MVC optimum = 1 (just vertex 1) - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MinimumVertexCover::new(graph, vec![1i32; 3]); - let result = solve_via_decision(&problem, 0, 3); - assert_eq!(result, Some(1)); -} - -#[test] -fn test_golden_section_max() { - // Path graph 0-1-2: MIS optimum = 2 (vertices 0 and 2) - let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); - let problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); - let result = solve_via_decision(&problem, 0, 3); - assert_eq!(result, Some(2)); -} - -#[test] -fn test_golden_section_matches_brute_force() { - // Pentagon graph: compare golden-section vs brute-force - let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); - let problem = MinimumVertexCover::new(graph, vec![1i32; 5]); - - let solver = BruteForce::new(); - let bf_value = solver.solve(&problem); - - let gs_value = solve_via_decision(&problem, 0, 5); - assert_eq!(gs_value, bf_value.size().copied()); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test test_golden_section -- --nocapture 2>&1 | head -20` -Expected: FAIL — module not found. - -- [ ] **Step 3: Implement golden-section search solver** - -Create `src/solvers/golden_section.rs`: - -```rust -//! Golden-section search solver for optimization via decision queries. -//! -//! Given an optimization problem P, finds the optimal value by querying -//! Decision

with varying bounds using the golden ratio. -//! -//! Reference: - -use crate::models::decision::{Decision, DecisionProblemMeta}; -use crate::solvers::BruteForce; -use crate::traits::Problem; -use crate::types::{Aggregate, OptimizationValue, Or, Min, Max}; -use crate::Solver; -use serde::de::DeserializeOwned; -use serde::Serialize; -use std::fmt; - -/// Whether a decision problem is satisfiable (any config evaluates to Or(true)). -fn is_satisfiable

(problem: &P) -> bool -where - P: Problem, - Or: Aggregate, -{ - let solver = BruteForce::new(); - let value = solver.solve(problem); - value.0 -} - -/// Solve a Min-valued optimization problem by golden-section search on its -/// decision version. Returns the minimum feasible value. -/// -/// Searches the integer range [lower, upper] for the smallest bound where -/// Decision

is satisfiable. -pub fn solve_via_decision_min

(problem: &P, lower: i32, upper: i32) -> Option -where - P: DecisionProblemMeta + Clone, - P::Value: OptimizationValue - + Clone + fmt::Debug + Serialize + DeserializeOwned, -{ - // First check if any solution exists at all - let decision_upper = Decision::new(problem.clone(), upper); - if !is_satisfiable(&decision_upper) { - return None; - } - - // Binary search for the minimum feasible bound - let mut lo = lower; - let mut hi = upper; - while lo < hi { - let mid = lo + (hi - lo) / 2; - let decision = Decision::new(problem.clone(), mid); - if is_satisfiable(&decision) { - hi = mid; - } else { - lo = mid + 1; - } - } - Some(lo) -} - -/// Solve a Max-valued optimization problem by golden-section search on its -/// decision version. Returns the maximum feasible value. -/// -/// Searches the integer range [lower, upper] for the largest bound where -/// Decision

is satisfiable. -pub fn solve_via_decision_max

(problem: &P, lower: i32, upper: i32) -> Option -where - P: DecisionProblemMeta + Clone, - P::Value: OptimizationValue - + Clone + fmt::Debug + Serialize + DeserializeOwned, -{ - // First check if any solution exists at all - let decision_lower = Decision::new(problem.clone(), lower); - if !is_satisfiable(&decision_lower) { - // For Max: if not satisfiable at lowest bound, check higher - let decision_upper = Decision::new(problem.clone(), upper); - if !is_satisfiable(&decision_upper) { - return None; - } - } - - // Binary search for the maximum feasible bound - let mut lo = lower; - let mut hi = upper; - while lo < hi { - let mid = lo + (hi - lo + 1) / 2; // round up to avoid infinite loop - let decision = Decision::new(problem.clone(), mid); - if is_satisfiable(&decision) { - lo = mid; - } else { - hi = mid - 1; - } - } - - // Verify the found bound is actually feasible - let decision = Decision::new(problem.clone(), lo); - if is_satisfiable(&decision) { - Some(lo) - } else { - None - } -} - -/// Solve an optimization problem by searching its decision version. -/// -/// Dispatches to `solve_via_decision_min` or `solve_via_decision_max` based on -/// the problem's value type. -pub fn solve_via_decision

(problem: &P, lower: i32, upper: i32) -> Option -where - P: DecisionProblemMeta + Clone, - P::Value: OptimizationValue - + Clone + fmt::Debug + Serialize + DeserializeOwned, -{ - // Check if this is a Min or Max problem by evaluating identity properties - // We use a type-based dispatch via the OptimizationValue trait - solve_via_decision_dispatch::

(problem, lower, upper) -} - -/// Internal dispatch — determines Min vs Max at compile time. -fn solve_via_decision_dispatch

(problem: &P, lower: i32, upper: i32) -> Option -where - P: DecisionProblemMeta + Clone, - P::Value: OptimizationValue - + Clone + fmt::Debug + Serialize + DeserializeOwned, -{ - // Try Min direction: search for smallest feasible bound - // If Decision

at upper bound is satisfiable, it's a Min problem - let decision_upper = Decision::new(problem.clone(), upper); - if is_satisfiable(&decision_upper) { - // Could be Min (satisfiable at upper bound) — search down - let decision_lower = Decision::new(problem.clone(), lower); - if !is_satisfiable(&decision_lower) { - // Definitely Min: satisfiable at upper, not at lower - return solve_via_decision_min(problem, lower, upper); - } - // Satisfiable at both bounds — return lower for Min - return solve_via_decision_min(problem, lower, upper); - } - - // Not satisfiable at upper — try Max direction - solve_via_decision_max(problem, lower, upper) -} -``` - -- [ ] **Step 4: Wire up module in `src/solvers/mod.rs`** - -Add after the `pub use customized::CustomizedSolver;` line: - -```rust -pub mod golden_section; -``` - -- [ ] **Step 5: Add test module link in `src/solvers/golden_section.rs`** - -At the bottom of the file: - -```rust -#[cfg(test)] -#[path = "../unit_tests/solvers/golden_section.rs"] -mod tests; -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cargo test test_golden_section -- --nocapture` -Expected: All 3 tests PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/solvers/golden_section.rs src/solvers/mod.rs \ - src/unit_tests/solvers/golden_section.rs -git commit -m "feat: add golden-section search solver via Decision queries" -``` - ---- - -### Task 8: Paper and Example DB Migration - -**Files:** -- Modify: `docs/paper/reductions.typ` (lines 237, 652-667) -- Modify: `src/models/graph/minimum_vertex_cover.rs` (example_db spec) - -- [ ] **Step 1: Update paper display-name dict** - -In `docs/paper/reductions.typ`, change line 237: - -From: `"VertexCover": [Vertex Cover],` -To: `"DecisionMinimumVertexCover": [Decision Minimum Vertex Cover],` - -- [ ] **Step 2: Update paper problem-def for VertexCover** - -Replace the `VertexCover` problem-def block (around lines 652-670) to reference `DecisionMinimumVertexCover`. Update the `load-model-example` call and `problem-def` name accordingly. - -- [ ] **Step 3: Add canonical example for DecisionMinimumVertexCover** - -In `src/models/graph/minimum_vertex_cover.rs`, update the `canonical_model_example_specs()` to include a Decision variant example: - -```rust -#[cfg(feature = "example-db")] -pub(crate) fn decision_canonical_model_example_specs() -> Vec { - vec![crate::example_db::specs::ModelExampleSpec { - id: "decision_minimum_vertex_cover_simplegraph_i32", - instance: Box::new(Decision::new( - MinimumVertexCover::new( - SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), - vec![1i32; 4], - ), - 2, - )), - optimal_config: vec![1, 0, 1, 0], - optimal_value: serde_json::json!(true), - }] -} -``` - -Wire this into the example_db collection system following the existing pattern. - -- [ ] **Step 4: Build and test** - -Run: `make check` -Run: `make paper` (if Typst is available) -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add docs/paper/reductions.typ src/models/graph/minimum_vertex_cover.rs -git commit -m "docs: migrate VertexCover paper entry and example_db to DecisionMinimumVertexCover" -``` - ---- - -### Task 9: Final Integration Check - -- [ ] **Step 1: Run full test suite** - -Run: `make check` -Expected: All tests, clippy, and fmt pass. - -- [ ] **Step 2: Run coverage check** - -Run: `make coverage` -Expected: >95% coverage on new code. - -- [ ] **Step 3: Verify CLI alias backward compatibility** - -Run: `cargo run --bin pred -- show VertexCover` -Run: `cargo run --bin pred -- show VC` -Expected: Both resolve to `DecisionMinimumVertexCover`. - -- [ ] **Step 4: Verify new problems appear in catalog** - -Run: `cargo run --bin pred -- list | grep -i decision` -Expected: Shows both `DecisionMinimumVertexCover` and `DecisionMinimumDominatingSet`. - -- [ ] **Step 5: Final commit if any fixups needed** - -```bash -git add -A -git commit -m "chore: integration fixups for Decision wrapper" -``` diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 36a6d2d9..d30079b6 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -178,7 +178,20 @@ pub(super) fn create_schema_driven( json_map.insert(field.name.clone(), value); } - let data = serde_json::Value::Object(json_map); + // Decision

types serialize as {inner: {graph, weights, ...}, bound} but schema + // fields are flat (graph, weights, bound). Restructure when the canonical name + // indicates a Decision wrapper. + let data = if canonical.starts_with("Decision") { + let bound = json_map + .remove("bound") + .expect("Decision types require a bound field"); + let mut outer = serde_json::Map::new(); + outer.insert("inner".to_string(), serde_json::Value::Object(json_map)); + outer.insert("bound".to_string(), bound); + serde_json::Value::Object(outer) + } else { + serde_json::Value::Object(json_map) + }; validate_schema_driven_semantics(args, canonical, resolved_variant, &data) .map_err(|error| with_schema_usage(error, canonical, resolved_variant))?; (variant_entry.factory)(data.clone()).map_err(|error| { diff --git a/src/models/decision.rs b/src/models/decision.rs index 3aa83014..c2cc0d07 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -25,6 +25,107 @@ macro_rules! decision_problem_meta { }; } +/// Register the boilerplate inventory entries for a concrete `Decision

` variant. +#[macro_export] +macro_rules! register_decision_variant { + ( + $inner:ty, + $name:literal, + $complexity:literal, + $aliases:expr, + $description:literal, + [$($field:expr),* $(,)?] + ) => { + impl $crate::models::decision::Decision<$inner> { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } + + /// Decision bound as a nonnegative integer. + pub fn k(&self) -> usize { + (*self.bound()).try_into().unwrap_or(0) + } + } + + $crate::declare_variants! { + default $crate::models::decision::Decision<$inner> => $complexity, + } + + $crate::inventory::submit! { + $crate::registry::ProblemSchemaEntry { + name: $name, + display_name: $crate::register_decision_variant!(@display_name $name), + aliases: $aliases, + dimensions: &[ + $crate::registry::VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + $crate::registry::VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: $description, + fields: &[$($field),*], + } + } + + $crate::inventory::submit! { + $crate::rules::ReductionEntry { + source_name: $name, + target_name: <$inner as $crate::traits::Problem>::NAME, + source_variant_fn: <$crate::models::decision::Decision<$inner> as $crate::traits::Problem>::variant, + target_variant_fn: <$inner as $crate::traits::Problem>::variant, + overhead_fn: || $crate::rules::ReductionOverhead::identity(&["num_vertices", "num_edges"]), + module_path: module_path!(), + reduce_fn: None, + reduce_aggregate_fn: Some(|any| { + let source = any + .downcast_ref::<$crate::models::decision::Decision<$inner>>() + .expect(concat!($name, " aggregate reduction source type mismatch")); + Box::new( + <$crate::models::decision::Decision<$inner> as $crate::rules::ReduceToAggregate<$inner>>::reduce_to_aggregate(source), + ) + }), + capabilities: $crate::rules::EdgeCapabilities::aggregate_only(), + overhead_eval_fn: |any| { + let source = any + .downcast_ref::<$crate::models::decision::Decision<$inner>>() + .expect(concat!($name, " overhead source type mismatch")); + $crate::types::ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ]) + }, + source_size_fn: |any| { + let source = any + .downcast_ref::<$crate::models::decision::Decision<$inner>>() + .expect(concat!($name, " size source type mismatch")); + $crate::types::ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ("k", source.k()), + ]) + }, + } + } + }; + (@display_name "DecisionMinimumVertexCover") => { + "Decision Minimum Vertex Cover" + }; + (@display_name "DecisionMinimumDominatingSet") => { + "Decision Minimum Dominating Set" + }; + (@display_name "DecisionMaximumIndependentSet") => { + "Decision Maximum Independent Set" + }; + (@display_name $name:literal) => { + $name + }; +} + /// Decision version of an optimization problem with a fixed objective bound. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision diff --git a/src/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index 6568057d..9f7e72a0 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -163,10 +163,14 @@ crate::declare_variants! { MaximumIndependentSet => "2^sqrt(num_vertices)", } -crate::decision_problem_meta!( - MaximumIndependentSet, - "DecisionMaximumIndependentSet" -); +impl crate::models::decision::DecisionProblemMeta for MaximumIndependentSet +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, + W::Sum: std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, +{ + const DECISION_NAME: &'static str = "DecisionMaximumIndependentSet"; +} #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index 0e8bc5c9..bf8edda9 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -3,12 +3,10 @@ //! The Dominating Set problem asks for a minimum weight subset of vertices //! such that every vertex is either in the set or adjacent to a vertex in the set. -use crate::models::decision::Decision; use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; -use crate::rules::{EdgeCapabilities, ReduceToAggregate, ReductionEntry, ReductionOverhead}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, ProblemSize, WeightElement}; +use crate::types::{Min, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -169,88 +167,39 @@ crate::declare_variants! { default MinimumDominatingSet => "1.4969^num_vertices", } -crate::decision_problem_meta!( - MinimumDominatingSet, - "DecisionMinimumDominatingSet" -); - -impl Decision> { - /// Number of vertices in the underlying graph. - pub fn num_vertices(&self) -> usize { - self.inner().num_vertices() - } - - /// Number of edges in the underlying graph. - pub fn num_edges(&self) -> usize { - self.inner().num_edges() - } - - /// Decision bound as a nonnegative integer. - pub fn k(&self) -> usize { - (*self.bound()).try_into().unwrap_or(0) - } -} - -crate::declare_variants! { - default Decision> => "1.4969^num_vertices", -} - -inventory::submit! { - ProblemSchemaEntry { - name: "DecisionMinimumDominatingSet", - display_name: "Decision Minimum Dominating Set", - aliases: &[], - dimensions: &[ - VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("weight", "i32", &["i32"]), - ], - module_path: module_path!(), - description: "Decision version: does a dominating set of cost <= bound exist?", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (maximum allowed dominating-set cost)" }, - ], - } +impl crate::models::decision::DecisionProblemMeta for MinimumDominatingSet +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, + W::Sum: std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, +{ + const DECISION_NAME: &'static str = "DecisionMinimumDominatingSet"; } -inventory::submit! { - ReductionEntry { - source_name: "DecisionMinimumDominatingSet", - target_name: "MinimumDominatingSet", - source_variant_fn: > as Problem>::variant, - target_variant_fn: as Problem>::variant, - overhead_fn: || ReductionOverhead::identity(&["num_vertices", "num_edges"]), - module_path: module_path!(), - reduce_fn: None, - reduce_aggregate_fn: Some(|any| { - let source = any - .downcast_ref::>>() - .expect("DecisionMinimumDominatingSet aggregate reduction source type mismatch"); - Box::new(source.reduce_to_aggregate()) - }), - capabilities: EdgeCapabilities::aggregate_only(), - overhead_eval_fn: |any| { - let source = any - .downcast_ref::>>() - .expect("DecisionMinimumDominatingSet overhead source type mismatch"); - ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), - ]) +crate::register_decision_variant!( + MinimumDominatingSet, + "DecisionMinimumDominatingSet", + "1.4969^num_vertices", + &[], + "Decision version: does a dominating set of cost <= bound exist?", + [ + FieldInfo { + name: "graph", + type_name: "G", + description: "The underlying graph G=(V,E)", }, - source_size_fn: |any| { - let source = any - .downcast_ref::>>() - .expect("DecisionMinimumDominatingSet size source type mismatch"); - ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), - ("k", source.k()), - ]) + FieldInfo { + name: "weights", + type_name: "Vec", + description: "Vertex weights w: V -> R", }, - } -} + FieldInfo { + name: "bound", + type_name: "i32", + description: "Decision bound (maximum allowed dominating-set cost)", + }, + ] +); #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { @@ -265,6 +214,38 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_minimum_dominating_set_to_minimum_dominating_set", + build: || { + use crate::example_db::specs::assemble_rule_example; + use crate::export::SolutionPair; + use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + + let source = crate::models::decision::Decision::new( + MinimumDominatingSet::new( + SimpleGraph::new(5, vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]), + vec![1i32; 5], + ), + 2, + ); + let result = source.reduce_to_aggregate(); + let target = result.target_problem(); + let config = vec![0, 0, 1, 1, 0]; + assemble_rule_example( + &source, + target, + vec![SolutionPair { + source_config: config.clone(), + target_config: config, + }], + ) + }, + }] +} + /// Check if a set of vertices is a dominating set. /// /// # Panics diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 020284c8..7ae7d371 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -3,12 +3,10 @@ //! The Vertex Cover problem asks for a minimum weight subset of vertices //! such that every edge has at least one endpoint in the subset. -use crate::models::decision::Decision; use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; -use crate::rules::{EdgeCapabilities, ReduceToAggregate, ReductionEntry, ReductionOverhead}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; -use crate::types::{Min, One, ProblemSize, WeightElement}; +use crate::types::{Min, One, WeightElement}; use num_traits::Zero; use serde::{Deserialize, Serialize}; @@ -157,88 +155,39 @@ crate::declare_variants! { MinimumVertexCover => "1.1996^num_vertices", } -crate::decision_problem_meta!( - MinimumVertexCover, - "DecisionMinimumVertexCover" -); - -impl Decision> { - /// Number of vertices in the underlying graph. - pub fn num_vertices(&self) -> usize { - self.inner().num_vertices() - } - - /// Number of edges in the underlying graph. - pub fn num_edges(&self) -> usize { - self.inner().num_edges() - } - - /// Decision bound as a nonnegative integer. - pub fn k(&self) -> usize { - (*self.bound()).try_into().unwrap_or(0) - } -} - -crate::declare_variants! { - default Decision> => "1.1996^num_vertices", -} - -inventory::submit! { - ProblemSchemaEntry { - name: "DecisionMinimumVertexCover", - display_name: "Decision Minimum Vertex Cover", - aliases: &["VertexCover", "VC"], - dimensions: &[ - VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - VariantDimension::new("weight", "i32", &["i32"]), - ], - module_path: module_path!(), - description: "Decision version: does a vertex cover of cost <= bound exist?", - fields: &[ - FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, - FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, - FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (maximum allowed cover cost)" }, - ], - } +impl crate::models::decision::DecisionProblemMeta for MinimumVertexCover +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, + W::Sum: std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, +{ + const DECISION_NAME: &'static str = "DecisionMinimumVertexCover"; } -inventory::submit! { - ReductionEntry { - source_name: "DecisionMinimumVertexCover", - target_name: "MinimumVertexCover", - source_variant_fn: > as Problem>::variant, - target_variant_fn: as Problem>::variant, - overhead_fn: || ReductionOverhead::identity(&["num_vertices", "num_edges"]), - module_path: module_path!(), - reduce_fn: None, - reduce_aggregate_fn: Some(|any| { - let source = any - .downcast_ref::>>() - .expect("DecisionMinimumVertexCover aggregate reduction source type mismatch"); - Box::new(source.reduce_to_aggregate()) - }), - capabilities: EdgeCapabilities::aggregate_only(), - overhead_eval_fn: |any| { - let source = any - .downcast_ref::>>() - .expect("DecisionMinimumVertexCover overhead source type mismatch"); - ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), - ]) +crate::register_decision_variant!( + MinimumVertexCover, + "DecisionMinimumVertexCover", + "1.1996^num_vertices", + &["VertexCover", "VC"], + "Decision version: does a vertex cover of cost <= bound exist?", + [ + FieldInfo { + name: "graph", + type_name: "G", + description: "The underlying graph G=(V,E)", }, - source_size_fn: |any| { - let source = any - .downcast_ref::>>() - .expect("DecisionMinimumVertexCover size source type mismatch"); - ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), - ("k", source.k()), - ]) + FieldInfo { + name: "weights", + type_name: "Vec", + description: "Vertex weights w: V -> R", }, - } -} + FieldInfo { + name: "bound", + type_name: "i32", + description: "Decision bound (maximum allowed cover cost)", + }, + ] +); #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { @@ -258,7 +207,7 @@ pub(crate) fn decision_canonical_model_example_specs( ) -> Vec { vec![crate::example_db::specs::ModelExampleSpec { id: "decision_minimum_vertex_cover_simplegraph_i32", - instance: Box::new(Decision::new( + instance: Box::new(crate::models::decision::Decision::new( MinimumVertexCover::new( SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), vec![1i32; 4], @@ -270,6 +219,38 @@ pub(crate) fn decision_canonical_model_example_specs( }] } +#[cfg(feature = "example-db")] +pub(crate) fn decision_canonical_rule_example_specs( +) -> Vec { + vec![crate::example_db::specs::RuleExampleSpec { + id: "decision_minimum_vertex_cover_to_minimum_vertex_cover", + build: || { + use crate::example_db::specs::assemble_rule_example; + use crate::export::SolutionPair; + use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + + let source = crate::models::decision::Decision::new( + MinimumVertexCover::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2), (2, 3)]), + vec![1i32; 4], + ), + 2, + ); + let result = source.reduce_to_aggregate(); + let target = result.target_problem(); + let config = vec![1, 0, 1, 0]; + assemble_rule_example( + &source, + target, + vec![SolutionPair { + source_config: config.clone(), + target_config: config, + }], + ) + }, + }] +} + /// Check if a set of vertices forms a vertex cover. /// /// # Arguments diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 81e25cb0..f0039b76 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -456,6 +456,12 @@ pub(crate) fn canonical_rule_example_specs() -> Vec MinimumVertexCover { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); MinimumVertexCover::new(graph, vec![1; 3]) } +fn star_mds() -> MinimumDominatingSet { + let graph = SimpleGraph::new(5, vec![(0, 1), (0, 2), (0, 3), (0, 4)]); + MinimumDominatingSet::new(graph, vec![One; 5]) +} + #[test] fn test_decision_min_creation() { let mvc = triangle_mvc(); @@ -88,3 +93,75 @@ fn test_decision_reduce_to_aggregate() { let source_val = result.extract_value(target_val); assert_eq!(source_val, Or(false)); } + +#[test] +fn test_decision_reduce_to_aggregate_infeasible_bound() { + use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + + let decision = Decision::new(triangle_mvc(), 1); + let result = decision.reduce_to_aggregate(); + let target = result.target_problem(); + + for mask in 0..8 { + let config = vec![ + (mask & 0b001 != 0) as usize, + (mask & 0b010 != 0) as usize, + (mask & 0b100 != 0) as usize, + ]; + let target_val = target.evaluate(&config); + let source_val = result.extract_value(target_val); + assert_eq!( + source_val, + Or(false), + "config {config:?} should be infeasible" + ); + } +} + +#[test] +fn test_decision_mds_creation() { + let mds = star_mds(); + let decision = Decision::new(mds, 1); + assert_eq!(decision.bound(), &1); + assert_eq!(decision.inner().num_vertices(), 5); +} + +#[test] +fn test_decision_mds_evaluate_feasible() { + let decision = Decision::new(star_mds(), 1); + assert_eq!(decision.evaluate(&[1, 0, 0, 0, 0]), Or(true)); +} + +#[test] +fn test_decision_mds_evaluate_infeasible_cost() { + let decision = Decision::new(star_mds(), 0); + assert_eq!(decision.evaluate(&[1, 0, 0, 0, 0]), Or(false)); +} + +#[test] +fn test_decision_mds_reduce_to_aggregate() { + use crate::rules::{AggregateReductionResult, ReduceToAggregate}; + + let decision = Decision::new(star_mds(), 1); + let result = decision.reduce_to_aggregate(); + let target = result.target_problem(); + assert_eq!(target.num_vertices(), 5); + + let target_val = target.evaluate(&[1, 0, 0, 0, 0]); + let source_val = result.extract_value(target_val); + assert_eq!(source_val, Or(true)); + + let target_val = target.evaluate(&[1, 1, 0, 0, 0]); + let source_val = result.extract_value(target_val); + assert_eq!(source_val, Or(false)); +} + +#[test] +fn test_decision_mds_solver() { + let decision = Decision::new(star_mds(), 1); + let solver = BruteForce::new(); + let witness = solver.find_witness(&decision); + assert!(witness.is_some()); + let config = witness.unwrap(); + assert_eq!(decision.evaluate(&config), Or(true)); +} diff --git a/src/unit_tests/solvers/golden_section.rs b/src/unit_tests/solvers/decision_search.rs similarity index 85% rename from src/unit_tests/solvers/golden_section.rs rename to src/unit_tests/solvers/decision_search.rs index d917591b..78912666 100644 --- a/src/unit_tests/solvers/golden_section.rs +++ b/src/unit_tests/solvers/decision_search.rs @@ -6,7 +6,7 @@ use crate::types::{Max, Min}; use crate::Solver; #[test] -fn test_golden_section_min() { +fn test_decision_search_min() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let problem = MinimumVertexCover::new(graph, vec![1i32; 3]); @@ -14,7 +14,7 @@ fn test_golden_section_min() { } #[test] -fn test_golden_section_max() { +fn test_decision_search_max() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); @@ -22,7 +22,7 @@ fn test_golden_section_max() { } #[test] -fn test_golden_section_matches_brute_force() { +fn test_decision_search_matches_brute_force() { let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); let problem = MinimumVertexCover::new(graph, vec![1i32; 5]); @@ -35,7 +35,7 @@ fn test_golden_section_matches_brute_force() { } #[test] -fn test_golden_section_min_returns_none_when_upper_bound_is_too_small() { +fn test_decision_search_min_returns_none_when_upper_bound_is_too_small() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let problem = MinimumVertexCover::new(graph, vec![1i32; 3]); @@ -43,7 +43,7 @@ fn test_golden_section_min_returns_none_when_upper_bound_is_too_small() { } #[test] -fn test_golden_section_max_returns_none_when_interval_is_above_optimum() { +fn test_decision_search_max_returns_none_when_interval_is_above_optimum() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); @@ -51,7 +51,7 @@ fn test_golden_section_max_returns_none_when_interval_is_above_optimum() { } #[test] -fn test_golden_section_invalid_interval_returns_none() { +fn test_decision_search_invalid_interval_returns_none() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let min_problem = MinimumVertexCover::new(graph.clone(), vec![1i32; 3]); let max_problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); @@ -61,7 +61,7 @@ fn test_golden_section_invalid_interval_returns_none() { } #[test] -fn test_golden_section_preserves_value_direction() { +fn test_decision_search_preserves_value_direction() { let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); let min_problem = MinimumVertexCover::new(graph.clone(), vec![1i32; 3]); let max_problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); From 754c4df01d90d8b3ad6e39bfd6752122df3fd8cc Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 10:04:47 +0800 Subject: [PATCH 12/18] fix: relax flaky kclique_ilp assertion (ILP may return larger valid clique) The K4 graph with k=3 has both size-3 and size-4 valid cliques. The ILP solver nondeterministically picks either, so assert >= k instead of == k. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/unit_tests/rules/kclique_ilp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/unit_tests/rules/kclique_ilp.rs b/src/unit_tests/rules/kclique_ilp.rs index 9bd04e95..6c8628c0 100644 --- a/src/unit_tests/rules/kclique_ilp.rs +++ b/src/unit_tests/rules/kclique_ilp.rs @@ -47,8 +47,8 @@ fn test_solution_extraction() { .expect("solvable"); let extracted = reduction.extract_solution(&ilp_solution); assert_eq!(problem.evaluate(&extracted), Or(true)); - // Should select exactly 3 vertices - assert_eq!(extracted.iter().sum::(), 3); + // Should select at least k=3 vertices (ILP may return a larger valid clique) + assert!(extracted.iter().sum::() >= 3); } #[test] From 1655781880955e2b465eb6e3d6bab8fa429fd84b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 11:30:51 +0800 Subject: [PATCH 13/18] =?UTF-8?q?feat:=20add=20Turing=20(multi-query)=20re?= =?UTF-8?q?duction=20edges=20for=20Optimization=20=E2=86=92=20Decision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers P → Decision

as a Turing reduction edge — representing that solving an optimization problem via its decision version requires multiple adaptive queries (binary search over the bound). - Add `turing` field to EdgeCapabilities and ReductionMode::Turing - Register reverse Turing edges in register_decision_variant! macro - Export turing flag in JSON graph - Turing edges excluded from rule example coverage (no single-shot demo) - Add tests for MVC→DecisionMVC and MDS→DecisionMDS Turing edges Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/decision.rs | 33 +++++++++++++++++++++++++++++++ src/rules/graph.rs | 7 +++++++ src/rules/registry.rs | 15 ++++++++++++++ src/unit_tests/example_db.rs | 2 ++ src/unit_tests/reduction_graph.rs | 29 +++++++++++++++++++++++++++ 5 files changed, 86 insertions(+) diff --git a/src/models/decision.rs b/src/models/decision.rs index c2cc0d07..b07b4a38 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -111,6 +111,39 @@ macro_rules! register_decision_variant { }, } } + + // Reverse edge: P → Decision

(Turing/multi-query reduction via binary search) + $crate::inventory::submit! { + $crate::rules::ReductionEntry { + source_name: <$inner as $crate::traits::Problem>::NAME, + target_name: $name, + source_variant_fn: <$inner as $crate::traits::Problem>::variant, + target_variant_fn: <$crate::models::decision::Decision<$inner> as $crate::traits::Problem>::variant, + overhead_fn: || $crate::rules::ReductionOverhead::identity(&["num_vertices", "num_edges"]), + module_path: module_path!(), + reduce_fn: None, + reduce_aggregate_fn: None, + capabilities: $crate::rules::EdgeCapabilities::turing(), + overhead_eval_fn: |any| { + let source = any + .downcast_ref::<$inner>() + .expect(concat!($name, " turing overhead source type mismatch")); + $crate::types::ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ]) + }, + source_size_fn: |any| { + let source = any + .downcast_ref::<$inner>() + .expect(concat!($name, " turing size source type mismatch")); + $crate::types::ProblemSize::new(vec![ + ("num_vertices", source.num_vertices()), + ("num_edges", source.num_edges()), + ]) + }, + } + } }; (@display_name "DecisionMinimumVertexCover") => { "Decision Minimum Vertex Cover" diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 3143924d..1fcbc132 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -117,6 +117,8 @@ pub(crate) struct EdgeJson { pub(crate) witness: bool, /// Whether the edge supports aggregate/value workflows. pub(crate) aggregate: bool, + /// Whether the edge is a Turing (multi-query) reduction. + pub(crate) turing: bool, } /// A path through the variant-level reduction graph. @@ -253,6 +255,9 @@ pub enum TraversalFlow { pub enum ReductionMode { Witness, Aggregate, + /// Multi-query (Turing) reductions: solving the source requires multiple + /// adaptive queries to the target (e.g., binary search over a bound). + Turing, } /// A tree node for neighbor traversal results. @@ -421,6 +426,7 @@ impl ReductionGraph { match mode { ReductionMode::Witness => edge.capabilities.witness, ReductionMode::Aggregate => edge.capabilities.aggregate, + ReductionMode::Turing => edge.capabilities.turing, } } @@ -1208,6 +1214,7 @@ impl ReductionGraph { doc_path, witness: capabilities.witness, aggregate: capabilities.aggregate, + turing: capabilities.turing, }); } diff --git a/src/rules/registry.rs b/src/rules/registry.rs index d8dc4bb4..be848af2 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -94,6 +94,10 @@ pub type AggregateReduceFn = fn(&dyn Any) -> Box Self { + Self { + witness: false, + aggregate: false, + turing: true, } } } diff --git a/src/unit_tests/example_db.rs b/src/unit_tests/example_db.rs index 471d8e25..04d4751e 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -420,6 +420,8 @@ fn canonical_rule_examples_cover_exactly_authored_direct_reductions() { let direct_reduction_keys: BTreeSet<_> = reduction_entries() .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) .map(|entry| { ( ProblemRef { diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 231886ae..7aca0801 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -765,3 +765,32 @@ fn test_decision_minimum_dominating_set_has_direct_aggregate_edge() { ReductionMode::Witness, )); } + +#[test] +fn test_optimization_to_decision_turing_edges() { + let graph = ReductionGraph::new(); + + // MinimumVertexCover → DecisionMinimumVertexCover (Turing) + assert!(graph.has_direct_reduction_by_name_mode( + "MinimumVertexCover", + "DecisionMinimumVertexCover", + ReductionMode::Turing, + )); + assert!(!graph.has_direct_reduction_by_name_mode( + "MinimumVertexCover", + "DecisionMinimumVertexCover", + ReductionMode::Witness, + )); + assert!(!graph.has_direct_reduction_by_name_mode( + "MinimumVertexCover", + "DecisionMinimumVertexCover", + ReductionMode::Aggregate, + )); + + // MinimumDominatingSet → DecisionMinimumDominatingSet (Turing) + assert!(graph.has_direct_reduction_by_name_mode( + "MinimumDominatingSet", + "DecisionMinimumDominatingSet", + ReductionMode::Turing, + )); +} From 7d0b11bfece81a398ab9e9133bea39fe9a024fc6 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 11:39:38 +0800 Subject: [PATCH 14/18] refactor: parameterize register_decision_variant! size fields The macro previously hardcoded num_vertices/num_edges as size getters, which only works for graph problems. Now accepts dims, fields, and size_getters as parameters so non-graph Decision variants can specify their own problem-size fields (e.g., num_vars/num_clauses for SAT). Callers define inherent methods on Decision

before invoking the macro. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/decision.rs | 50 ++++++++-------------- src/models/graph/minimum_dominating_set.rs | 45 +++++++++++-------- src/models/graph/minimum_vertex_cover.rs | 45 +++++++++++-------- 3 files changed, 74 insertions(+), 66 deletions(-) diff --git a/src/models/decision.rs b/src/models/decision.rs index b07b4a38..c7c0ac38 100644 --- a/src/models/decision.rs +++ b/src/models/decision.rs @@ -26,6 +26,14 @@ macro_rules! decision_problem_meta { } /// Register the boilerplate inventory entries for a concrete `Decision

` variant. +/// +/// The `size_getters` parameter defines problem-specific size fields as +/// `(name, getter_on_inner)` pairs, e.g., `[("num_vertices", num_vertices), ("num_edges", num_edges)]`. +/// These are used for overhead expressions and `ProblemSize` extraction. +/// The macro automatically adds a `("k", k)` entry for `source_size_fn` on the Decision side. +/// +/// Callers must define inherent methods on `Decision` (delegating to `self.inner()`) +/// and a `k()` method (from `self.bound()`) **before** invoking this macro. #[macro_export] macro_rules! register_decision_variant { ( @@ -34,25 +42,10 @@ macro_rules! register_decision_variant { $complexity:literal, $aliases:expr, $description:literal, - [$($field:expr),* $(,)?] + dims: [$($dim:expr),* $(,)?], + fields: [$($field:expr),* $(,)?], + size_getters: [$(($sg_name:literal, $sg_method:ident)),* $(,)?] ) => { - impl $crate::models::decision::Decision<$inner> { - /// Number of vertices in the underlying graph. - pub fn num_vertices(&self) -> usize { - self.inner().num_vertices() - } - - /// Number of edges in the underlying graph. - pub fn num_edges(&self) -> usize { - self.inner().num_edges() - } - - /// Decision bound as a nonnegative integer. - pub fn k(&self) -> usize { - (*self.bound()).try_into().unwrap_or(0) - } - } - $crate::declare_variants! { default $crate::models::decision::Decision<$inner> => $complexity, } @@ -62,10 +55,7 @@ macro_rules! register_decision_variant { name: $name, display_name: $crate::register_decision_variant!(@display_name $name), aliases: $aliases, - dimensions: &[ - $crate::registry::VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), - $crate::registry::VariantDimension::new("weight", "i32", &["i32"]), - ], + dimensions: &[$($dim),*], module_path: module_path!(), description: $description, fields: &[$($field),*], @@ -78,7 +68,7 @@ macro_rules! register_decision_variant { target_name: <$inner as $crate::traits::Problem>::NAME, source_variant_fn: <$crate::models::decision::Decision<$inner> as $crate::traits::Problem>::variant, target_variant_fn: <$inner as $crate::traits::Problem>::variant, - overhead_fn: || $crate::rules::ReductionOverhead::identity(&["num_vertices", "num_edges"]), + overhead_fn: || $crate::rules::ReductionOverhead::identity(&[$($sg_name),*]), module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: Some(|any| { @@ -95,8 +85,7 @@ macro_rules! register_decision_variant { .downcast_ref::<$crate::models::decision::Decision<$inner>>() .expect(concat!($name, " overhead source type mismatch")); $crate::types::ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), + $(($sg_name, source.$sg_method())),* ]) }, source_size_fn: |any| { @@ -104,8 +93,7 @@ macro_rules! register_decision_variant { .downcast_ref::<$crate::models::decision::Decision<$inner>>() .expect(concat!($name, " size source type mismatch")); $crate::types::ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), + $(($sg_name, source.$sg_method()),)* ("k", source.k()), ]) }, @@ -119,7 +107,7 @@ macro_rules! register_decision_variant { target_name: $name, source_variant_fn: <$inner as $crate::traits::Problem>::variant, target_variant_fn: <$crate::models::decision::Decision<$inner> as $crate::traits::Problem>::variant, - overhead_fn: || $crate::rules::ReductionOverhead::identity(&["num_vertices", "num_edges"]), + overhead_fn: || $crate::rules::ReductionOverhead::identity(&[$($sg_name),*]), module_path: module_path!(), reduce_fn: None, reduce_aggregate_fn: None, @@ -129,8 +117,7 @@ macro_rules! register_decision_variant { .downcast_ref::<$inner>() .expect(concat!($name, " turing overhead source type mismatch")); $crate::types::ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), + $(($sg_name, source.$sg_method())),* ]) }, source_size_fn: |any| { @@ -138,8 +125,7 @@ macro_rules! register_decision_variant { .downcast_ref::<$inner>() .expect(concat!($name, " turing size source type mismatch")); $crate::types::ProblemSize::new(vec![ - ("num_vertices", source.num_vertices()), - ("num_edges", source.num_edges()), + $(($sg_name, source.$sg_method())),* ]) }, } diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index bf8edda9..8fc69826 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -3,6 +3,7 @@ //! The Dominating Set problem asks for a minimum weight subset of vertices //! such that every vertex is either in the set or adjacent to a vertex in the set. +use crate::models::decision::Decision; use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; @@ -176,29 +177,39 @@ where const DECISION_NAME: &'static str = "DecisionMinimumDominatingSet"; } +impl Decision> { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } + + /// Decision bound as a nonnegative integer. + pub fn k(&self) -> usize { + (*self.bound()).try_into().unwrap_or(0) + } +} + crate::register_decision_variant!( MinimumDominatingSet, "DecisionMinimumDominatingSet", "1.4969^num_vertices", &[], "Decision version: does a dominating set of cost <= bound exist?", - [ - FieldInfo { - name: "graph", - type_name: "G", - description: "The underlying graph G=(V,E)", - }, - FieldInfo { - name: "weights", - type_name: "Vec", - description: "Vertex weights w: V -> R", - }, - FieldInfo { - name: "bound", - type_name: "i32", - description: "Decision bound (maximum allowed dominating-set cost)", - }, - ] + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + fields: [ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (maximum allowed dominating-set cost)" }, + ], + size_getters: [("num_vertices", num_vertices), ("num_edges", num_edges)] ); #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 7ae7d371..0a510b27 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -3,6 +3,7 @@ //! The Vertex Cover problem asks for a minimum weight subset of vertices //! such that every edge has at least one endpoint in the subset. +use crate::models::decision::Decision; use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; @@ -164,29 +165,39 @@ where const DECISION_NAME: &'static str = "DecisionMinimumVertexCover"; } +impl Decision> { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.inner().num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.inner().num_edges() + } + + /// Decision bound as a nonnegative integer. + pub fn k(&self) -> usize { + (*self.bound()).try_into().unwrap_or(0) + } +} + crate::register_decision_variant!( MinimumVertexCover, "DecisionMinimumVertexCover", "1.1996^num_vertices", &["VertexCover", "VC"], "Decision version: does a vertex cover of cost <= bound exist?", - [ - FieldInfo { - name: "graph", - type_name: "G", - description: "The underlying graph G=(V,E)", - }, - FieldInfo { - name: "weights", - type_name: "Vec", - description: "Vertex weights w: V -> R", - }, - FieldInfo { - name: "bound", - type_name: "i32", - description: "Decision bound (maximum allowed cover cost)", - }, - ] + dims: [ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + fields: [ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "bound", type_name: "i32", description: "Decision bound (maximum allowed cover cost)" }, + ], + size_getters: [("num_vertices", num_vertices), ("num_edges", num_edges)] ); #[cfg(feature = "example-db")] From 72c2d5d229bafda2abf2096d7ab83e028de01337 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 11:50:58 +0800 Subject: [PATCH 15/18] docs: update CLAUDE.md and add-model skill for Decision wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document Decision

wrapper, OptimizationValue trait, decision_search solver - Document EdgeCapabilities.turing field and ReductionMode::Turing - Document register_decision_variant! macro with dims/fields/size_getters params - Document Decision↔P completeness filter in paper section - Add anti-pattern entry in add-model skill: use Decision

not hand-written models Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/CLAUDE.md | 23 +++++++++++++++++------ .claude/skills/add-model/SKILL.md | 1 + 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 405f6c26..d83bdb06 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -90,7 +90,8 @@ make release V=x.y.z # Tag and push a new release (CI publishes to crates.io) - `misc/` - Unique input structures - Run `pred list` for the full catalog of problems, variants, and reductions; `pred show ` for details on a specific problem - `src/rules/` - Reduction rules + inventory registration -- `src/solvers/` - BruteForce solver for aggregate values plus witness recovery when supported, ILP solver (feature-gated, witness-only). To check if a problem supports ILP solving via a witness-capable reduction path, run `pred path ILP` +- `src/models/decision.rs` - Generic `Decision

` wrapper converting optimization problems to decision problems +- `src/solvers/` - BruteForce solver for aggregate values plus witness recovery when supported, ILP solver (feature-gated, witness-only), decision search (binary search via Decision queries). To check if a problem supports ILP solving via a witness-capable reduction path, run `pred path ILP` - `src/traits.rs` - `Problem` trait - `src/rules/traits.rs` - `ReduceTo`, `ReduceToAggregate`, `ReductionResult`, `AggregateReductionResult` traits - `src/registry/` - Compile-time reduction metadata collection @@ -121,14 +122,22 @@ Problem (core trait — all problems must implement) **Aggregate-only problems** use fold values such as `Sum` or `And`; these solve to a value but have no representative witness configuration. +**Decision problems** wrap an optimization problem with a bound: `Decision

` where `P::Value: OptimizationValue`. Evaluates to `Or(true)` when the inner objective meets the bound (≤ for Min, ≥ for Max). + Common aggregate wrappers live in `src/types.rs`: ```rust Max, Min, Sum, Or, And, Extremum, ExtremumSense ``` +`OptimizationValue` trait (in `src/types.rs`) enables generic Decision conversion: +- `Min`: meets bound when value ≤ bound +- `Max`: meets bound when value ≥ bound + ### Key Patterns - `variant_params!` macro implements `Problem::variant()` — e.g., `crate::variant_params![G, W]` for two type params, `crate::variant_params![]` for none (see `src/variant.rs`) - `declare_variants!` proc macro registers concrete type instantiations with best-known complexity and registry-backed load/serialize/value-solve/witness-solve metadata. One entry per problem may be marked `default`, and variable names in complexity strings are validated at compile time against actual getter methods. +- `decision_problem_meta!` macro registers `DecisionProblemMeta` for a concrete inner type, providing the `DECISION_NAME` constant. +- `register_decision_variant!` macro generates `declare_variants!`, `ProblemSchemaEntry`, and both `ReductionEntry` submissions (aggregate Decision→Opt + Turing Opt→Decision) for a `Decision

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

` before invoking. Accepts `dims`, `fields`, and `size_getters` parameters for problem-specific size fields. - Problems parameterized by graph type `G` and optionally weight type `W` (problem-dependent) - `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()` @@ -170,14 +179,16 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: - Default variant ranking: `SimpleGraph`, `One`, `KN` are considered default values; variants with the most default values sort first - Nodes come exclusively from `#[reduction]` registrations; natural edges between same-name variants are inferred from the graph/weight subtype partial order - Each primitive reduction is determined by the exact `(source_variant, target_variant)` endpoint pair -- Reduction edges carry `EdgeCapabilities { witness, aggregate }`; graph search defaults to witness mode, and aggregate mode is available through `ReductionMode::Aggregate` -- `#[reduction]` accepts only `overhead = { ... }` and currently registers witness/config reductions; aggregate-only edges require manual `ReductionEntry` registration with `reduce_aggregate_fn` +- Reduction edges carry `EdgeCapabilities { witness, aggregate, turing }`; graph search defaults to witness mode, aggregate mode is available through `ReductionMode::Aggregate`, and Turing (multi-query) mode via `ReductionMode::Turing` +- `#[reduction]` accepts only `overhead = { ... }` and currently registers witness/config reductions; aggregate-only and Turing edges require manual `ReductionEntry` registration +- `Decision

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

` is a Turing edge (binary search over decision bound) ### Extension Points - New models register dynamic load/serialize/brute-force dispatch through `declare_variants!` in the model file, not by adding manual match arms in the CLI - **CLI creation is schema-driven:** `pred create` automatically maps `ProblemSchemaEntry` fields to CLI flags via `snake_case → kebab-case` convention. New models need only: (1) matching CLI flags in `CreateArgs` + `flag_map()`, and (2) type parser support in `parse_field_value()` if using a new field type. No match arm in `create.rs` is needed. - **CLI flag names must match schema field names.** The canonical name for a CLI flag is the schema field name in kebab-case (e.g., schema field `universe_size` → `--universe-size`, field `subsets` → `--subsets`). Old aliases (e.g., `--universe`, `--sets`) may exist as clap `alias` for backward compatibility at the clap level, but `flag_map()`, help text, error messages, and documentation must use the schema-derived name. Do not add new backward-compat aliases; if a field is renamed in the schema, update the CLI flag name to match. -- Aggregate-only models are first-class in `declare_variants!`; aggregate-only reduction edges still need manual `ReductionEntry` wiring because `#[reduction]` only registers witness/config reductions today +- **Decision variants** of optimization problems use `Decision

` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision`, (3) `register_decision_variant!` with `dims`, `fields`, `size_getters`. Schema-driven CLI creation auto-restructures flat JSON into `{inner: {...}, bound}`. +- 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` @@ -194,8 +205,8 @@ Reduction graph nodes use variant key-value pairs from `Problem::variant()`: ### Paper (docs/paper/reductions.typ) - `problem-def(name)[def][body]` — defines a problem with auto-generated schema, reductions list, and label ``. Title comes from `display-name` dict. - `reduction-rule(source, target, example: bool, ...)[rule][proof]` — generates a theorem with label `` and registers in `covered-rules` state. Overhead auto-derived from JSON edge data. -- Every directed reduction needs its own `reduction-rule` entry -- Completeness warnings auto-check that all JSON graph nodes/edges are covered in the paper +- Every directed reduction needs its own `reduction-rule` entry (except trivial Decision↔Optimization pairs which are auto-filtered) +- Completeness warnings auto-check that all JSON graph nodes/edges are covered in the paper; `Decision

↔ P` edges are excluded since they are trivial solve-and-compare reductions - `display-name` dict maps `ProblemName` to display text ## Testing Requirements diff --git a/.claude/skills/add-model/SKILL.md b/.claude/skills/add-model/SKILL.md index 95d89b0b..54f4c229 100644 --- a/.claude/skills/add-model/SKILL.md +++ b/.claude/skills/add-model/SKILL.md @@ -311,6 +311,7 @@ Structural and quality review is handled by the `review-pipeline` stage, not her | Wrong aggregate wrapper | Use `Max` / `Min` / `Extremum` for objective problems, `Or` for existential witness problems, and `Sum` / `And` (or a custom aggregate) for value-only folds | | Wrong `declare_variants!` syntax | Entries no longer use `opt` / `sat`; one entry per problem may be marked `default` | | Forgetting CLI alias | Must add lowercase entry in `problem_name.rs` `resolve_alias()` | +| Adding a hand-written decision model | Use `Decision

` wrapper instead — see `decision_problem_meta!` + `register_decision_variant!` in `src/models/graph/minimum_vertex_cover.rs` for the pattern | | Inventing short aliases | Only use well-established literature abbreviations (MIS, SAT, TSP); do NOT invent new ones | | Forgetting CLI flags | Schema-driven create needs matching CLI flags in `CreateArgs` for each `ProblemSchemaEntry` field (snake_case → kebab-case). Also add to `flag_map()`. | | Missing type parser | If the problem uses a new field type, add a handler in `parse_field_value()` in `create.rs` | From 5f04fefd884ddb9e90e99b74f698695e393faa05 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 13:07:54 +0800 Subject: [PATCH 16/18] fix: CLI compat for Decision types (--k alias, MDS example) - Map --k to --bound for Decision types in schema_field_flag_keys so `pred create VC --k 2` works (backward compat with old VertexCover) - Add canonical model example for DecisionMinimumDominatingSet so `pred create --example DecisionMinimumDominatingSet` works Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/commands/create/schema_support.rs | 4 ++++ src/models/graph/minimum_dominating_set.rs | 17 +++++++++++++++++ src/models/graph/mod.rs | 1 + 3 files changed, 22 insertions(+) diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index d30079b6..46a69be6 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -409,6 +409,10 @@ pub(super) fn schema_field_flag_keys( keys.push(display_key); } } + // Decision types accept --k as an alias for --bound (backward compat with old VertexCover) + if field_name == "bound" && canonical.starts_with("Decision") && !keys.contains(&"k".into()) { + keys.push("k".into()); + } keys } diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index 8fc69826..683dc016 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -225,6 +225,23 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "decision_minimum_dominating_set_simplegraph_i32", + instance: Box::new(Decision::new( + MinimumDominatingSet::new( + SimpleGraph::new(5, vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]), + vec![1i32; 5], + ), + 2, + )), + optimal_config: vec![0, 0, 1, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + #[cfg(feature = "example-db")] pub(crate) fn decision_canonical_rule_example_specs( ) -> Vec { diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 0535dc20..cb7ad098 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -254,6 +254,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec Date: Tue, 7 Apr 2026 13:19:06 +0800 Subject: [PATCH 17/18] refactor: remove backward-compat aliases (VertexCover/VC/--k) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes are allowed at this stage. Remove: - VertexCover and VC aliases from DecisionMinimumVertexCover schema - --k → --bound mapping for Decision types - Replace with DMVC alias Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create/schema_support.rs | 4 ---- problemreductions-cli/src/problem_name.rs | 4 +--- src/models/graph/minimum_vertex_cover.rs | 2 +- src/unit_tests/registry/schema.rs | 2 +- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 46a69be6..d30079b6 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -409,10 +409,6 @@ pub(super) fn schema_field_flag_keys( keys.push(display_key); } } - // Decision types accept --k as an alias for --bound (backward compat with old VertexCover) - if field_name == "bound" && canonical.starts_with("Decision") && !keys.contains(&"k".into()) { - keys.push("k".into()); - } keys } diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 1077effc..453bfe6b 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -309,9 +309,7 @@ mod tests { resolve_alias("biconnectivityaugmentation"), "BiconnectivityAugmentation" ); - // VertexCover aliases now resolve to the Decision wrapper - assert_eq!(resolve_alias("VC"), "DecisionMinimumVertexCover"); - assert_eq!(resolve_alias("VertexCover"), "DecisionMinimumVertexCover"); + assert_eq!(resolve_alias("DMVC"), "DecisionMinimumVertexCover"); // Pass-through for full names assert_eq!( resolve_alias("MaximumIndependentSet"), diff --git a/src/models/graph/minimum_vertex_cover.rs b/src/models/graph/minimum_vertex_cover.rs index 0a510b27..ae74aefa 100644 --- a/src/models/graph/minimum_vertex_cover.rs +++ b/src/models/graph/minimum_vertex_cover.rs @@ -186,7 +186,7 @@ crate::register_decision_variant!( MinimumVertexCover, "DecisionMinimumVertexCover", "1.1996^num_vertices", - &["VertexCover", "VC"], + &["DMVC"], "Decision version: does a vertex cover of cost <= bound exist?", dims: [ VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 5b856abd..234eb389 100644 --- a/src/unit_tests/registry/schema.rs +++ b/src/unit_tests/registry/schema.rs @@ -95,7 +95,7 @@ fn test_decision_problem_schema_entries_registered() { .iter() .find(|entry| entry.name == "DecisionMinimumVertexCover") .expect("DecisionMinimumVertexCover schema should be registered"); - assert_eq!(mvc.aliases, ["VertexCover", "VC"]); + assert_eq!(mvc.aliases, ["DMVC"]); assert!(mvc.fields.iter().any(|field| field.name == "bound")); assert_eq!(mvc.dimensions.len(), 2); assert!( From a5ae10b9e98782788e22a627087b9a66babbc93b Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Tue, 7 Apr 2026 14:06:17 +0800 Subject: [PATCH 18/18] fix: Typst syntax errors in paper completeness filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use if/else instead of `not ... and` (Typst precedence issue) - Fix RR_ge → RR_(gt.eq 0) in DecisionMVC math notation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 03fc0bf6..0d4572ef 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -668,7 +668,7 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| let cover = sol.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) [ #problem-def("DecisionMinimumVertexCover")[ - Given an undirected graph $G = (V, E)$ with vertex weights $w: V -> RR_ge 0$ and an integer bound $k$, determine whether there exists a vertex cover $S subset.eq V$ with $sum_(v in S) w(v) <= k$ such that every edge has at least one endpoint in $S$. + Given an undirected graph $G = (V, E)$ with vertex weights $w: V -> RR_(gt.eq 0)$ and an integer bound $k$, determine whether there exists a vertex cover $S subset.eq V$ with $sum_(v in S) w(v) <= k$ such that every edge has at least one endpoint in $S$. ][ Decision Minimum Vertex Cover is the decision version of Minimum Vertex Cover and one of Karp's 21 NP-complete problems @karp1972 @garey1979. It asks whether the optimization objective can be achieved within a prescribed budget rather than minimizing the cover weight directly. @@ -13102,8 +13102,8 @@ See #link("https://github.com/CodingThrust/problem-reductions/blob/main/examples src == "Decision" + tgt or tgt == "Decision" + src } let missing = json-edges.filter(e => { - not is-decision-opt-pair(e.at(0), e.at(1)) and - covered.find(c => c.at(0) == e.at(0) and c.at(1) == e.at(1)) == none + if is-decision-opt-pair(e.at(0), e.at(1)) { false } + else { covered.find(c => c.at(0) == e.at(0) and c.at(1) == e.at(1)) == none } }) if missing.len() > 0 { block(width: 100%, inset: (x: 1em, y: 0.5em), fill: rgb("#fff3cd"), stroke: (left: 3pt + rgb("#ffc107")))[