Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4f10a15
docs: add Decision wrapper design spec and implementation plan
GiggleLiu Apr 6, 2026
7468899
feat: add OptimizationValue trait for Min/Max decision conversion
GiggleLiu Apr 6, 2026
b46fa3b
feat: add Decision<P> generic wrapper with Problem impl
GiggleLiu Apr 6, 2026
63abe4e
feat: add ReduceToAggregate<P> impl for Decision<P>
GiggleLiu Apr 6, 2026
52ffb59
fix: extract_type_name handles Decision<T> nested generics
GiggleLiu Apr 6, 2026
7436969
feat: register Decision variants for MVC and MDS
GiggleLiu Apr 6, 2026
ec9cf85
refactor: remove hand-written VertexCover, replaced by Decision<Minim…
GiggleLiu Apr 6, 2026
1020d8f
feat: add golden-section search solver via Decision queries
GiggleLiu Apr 6, 2026
98b3216
docs: migrate VertexCover paper entry and example_db to DecisionMinim…
GiggleLiu Apr 6, 2026
4ea52d8
chore: integration fixups for Decision wrapper
GiggleLiu Apr 6, 2026
6471863
fix: address PR #1014 review comments and quality issues
GiggleLiu Apr 7, 2026
754c4df
fix: relax flaky kclique_ilp assertion (ILP may return larger valid c…
GiggleLiu Apr 7, 2026
1655781
feat: add Turing (multi-query) reduction edges for Optimization → Dec…
GiggleLiu Apr 7, 2026
7d0b11b
refactor: parameterize register_decision_variant! size fields
GiggleLiu Apr 7, 2026
72c2d5d
docs: update CLAUDE.md and add-model skill for Decision wrapper
GiggleLiu Apr 7, 2026
5f04fef
fix: CLI compat for Decision types (--k alias, MDS example)
GiggleLiu Apr 7, 2026
921dd49
refactor: remove backward-compat aliases (VertexCover/VC/--k)
GiggleLiu Apr 7, 2026
a5ae10b
fix: Typst syntax errors in paper completeness filter
GiggleLiu Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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 <ProblemName> ILP`
- `src/models/decision.rs` - Generic `Decision<P>` 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 <ProblemName> ILP`
- `src/traits.rs` - `Problem` trait
- `src/rules/traits.rs` - `ReduceTo<T>`, `ReduceToAggregate<T>`, `ReductionResult`, `AggregateReductionResult` traits
- `src/registry/` - Compile-time reduction metadata collection
Expand Down Expand Up @@ -121,14 +122,22 @@ Problem (core trait — all problems must implement)

**Aggregate-only problems** use fold values such as `Sum<W>` or `And`; these solve to a value but have no representative witness configuration.

**Decision problems** wrap an optimization problem with a bound: `Decision<P>` 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<V>, Min<V>, Sum<W>, Or, And, Extremum<V>, ExtremumSense
```

`OptimizationValue` trait (in `src/types.rs`) enables generic Decision conversion:
- `Min<V>`: meets bound when value ≤ bound
- `Max<V>`: 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<P>` variant. Callers must define inherent getters (`num_vertices()`, `num_edges()`, `k()`) on `Decision<P>` 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()`
Expand Down Expand Up @@ -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> → P` is an aggregate-only edge (solve optimization, compare to bound); `P → Decision<P>` 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<P>` wrapper. Add via: (1) `decision_problem_meta!` for the inner type, (2) inherent methods on `Decision<Inner>`, (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`
Expand All @@ -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 `<def:ProblemName>`. Title comes from `display-name` dict.
- `reduction-rule(source, target, example: bool, ...)[rule][proof]` — generates a theorem with label `<thm:Source-to-Target>` 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> ↔ P` edges are excluded since they are trivial solve-and-compare reductions
- `display-name` dict maps `ProblemName` to display text

## Testing Requirements
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/add-model/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<P>` 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` |
Expand Down
38 changes: 27 additions & 11 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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(","),
)
Expand Down Expand Up @@ -13086,8 +13097,13 @@ See #link("https://github.com/CodingThrust/problem-reductions/blob/main/examples
}
unique
}
// Skip trivial Decision<P> ↔ 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")))[
Expand Down
2 changes: 1 addition & 1 deletion problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI help now advertises DecisionMinimumVertexCover --graph, --weights, --bound, but schema-driven creation currently constructs JSON using the schema field names (top-level graph/weights/bound). Since Decision<...> deserializes from { inner: {...}, bound }, pred create DecisionMinimumVertexCover --graph ... --weights ... --bound ... will fail unless the model/serde or CLI creation path is updated to match.

Suggested change
DecisionMinimumVertexCover --graph, --weights, --bound
DecisionMinimumVertexCover use JSON input ({\"inner\": {...}, \"bound\": ...})

Copilot uses AI. Check for mistakes.
MinimumMultiwayCut --graph, --terminals, --edge-weights
MonochromaticTriangle --graph
PartitionIntoTriangles --graph
Expand Down
98 changes: 73 additions & 25 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -672,6 +673,19 @@ fn ser_vertex_weight_problem_with<G: Graph + Serialize>(
}
}

fn ser_decision_minimum_vertex_cover_with<
G: Graph + Serialize + problemreductions::variant::VariantParam,
>(
graph: G,
weights: Vec<i32>,
bound: i32,
) -> Result<serde_json::Value> {
ser(Decision::new(
MinimumVertexCover::new(graph, weights),
bound,
))
}

fn ser<T: Serialize>(problem: T) -> Result<serde_json::Value> {
util::ser(problem)
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)"
),
Expand Down
Loading
Loading