Design
This guide covers the library internals for contributors.
Module Architecture
Problem Model
Every problem implements Problem. The associated Value type is the per-configuration aggregate returned by evaluate(). Solvers fold these values across the configuration space, and witness-capable aggregates can also recover representative configurations.
trait Problem: Clone {
const NAME: &'static str; // e.g., "MaximumIndependentSet"
type Value: Clone; // e.g., Max<i32>, Or, Sum<i32>
fn dims(&self) -> Vec<usize>; // config space per variable
fn evaluate(&self, config: &[usize]) -> Self::Value;
fn variant() -> Vec<(&'static str, &'static str)>; // e.g., [("graph", "SimpleGraph"), ("weight", "i32")]
fn num_variables(&self) -> usize; // default: dims().len()
fn problem_type() -> ProblemType; // default: registry lookup by NAME
}
Problem— the base trait. Every problem declares aNAME(e.g.,"MaximumIndependentSet"). The solver explores the configuration space defined bydims()and scores each configuration withevaluate(). For example, a 4-vertex MIS hasdims() = [2, 2, 2, 2](each vertex is selected or not);evaluate(&[1, 0, 1, 0])returnsMax(Some(2))if vertices 0 and 2 form an independent set, orMax(None)if they share an edge. Each problem also provides inherent getter methods (e.g.,num_vertices(),num_edges()) used by reduction overhead expressions.- Witness-capable objective problems — typically use
Max<V>,Min<V>, orExtremum<V>asValue. - Witness-capable feasibility problems — typically use
Or. - Aggregate-only problems — use fold values such as
Sum<W>orAnd; these solve to a value but do not admit representative witness configurations. - Common aggregate wrappers —
Max<V>,Min<V>,Sum<W>,Or,And,Extremum<V>,ExtremumSense.
Variant System
A single problem name like MaximumIndependentSet can have multiple variants — carrying weights on vertices, or defined on a restricted topology (e.g., king's subgraph). Variants form a subtype hierarchy: independent sets on king's subgraphs are a subset of independent sets on unit-disk graphs. The reduction from a more specific variant to a less specific one is a variant cast — an identity mapping where indices are preserved.
Variant types fall into three categories:
- Graph type —
SimpleGraph(root),PlanarGraph,BipartiteGraph,UnitDiskGraph,KingsSubgraph,TriangularSubgraph. - Weight type —
One(unweighted),i32,f64. - K value — e.g.,
K3for 3-SAT,KNfor arbitrary K.
Implementation details: VariantParam trait and macros
VariantParam trait
Each variant parameter type implements VariantParam, which declares its category, value, and optional parent:
pub trait VariantParam: 'static {
const CATEGORY: &'static str; // e.g., "graph", "weight", "k"
const VALUE: &'static str; // e.g., "SimpleGraph", "i32"
const PARENT_VALUE: Option<&'static str>; // None for root types
}
Types with a parent also implement CastToParent, providing the runtime conversion for variant casts:
pub trait CastToParent: VariantParam {
type Parent: VariantParam;
fn cast_to_parent(&self) -> Self::Parent;
}
Registration with impl_variant_param!
The impl_variant_param! macro implements VariantParam (and optionally CastToParent / KValue) for a type:
// Root type (no parent):
impl_variant_param!(SimpleGraph, "graph");
// K root (arbitrary K):
impl_variant_param!(KN, "k", k: None);
// Specific K with parent:
impl_variant_param!(K3, "k", parent: KN, cast: |_| KN, k: Some(3));
Variant cast reductions with impl_variant_reduction!
When a more specific variant needs to be treated as a less specific one, an explicit variant cast reduction is declared:
impl_variant_reduction!(
MaximumIndependentSet,
<KingsSubgraph, i32> => <UnitDiskGraph, i32>,
fields: [num_vertices, num_edges],
|src| MaximumIndependentSet::new(
src.graph().cast_to_parent(), src.weights().to_vec())
);
Composing Problem::variant()
The variant_params! macro composes the Problem::variant() body from type parameter names:
// MaximumIndependentSet<G: VariantParam, W: VariantParam>
fn variant() -> Vec<(&'static str, &'static str)> {
crate::variant_params![G, W]
// e.g., MaximumIndependentSet<UnitDiskGraph, One>
// -> vec![("graph", "UnitDiskGraph"), ("weight", "One")]
}
Reduction Rules
A reduction requires two pieces: a result struct and a ReduceTo<T> impl.
The result struct holds the target problem and the logic to map solutions back:
#[derive(Debug, Clone)]
pub struct ReductionISToVC<W> {
target: MinimumVertexCover<SimpleGraph, W>,
}
impl<W: WeightElement + VariantParam> ReductionResult for ReductionISToVC<W> {
type Source = MaximumIndependentSet<SimpleGraph, W>;
type Target = MinimumVertexCover<SimpleGraph, W>;
fn target_problem(&self) -> &Self::Target { &self.target }
fn extract_solution(&self, target_sol: &[usize]) -> Vec<usize> {
target_sol.iter().map(|&x| 1 - x).collect() // complement
}
}
The #[reduction] attribute on the ReduceTo<T> impl registers the reduction in the global registry (via inventory):
#[reduction(overhead = {
num_vertices = "num_vertices",
num_edges = "num_edges",
})]
impl ReduceTo<MinimumVertexCover<SimpleGraph, i32>>
for MaximumIndependentSet<SimpleGraph, i32>
{
type Result = ReductionISToVC<i32>;
fn reduce_to(&self) -> Self::Result { /* ... */ }
}
What the #[reduction] macro generates
The #[reduction] attribute expands to the original impl block plus an inventory::submit! call:
inventory::submit! {
ReductionEntry {
source_name: "MaximumIndependentSet",
target_name: "MinimumVertexCover",
source_variant_fn: || <MaximumIndependentSet<SimpleGraph, i32> as Problem>::variant(),
target_variant_fn: || <MinimumVertexCover<SimpleGraph, i32> as Problem>::variant(),
overhead_fn: || ReductionOverhead {
output_size: vec![
("num_vertices", Expr::Var("num_vertices")),
("num_edges", Expr::Var("num_edges")),
],
},
module_path: module_path!(),
reduce_fn: |src: &dyn Any| -> Box<dyn DynReductionResult> {
let src = src.downcast_ref::<MaximumIndependentSet<SimpleGraph, i32>>().unwrap();
Box::new(ReduceTo::<MinimumVertexCover<SimpleGraph, i32>>::reduce_to(src))
},
}
}
Each ReductionEntry is collected by inventory at link time and iterated at runtime, making every reduction discoverable by ReductionGraph without manual registration. The reduce_fn field provides a type-erased executor that enables dynamically discovered paths to chain reductions automatically.
Reduction Graph
ReductionGraph::new() iterates all registered ReductionEntry items (via inventory) and builds a variant-level directed graph:
- Nodes are unique
(problem_name, variant)pairs — e.g.,("MaximumIndependentSet", {graph: "KingsSubgraph", weight: "i32"}). - Edges come exclusively from
#[reduction]registrations — both cross-problem reductions and variant casts. There are no auto-generated edges.
Exported files:
- reduction_graph.json — all problem variants and reduction edges
- problem_schemas.json — field definitions for each problem type
These JSON assets are generated during make doc, make mdbook, and make paper; they are build artifacts, not committed source files.
Generate them manually with cargo run --example export_graph and cargo run --example export_schemas when you need the raw exports locally.
Path finding
All path-finding operates on exact variant nodes. Use ReductionGraph::variant_to_map(&T::variant()) to convert a Problem::variant() into the required BTreeMap<String, String>.
| Method | Algorithm | Use case |
|---|---|---|
find_cheapest_path(src, src_var, dst, dst_var, input_size, cost_fn) | Dijkstra | Optimal path under a cost function |
find_all_paths(src, src_var, dst, dst_var) | All simple paths | Enumerate every route |
Use find_cheapest_path with MinimizeSteps for fewest-hops search.
The PathCostFn trait (used by find_cheapest_path) computes edge cost from overhead and current problem size:
| Cost function | Strategy |
|---|---|
MinimizeSteps | Minimize number of hops (unit edge cost) |
Minimize("field") | Minimize a single output field (e.g., Minimize("num_variables")) |
CustomCost(closure) | User-defined: |overhead: &ReductionOverhead, size: &ProblemSize| -> f64 |
CustomCost wraps a closure that receives the edge's ReductionOverhead (polynomial mapping from input to output size fields) and the current ProblemSize (accumulated field values at that point in the path), and returns an f64 edge cost. Dijkstra minimizes the total cost along the path.
Example: Finding a path from MIS{KingsSubgraph, i32} to VC{SimpleGraph, i32}:
MIS{KingsSubgraph,i32} -> MIS{UnitDiskGraph,i32} -> MIS{SimpleGraph,i32} -> VC{SimpleGraph,i32}
variant cast variant cast reduction
Executable paths
Convert a ReductionPath into a typed ExecutablePath<S, T> via make_executable(), then call reduce():
// find_cheapest_path returns a ReductionPath (list of variant node IDs)
let rpath = graph.find_cheapest_path("Factoring", &src_var,
"SpinGlass", &dst_var, &ProblemSize::new(vec![]), &MinimizeSteps).unwrap();
// make_executable converts it into a typed, callable chain
let path = graph.make_executable::<Factoring, SpinGlass<SimpleGraph, f64>>(&rpath).unwrap();
// reduce() applies each step, returning a ChainedReduction
let reduction = path.reduce(&factoring_instance);
let target: &SpinGlass<SimpleGraph, f64> = reduction.target_problem();
let solution: Vec<usize> = reduction.extract_solution(&target_solution);
ExecutablePath holds a type-erased ReduceFn per edge. reduce() applies them sequentially, producing a ChainedReduction that stores each intermediate result. extract_solution maps the final solution back through the chain in reverse order.
For full type control, you can also chain ReduceTo::reduce_to() calls manually at each step.
Overhead evaluation
Each reduction declares how the output problem size relates to the input, expressed as symbolic Expr expressions. The #[reduction] macro parses overhead strings at compile time:
#[reduction(overhead = {
num_vars = "num_vertices + num_edges",
num_clauses = "3 * num_edges",
})]
impl ReduceTo<Target> for Source { ... }
Expressions support: constants, variables, +, *, ^, exp(), log(), sqrt(). Each problem type provides inherent getter methods (e.g., num_vertices(), num_edges()) that the overhead expressions reference.
evaluate_output_size(input) substitutes input values:
Input: ProblemSize { num_vertices: 10, num_edges: 15 }
Output: ProblemSize { num_vars: 25, num_clauses: 45 }
For multi-step paths, overhead composes: the output of step N becomes the input of step N+1. Variant cast edges use ReductionOverhead::identity(), passing through all fields unchanged.
Solvers
Solvers implement the Solver trait:
pub trait Solver {
fn solve<P>(&self, problem: &P) -> P::Value
where
P: Problem,
P::Value: Aggregate;
}
| Solver | Description |
|---|---|
| BruteForce | Enumerates all configurations. solve() works for any aggregate problem; find_witness(), find_all_witnesses(), and solve_with_witnesses() are available when P::Value supports witnesses. Used for testing and verification. |
| ILPSolver | Enabled by default. Solves ILP instances directly with HiGHS via good_lp. Also provides solve_reduced() for witness-capable problems that implement ReduceTo<ILP<bool>>. |
JSON Serialization
All problem types support JSON serialization via serde:
use problemreductions::io::{to_json, from_json};
let json: String = to_json(&problem)?;
let restored: MaximumIndependentSet<SimpleGraph, i32> = from_json(&json)?;
Contributing
See Call for Contributions for the recommended issue-based workflow (no coding required).