diff --git a/docs/paper/examples/travelingsalesman_to_qubo.json b/docs/paper/examples/travelingsalesman_to_qubo.json new file mode 100644 index 000000000..c063cacdc --- /dev/null +++ b/docs/paper/examples/travelingsalesman_to_qubo.json @@ -0,0 +1,139 @@ +{ + "source": { + "problem": "TravelingSalesman", + "variant": { + "weight": "i32", + "graph": "SimpleGraph" + }, + "instance": { + "num_edges": 3, + "num_vertices": 3 + } + }, + "target": { + "problem": "QUBO", + "variant": { + "weight": "f64" + }, + "instance": { + "matrix": [ + [ + -14.0, + 14.0, + 14.0, + 14.0, + 1.0, + 1.0, + 14.0, + 2.0, + 2.0 + ], + [ + 0.0, + -14.0, + 14.0, + 1.0, + 14.0, + 1.0, + 2.0, + 14.0, + 2.0 + ], + [ + 0.0, + 0.0, + -14.0, + 1.0, + 1.0, + 14.0, + 2.0, + 2.0, + 14.0 + ], + [ + 0.0, + 0.0, + 0.0, + -14.0, + 14.0, + 14.0, + 14.0, + 3.0, + 3.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + -14.0, + 14.0, + 3.0, + 14.0, + 3.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -14.0, + 3.0, + 3.0, + 14.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -14.0, + 14.0, + 14.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -14.0, + 14.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -14.0 + ] + ], + "num_vars": 9 + } + }, + "overhead": [ + { + "field": "num_vars", + "expr": { + "Pow": [ + { + "Var": "num_vertices" + }, + { + "Const": 2.0 + } + ] + }, + "formula": "num_vertices^2" + } + ] +} \ No newline at end of file diff --git a/docs/paper/examples/travelingsalesman_to_qubo.result.json b/docs/paper/examples/travelingsalesman_to_qubo.result.json new file mode 100644 index 000000000..cec9a77b8 --- /dev/null +++ b/docs/paper/examples/travelingsalesman_to_qubo.result.json @@ -0,0 +1,112 @@ +{ + "solutions": [ + { + "source_config": [ + 1, + 1, + 1 + ], + "target_config": [ + 0, + 0, + 1, + 0, + 1, + 0, + 1, + 0, + 0 + ] + }, + { + "source_config": [ + 1, + 1, + 1 + ], + "target_config": [ + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 1, + 0 + ] + }, + { + "source_config": [ + 1, + 1, + 1 + ], + "target_config": [ + 0, + 1, + 0, + 0, + 0, + 1, + 1, + 0, + 0 + ] + }, + { + "source_config": [ + 1, + 1, + 1 + ], + "target_config": [ + 0, + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 1 + ] + }, + { + "source_config": [ + 1, + 1, + 1 + ], + "target_config": [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 1, + 0 + ] + }, + { + "source_config": [ + 1, + 1, + 1 + ], + "target_config": [ + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ] + } + ] +} \ No newline at end of file diff --git a/examples/reduction_travelingsalesman_to_qubo.rs b/examples/reduction_travelingsalesman_to_qubo.rs new file mode 100644 index 000000000..8fde9e895 --- /dev/null +++ b/examples/reduction_travelingsalesman_to_qubo.rs @@ -0,0 +1,142 @@ +// # Traveling Salesman to QUBO Reduction (Penalty Method) +// +// ## Mathematical Relationship +// The TSP on a graph G = (V, E) with edge weights is mapped to QUBO using +// position-based encoding. Each vertex v and position k has a binary variable +// x_{v,k}, with penalties enforcing: +// +// 1. Assignment constraint: each vertex appears exactly once in the tour +// 2. Position constraint: each position has exactly one vertex +// 3. Edge constraint: consecutive positions use valid edges +// 4. Objective: total edge weight of the tour +// +// The QUBO has n^2 variables (n vertices x n positions). +// +// ## This Example +// - Instance: K3 complete graph with edge weights [1, 2, 3] +// (w01=1, w02=2, w12=3) +// - Source: TravelingSalesman on 3 vertices, 3 edges +// - QUBO variables: 9 (3^2 = 9, position encoding) +// - Optimal tour cost = 6 (all edges used: 1 + 2 + 3) +// +// ## Outputs +// - `docs/paper/examples/travelingsalesman_to_qubo.json` — reduction structure +// - `docs/paper/examples/travelingsalesman_to_qubo.result.json` — solutions +// +// ## Usage +// ```bash +// cargo run --example reduction_travelingsalesman_to_qubo +// ``` + +use problemreductions::export::*; +use problemreductions::prelude::*; +use problemreductions::topology::{Graph, SimpleGraph}; + +pub fn run() { + println!("=== TravelingSalesman -> QUBO Reduction ===\n"); + + // K3 complete graph with edge weights: w01=1, w02=2, w12=3 + let graph = SimpleGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + let tsp = TravelingSalesman::new(graph, vec![1i32, 2, 3]); + + // Reduce to QUBO + let reduction = ReduceTo::::reduce_to(&tsp); + let qubo = reduction.target_problem(); + + println!( + "Source: TravelingSalesman on K3 ({} vertices, {} edges)", + tsp.graph().num_vertices(), + tsp.graph().num_edges() + ); + println!( + "Target: QUBO with {} variables (position encoding: 3 vertices x 3 positions)", + qubo.num_variables() + ); + println!("Q matrix:"); + for row in qubo.matrix() { + let formatted: Vec = row.iter().map(|v| format!("{:8.1}", v)).collect(); + println!(" [{}]", formatted.join(", ")); + } + + // Solve QUBO with brute force + let solver = BruteForce::new(); + let qubo_solutions = solver.find_all_best(qubo); + + // Extract and verify solutions + println!("\nOptimal QUBO solutions: {}", qubo_solutions.len()); + let mut solutions = Vec::new(); + for sol in &qubo_solutions { + let extracted = reduction.extract_solution(sol); + let edge_names = ["(0,1)", "(0,2)", "(1,2)"]; + let selected: Vec<&str> = extracted + .iter() + .enumerate() + .filter(|(_, &v)| v == 1) + .map(|(i, _)| edge_names[i]) + .collect(); + println!(" Edges: {}", selected.join(", ")); + + // Closed-loop verification: check solution is valid in original problem + let metric = tsp.evaluate(&extracted); + assert!(metric.is_valid(), "Tour must be valid in source problem"); + println!(" Cost: {:?}", metric); + + solutions.push(SolutionPair { + source_config: extracted, + target_config: sol.clone(), + }); + } + + // Cross-check with brute force on original problem + let bf_solutions = solver.find_all_best(&tsp); + let bf_metric = tsp.evaluate(&bf_solutions[0]); + let qubo_metric = tsp.evaluate(&reduction.extract_solution(&qubo_solutions[0])); + assert_eq!( + bf_metric, qubo_metric, + "QUBO reduction must match brute force optimum" + ); + + println!( + "\nVerification passed: optimal tour cost matches brute force ({:?})", + bf_metric + ); + + // Export JSON + let source_variant = variant_to_map(TravelingSalesman::::variant()); + let target_variant = variant_to_map(QUBO::::variant()); + let overhead = lookup_overhead( + "TravelingSalesman", + &source_variant, + "QUBO", + &target_variant, + ) + .expect("TravelingSalesman -> QUBO overhead not found"); + + let data = ReductionData { + source: ProblemSide { + problem: TravelingSalesman::::NAME.to_string(), + variant: source_variant, + instance: serde_json::json!({ + "num_vertices": tsp.graph().num_vertices(), + "num_edges": tsp.graph().num_edges(), + }), + }, + target: ProblemSide { + problem: QUBO::::NAME.to_string(), + variant: target_variant, + instance: serde_json::json!({ + "num_vars": qubo.num_vars(), + "matrix": qubo.matrix(), + }), + }, + overhead: overhead_to_json(&overhead), + }; + + let results = ResultData { solutions }; + let name = "travelingsalesman_to_qubo"; + write_example(name, &data, &results); +} + +fn main() { + run() +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs index fa77a18dc..a77a49023 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -33,6 +33,7 @@ mod spinglass_casts; mod spinglass_maxcut; mod spinglass_qubo; mod traits; +mod travelingsalesman_qubo; pub mod unitdiskmapping; diff --git a/src/rules/travelingsalesman_qubo.rs b/src/rules/travelingsalesman_qubo.rs new file mode 100644 index 000000000..09a94b152 --- /dev/null +++ b/src/rules/travelingsalesman_qubo.rs @@ -0,0 +1,167 @@ +//! Reduction from TravelingSalesman to QUBO. +//! +//! Uses the standard position-based QUBO encoding for TSP: +//! - Binary variables x_{v,p} = 1 iff vertex v is at position p in the tour +//! - H_A: each vertex appears exactly once (row constraint) +//! - H_B: each position has exactly one vertex (column constraint) +//! - H_C: objective encoding edge costs between consecutive positions + +use crate::models::algebraic::QUBO; +use crate::models::graph::TravelingSalesman; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; +use std::collections::HashMap; + +/// Result of reducing TravelingSalesman to QUBO. +#[derive(Debug, Clone)] +pub struct ReductionTravelingSalesmanToQUBO { + target: QUBO, + num_vertices: usize, + num_edges: usize, + edge_index: HashMap<(usize, usize), usize>, +} + +impl ReductionResult for ReductionTravelingSalesmanToQUBO { + type Source = TravelingSalesman; + type Target = QUBO; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Decode position encoding back to edge-based configuration. + /// + /// The QUBO solution uses n^2 binary variables x_{v,p} (vertex v at position p). + /// We extract the tour order, then map consecutive pairs to edge indices. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_vertices; + + // For each position p, find the vertex v where x_{v,p} == 1 + let mut tour = vec![0usize; n]; + for p in 0..n { + for v in 0..n { + if target_solution[v * n + p] == 1 { + tour[p] = v; + break; + } + } + } + + // Build edge-based config: for each consecutive pair in the tour, mark the edge + let mut config = vec![0usize; self.num_edges]; + for p in 0..n { + let u = tour[p]; + let v = tour[(p + 1) % n]; + let key = (u.min(v), u.max(v)); + if let Some(&idx) = self.edge_index.get(&key) { + config[idx] = 1; + } + } + + config + } +} + +#[reduction( + overhead = { + num_vars = "num_vertices^2", + } +)] +impl ReduceTo> for TravelingSalesman { + type Result = ReductionTravelingSalesmanToQUBO; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let edges = self.edges(); + + // Build edge weight map (both directions for undirected lookup) + let mut edge_weight_map: HashMap<(usize, usize), f64> = HashMap::new(); + let mut weight_sum: f64 = 0.0; + for &(u, v, w) in &edges { + let wf = w as f64; + edge_weight_map.insert((u, v), wf); + edge_weight_map.insert((v, u), wf); + weight_sum += wf.abs(); + } + + // Build edge index map: canonical (min, max) → edge index + let graph_edges = self.graph().edges(); + let num_edges = graph_edges.len(); + let mut edge_index: HashMap<(usize, usize), usize> = HashMap::new(); + for (idx, &(u, v)) in graph_edges.iter().enumerate() { + edge_index.insert((u.min(v), u.max(v)), idx); + } + + // Penalty weight: must exceed any possible tour cost + let a = 1.0 + weight_sum; + + // Build n^2 x n^2 upper-triangular QUBO matrix + let dim = n * n; + let mut matrix = vec![vec![0.0f64; dim]; dim]; + + // Helper: add value to upper-triangular position + let mut add_upper = |i: usize, j: usize, val: f64| { + let (lo, hi) = if i <= j { (i, j) } else { (j, i) }; + matrix[lo][hi] += val; + }; + + // H_A: each vertex visited exactly once (row constraint) + // For each vertex v: (sum_p x_{v,p} - 1)^2 + // = sum_p x_{v,p}^2 - 2*sum_p x_{v,p} + 1 + // = -sum_p x_{v,p} + 2*sum_{p1>::reduce_to(&tsp); + let qubo = reduction.target_problem(); + + let solver = BruteForce::new(); + let qubo_solutions = solver.find_all_best(qubo); + + // All QUBO solutions should extract to valid TSP solutions + for sol in &qubo_solutions { + let extracted = reduction.extract_solution(sol); + let metric = tsp.evaluate(&extracted); + assert!(metric.is_valid(), "Extracted solution should be valid"); + // K3 has only one Hamiltonian cycle (all 3 edges), cost = 1+2+3 = 6 + assert_eq!(metric, SolutionSize::Valid(6)); + } + + // There are multiple QUBO optima (different position assignments for the same tour), + // but they should all extract to valid tours with cost 6. + assert!( + !qubo_solutions.is_empty(), + "Should find at least one QUBO solution" + ); +} + +#[test] +fn test_travelingsalesman_to_qubo_k4() { + // K4 with unit weights + let graph = SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]); + let tsp = TravelingSalesman::new(graph, vec![1i32; 6]); + let reduction = ReduceTo::>::reduce_to(&tsp); + let qubo = reduction.target_problem(); + + let solver = BruteForce::new(); + let qubo_solutions = solver.find_all_best(qubo); + + // Every Hamiltonian cycle in K4 uses exactly 4 edges, so cost = 4 + for sol in &qubo_solutions { + let extracted = reduction.extract_solution(sol); + let metric = tsp.evaluate(&extracted); + assert!(metric.is_valid(), "Extracted solution should be valid"); + assert_eq!(metric, SolutionSize::Valid(4)); + } + + // K4 has 3 distinct Hamiltonian cycles, but each has multiple position encodings + // (4 rotations x 2 directions = 8 QUBO solutions per cycle, total 24). + // Just verify we get a non-trivial number of solutions. + assert!( + qubo_solutions.len() >= 3, + "Should find at least 3 QUBO solutions for K4" + ); +} + +#[test] +fn test_travelingsalesman_to_qubo_sizes() { + // K3: n=3, QUBO should have n^2 = 9 variables + let graph3 = SimpleGraph::new(3, vec![(0, 1), (0, 2), (1, 2)]); + let tsp3 = TravelingSalesman::new(graph3, vec![1i32; 3]); + let reduction3 = ReduceTo::>::reduce_to(&tsp3); + assert_eq!(reduction3.target_problem().num_variables(), 9); + + // K4: n=4, QUBO should have n^2 = 16 variables + let graph4 = SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]); + let tsp4 = TravelingSalesman::new(graph4, vec![1i32; 6]); + let reduction4 = ReduceTo::>::reduce_to(&tsp4); + assert_eq!(reduction4.target_problem().num_variables(), 16); +} diff --git a/tests/suites/examples.rs b/tests/suites/examples.rs index 3002a6838..b009e3803 100644 --- a/tests/suites/examples.rs +++ b/tests/suites/examples.rs @@ -51,6 +51,7 @@ example_test!(reduction_satisfiability_to_minimumdominatingset); example_test!(reduction_spinglass_to_maxcut); example_test!(reduction_spinglass_to_qubo); example_test!(reduction_travelingsalesman_to_ilp); +example_test!(reduction_travelingsalesman_to_qubo); macro_rules! example_fn { ($test_name:ident, $mod_name:ident) => { @@ -189,3 +190,7 @@ example_fn!( test_travelingsalesman_to_ilp, reduction_travelingsalesman_to_ilp ); +example_fn!( + test_travelingsalesman_to_qubo, + reduction_travelingsalesman_to_qubo +);