diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 405f6c260..d83bdb067 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 95d89b0bf..54f4c2292 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` | diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index d6244171a..0d4572efb 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,7 +244,7 @@ "MinimumDisjunctiveNormalForm": [Minimum Disjunctive Normal Form], "MinimumGraphBandwidth": [Minimum Graph Bandwidth], "MinimumMetricDimension": [Minimum Metric Dimension], - "VertexCover": [Vertex Cover], + "DecisionMinimumVertexCover": [Decision Minimum Vertex Cover], "MinimumCodeGenerationUnlimitedRegisters": [Minimum Code Generation (Unlimited Registers)], "RegisterSufficiency": [Register Sufficiency], "ResourceConstrainedScheduling": [Resource Constrained Scheduling], @@ -649,22 +659,23 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| } #{ - let x = load-model-example("VertexCover") + 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.k + let k = x.instance.bound 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'$. + #problem-def("DecisionMinimumVertexCover")[ + 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$. ][ - 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). + 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 $V' = {#cover.map(i => $v_#i$).join(", ")}$ with $|V'| = #cover.len() <= k$ is a valid vertex cover. + *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 VertexCover -o vc.json", + "pred create --example DecisionMinimumVertexCover -o vc.json", "pred solve vc.json", "pred evaluate vc.json --config " + sol.map(str).join(","), ) @@ -13086,8 +13097,13 @@ 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 => { - 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")))[ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 0c5aa3674..b7437de2c 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 85b13712c..4b8b88232 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -17,12 +17,13 @@ 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, ThreePartition, }; +use problemreductions::models::Decision; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ @@ -672,6 +673,19 @@ fn ser_vertex_weight_problem_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, + )) +} + fn ser(problem: T) -> Result { util::ser(problem) } @@ -1787,6 +1801,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 +1919,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 +2280,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 23390e6ef..d30079b65 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| { @@ -1501,8 +1514,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 b0289060a..f9c4d44fa 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 24a51eb73..453bfe6b6 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 alias - assert_eq!(resolve_alias("VC"), "VertexCover"); - assert_eq!(resolve_alias("VertexCover"), "VertexCover"); + assert_eq!(resolve_alias("DMVC"), "DecisionMinimumVertexCover"); // Pass-through for full names assert_eq!( resolve_alias("MaximumIndependentSet"), diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index e82983283..8cdcda052 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() { diff --git a/src/models/decision.rs b/src/models/decision.rs new file mode 100644 index 000000000..c7c0ac38a --- /dev/null +++ b/src/models/decision.rs @@ -0,0 +1,250 @@ +//! 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. +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; + } + }; +} + +/// 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 { + ( + $inner:ty, + $name:literal, + $complexity:literal, + $aliases:expr, + $description:literal, + dims: [$($dim:expr),* $(,)?], + fields: [$($field:expr),* $(,)?], + size_getters: [$(($sg_name:literal, $sg_method:ident)),* $(,)?] + ) => { + $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: &[$($dim),*], + 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(&[$($sg_name),*]), + 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![ + $(($sg_name, source.$sg_method())),* + ]) + }, + 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![ + $(($sg_name, source.$sg_method()),)* + ("k", source.k()), + ]) + }, + } + } + + // 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(&[$($sg_name),*]), + 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![ + $(($sg_name, source.$sg_method())),* + ]) + }, + source_size_fn: |any| { + let source = any + .downcast_ref::<$inner>() + .expect(concat!($name, " turing size source type mismatch")); + $crate::types::ProblemSize::new(vec![ + $(($sg_name, source.$sg_method())),* + ]) + }, + } + } + }; + (@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 +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() + } +} + +/// 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/models/graph/maximum_independent_set.rs b/src/models/graph/maximum_independent_set.rs index f72ab3679..9f7e72a08 100644 --- a/src/models/graph/maximum_independent_set.rs +++ b/src/models/graph/maximum_independent_set.rs @@ -163,6 +163,15 @@ crate::declare_variants! { MaximumIndependentSet => "2^sqrt(num_vertices)", } +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 { vec![ diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index 8b3f69e52..683dc0168 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; @@ -167,6 +168,50 @@ crate::declare_variants! { default MinimumDominatingSet => "1.4969^num_vertices", } +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"; +} + +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?", + 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")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { @@ -180,6 +225,55 @@ 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 { + 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 d10a9eac6..ae74aefa9 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; @@ -155,6 +156,50 @@ crate::declare_variants! { MinimumVertexCover => "1.1996^num_vertices", } +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"; +} + +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", + &["DMVC"], + "Decision version: does a vertex cover of cost <= bound exist?", + 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")] pub(crate) fn canonical_model_example_specs() -> Vec { vec![crate::example_db::specs::ModelExampleSpec { @@ -168,6 +213,55 @@ 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(crate::models::decision::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), + }] +} + +#[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/models/graph/mod.rs b/src/models/graph/mod.rs index f5f395945..cb7ad098c 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(); @@ -239,6 +234,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec 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 b072878a1..22a31e9bd 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, @@ -43,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/rules/graph.rs b/src/rules/graph.rs index 3143924d5..1fcbc1327 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/mod.rs b/src/rules/mod.rs index 81e25cb0f..f0039b768 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -456,6 +456,12 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Box Self { + Self { + witness: false, + aggregate: false, + turing: true, } } } diff --git a/src/solvers/decision_search.rs b/src/solvers/decision_search.rs new file mode 100644 index 000000000..d69a9804a --- /dev/null +++ b/src/solvers/decision_search.rs @@ -0,0 +1,109 @@ +//! Decision-guided binary 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/decision_search.rs"] +mod tests; diff --git a/src/solvers/mod.rs b/src/solvers/mod.rs index b9d2f89d4..9a1283cfc 100644 --- a/src/solvers/mod.rs +++ b/src/solvers/mod.rs @@ -2,6 +2,7 @@ mod brute_force; pub mod customized; +pub mod decision_search; #[cfg(feature = "ilp-solver")] pub mod ilp; diff --git a/src/types.rs b/src/types.rs index 4d14f6ca2..cc859423b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -298,6 +298,35 @@ 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 +586,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/example_db.rs b/src/unit_tests/example_db.rs index 1a577fcac..04d4751ef 100644 --- a/src/unit_tests/example_db.rs +++ b/src/unit_tests/example_db.rs @@ -198,6 +198,26 @@ fn test_find_model_example_minimum_dummy_activities_pert() { ); } +#[test] +fn test_find_model_example_decision_minimum_vertex_cover() { + let problem = ProblemRef { + name: "DecisionMinimumVertexCover".to_string(), + variant: BTreeMap::from([ + ("graph".to_string(), "SimpleGraph".to_string()), + ("weight".to_string(), "i32".to_string()), + ]), + }; + + let example = + find_model_example(&problem).expect("DecisionMinimumVertexCover example should exist"); + assert_eq!(example.problem, "DecisionMinimumVertexCover"); + assert_eq!(example.variant, problem.variant); + assert_eq!(example.instance["bound"], 2); + assert_eq!(example.instance["inner"]["graph"]["num_vertices"], 4); + assert_eq!(example.optimal_config, vec![1, 0, 1, 0]); + assert_eq!(example.optimal_value, serde_json::json!(true)); +} + #[test] fn test_find_rule_example_mvc_to_mis_contains_full_problem_json() { let source = ProblemRef { @@ -400,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 { @@ -564,22 +586,35 @@ fn rule_specs_solution_pairs_are_consistent() { ) .unwrap_or_else(|e| panic!("Failed to load target for {label}: {e}")); - // Re-run the reduction to get extract_solution for round-trip check - let chain = graph - .reduce_along_path( - &graph - .find_cheapest_path( - &example.source.problem, - &example.source.variant, - &example.target.problem, - &example.target.variant, - &crate::types::ProblemSize::new(vec![]), - &crate::rules::MinimizeSteps, - ) - .unwrap_or_else(|| panic!("No reduction path for {label}")), - source.as_any(), - ) - .unwrap_or_else(|| panic!("Failed to reduce along path for {label}")); + // Try witness path first; fall back to aggregate for aggregate-only edges + let witness_path = graph.find_cheapest_path( + &example.source.problem, + &example.source.variant, + &example.target.problem, + &example.target.variant, + &crate::types::ProblemSize::new(vec![]), + &crate::rules::MinimizeSteps, + ); + let aggregate_only = witness_path.is_none(); + if aggregate_only { + // Verify the aggregate path exists + let agg_path = graph.find_cheapest_path_mode( + &example.source.problem, + &example.source.variant, + &example.target.problem, + &example.target.variant, + crate::rules::ReductionMode::Aggregate, + &crate::types::ProblemSize::new(vec![]), + &crate::rules::MinimizeSteps, + ); + assert!( + agg_path.is_some(), + "No reduction path (witness or aggregate) for {label}" + ); + } + + // Only do witness round-trip when a witness path exists + let chain = witness_path.and_then(|path| graph.reduce_along_path(&path, source.as_any())); for pair in &example.solutions { // Verify config lengths match problem dimensions @@ -597,7 +632,7 @@ fn rule_specs_solution_pairs_are_consistent() { pair.target_config.len(), target.dims_dyn().len() ); - // Verify configs produce feasible witness-capable evaluations. + // Verify configs produce feasible evaluations. let source_eval = source.evaluate_dyn(&pair.source_config); let target_eval = target.evaluate_dyn(&pair.target_config); let source_val = source.evaluate_json(&pair.source_config); @@ -626,16 +661,18 @@ fn rule_specs_solution_pairs_are_consistent() { "Rule {label}: target_config evaluates to Or(false)" ); // Round-trip: extract_solution(target_config) must produce a valid - // source config with the same evaluation value - let extracted = chain.extract_solution(&pair.target_config); - let extracted_val = source.evaluate_json(&extracted); - assert_eq!( - extracted_val, source_val, - "Rule {label}: round-trip value mismatch: \ - evaluate(extract_solution(target_config)) = {} but evaluate(source_config) = {} \ - (extracted: {:?}, stored: {:?})", - extracted_val, source_val, extracted, pair.source_config - ); + // source config with the same evaluation value (witness paths only) + if let Some(ref chain) = chain { + let extracted = chain.extract_solution(&pair.target_config); + let extracted_val = source.evaluate_json(&extracted); + assert_eq!( + extracted_val, source_val, + "Rule {label}: round-trip value mismatch: \ + evaluate(extract_solution(target_config)) = {} but evaluate(source_config) = {} \ + (extracted: {:?}, stored: {:?})", + extracted_val, source_val, extracted, pair.source_config + ); + } } } } diff --git a/src/unit_tests/models/decision.rs b/src/unit_tests/models/decision.rs new file mode 100644 index 000000000..e78a2d19b --- /dev/null +++ b/src/unit_tests/models/decision.rs @@ -0,0 +1,167 @@ +use crate::models::decision::Decision; +use crate::models::graph::{MaximumIndependentSet, MinimumDominatingSet, MinimumVertexCover}; +use crate::solvers::BruteForce; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::{One, Or}; + +fn triangle_mvc() -> 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(); + 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)); +} + +#[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)); +} + +#[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/models/graph/vertex_cover.rs b/src/unit_tests/models/graph/vertex_cover.rs deleted file mode 100644 index 76de3efa1..000000000 --- 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/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 105344d5f..7aca0801b 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -733,3 +733,64 @@ 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, + )); +} + +#[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, + )); +} diff --git a/src/unit_tests/registry/schema.rs b/src/unit_tests/registry/schema.rs index 675b2ddf1..234eb3899 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,45 @@ 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_eq!(mvc.aliases, ["DMVC"]); + 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() + .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" + ); +} diff --git a/src/unit_tests/rules/kclique_ilp.rs b/src/unit_tests/rules/kclique_ilp.rs index 9bd04e95c..6c8628c0a 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] diff --git a/src/unit_tests/solvers/decision_search.rs b/src/unit_tests/solvers/decision_search.rs new file mode 100644 index 000000000..78912666a --- /dev/null +++ b/src/unit_tests/solvers/decision_search.rs @@ -0,0 +1,76 @@ +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_decision_search_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_decision_search_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_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]); + + let brute_force_value = BruteForce::new().solve(&problem); + + assert_eq!( + solve_via_decision(&problem, 0, 5), + brute_force_value.size().copied() + ); +} + +#[test] +fn test_decision_search_min_returns_none_when_upper_bound_is_too_small() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MinimumVertexCover::new(graph, vec![1i32; 3]); + + assert_eq!(solve_via_decision(&problem, 0, 0), None); +} + +#[test] +fn test_decision_search_max_returns_none_when_interval_is_above_optimum() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let problem = MaximumIndependentSet::new(graph, vec![1i32; 3]); + + assert_eq!(solve_via_decision(&problem, 3, 4), None); +} + +#[test] +fn test_decision_search_invalid_interval_returns_none() { + let graph = SimpleGraph::new(3, vec![(0, 1), (1, 2)]); + let min_problem = MinimumVertexCover::new(graph.clone(), vec![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_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]); + + 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)); +} diff --git a/src/unit_tests/types_optimization_value.rs b/src/unit_tests/types_optimization_value.rs new file mode 100644 index 000000000..2c4a69006 --- /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)); +}