diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 3e8213c9d..9167a0c15 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -72,6 +72,7 @@ "HamiltonianCircuit": [Hamiltonian Circuit], "BiconnectivityAugmentation": [Biconnectivity Augmentation], "HamiltonianPath": [Hamiltonian Path], + "LongestCircuit": [Longest Circuit], "ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path], "UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow], "LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths], @@ -764,6 +765,80 @@ Biconnectivity augmentation is a classical network-design problem: add backup li ] } +#{ + let x = load-model-example("LongestCircuit") + let nv = x.instance.graph.num_vertices + let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1))) + let ne = edges.len() + let edge-lengths = x.instance.edge_lengths + let K = x.instance.bound + let config = x.optimal_config + let selected = range(ne).filter(i => config.at(i) == 1) + let total-length = selected.map(i => edge-lengths.at(i)).sum() + let cycle-order = (0, 1, 2, 3, 4, 5) + [ + #problem-def("LongestCircuit")[ + Given an undirected graph $G = (V, E)$ with positive edge lengths $l: E -> ZZ^+$ and a positive bound $K$, determine whether there exists a simple circuit $C subset.eq E$ such that $sum_(e in C) l(e) >= K$. + ][ + Longest Circuit is the decision version of the classical longest-cycle problem. Hamiltonian Circuit is the special case where every edge has unit length and $K = |V|$, so Longest Circuit is NP-complete via Karp's original Hamiltonicity result @karp1972. A standard exact baseline uses Held--Karp-style subset dynamic programming in $O(n^2 dot 2^n)$ time @heldkarp1962; unlike Hamiltonicity, the goal here is to certify a sufficiently long simple cycle rather than specifically a spanning one. + + In the implementation, a configuration selects a subset of edges. It is satisfying exactly when the selected edges induce one connected 2-regular subgraph and the total selected length reaches the threshold $K$. + + *Example.* Consider the canonical 6-vertex instance with bound $K = #K$. The outer cycle $v_0 arrow v_1 arrow v_2 arrow v_3 arrow v_4 arrow v_5 arrow v_0$ uses edge lengths $3 + 2 + 4 + 1 + 5 + 2 = #total-length$, so it is a satisfying circuit with total length exactly $K$. The extra chords $(v_0, v_3)$, $(v_1, v_4)$, $(v_2, v_5)$, and $(v_3, v_5)$ provide alternative routes but are not needed for this witness. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o longest-circuit.json", + "pred solve longest-circuit.json", + "pred evaluate longest-circuit.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + let colors = ( + selected: graph-colors.at(0), + unused: luma(200), + ) + let r = 1.5 + let positions = range(nv).map(i => { + let angle = 90deg - i * 360deg / nv + (calc.cos(angle) * r, calc.sin(angle) * r) + }) + + for (ei, (u, v)) in edges.enumerate() { + let is-selected = config.at(ei) == 1 + let col = if is-selected { colors.selected } else { colors.unused } + let thickness = if is-selected { 1.3pt } else { 0.5pt } + let dash = if is-selected { "solid" } else { "dashed" } + line(positions.at(u), positions.at(v), stroke: (paint: col, thickness: thickness, dash: dash)) + + let mid = ( + (positions.at(u).at(0) + positions.at(v).at(0)) / 2, + (positions.at(u).at(1) + positions.at(v).at(1)) / 2, + ) + let dx = if ei == 6 { -0.28 } else if ei == 7 { 0.24 } else if ei == 8 { -0.24 } else if ei == 9 { 0.24 } else { 0 } + let dy = if ei == 6 { 0 } else if ei == 7 { 0.18 } else if ei == 8 { 0.18 } else if ei == 9 { -0.15 } else { 0 } + content( + (mid.at(0) + dx, mid.at(1) + dy), + text(6pt, fill: col)[#edge-lengths.at(ei)], + fill: white, + frame: "rect", + padding: 0.05, + stroke: none, + ) + } + + for (i, pos) in positions.enumerate() { + circle(pos, radius: 0.18, fill: white, stroke: 0.7pt + black) + content(pos, text(7pt)[$v_#i$]) + } + }), + caption: [Longest Circuit instance on #nv vertices. The highlighted cycle $#cycle-order.map(v => $v_#v$).join($arrow$) arrow v_#(cycle-order.at(0))$ has total length #total-length $= K$; the gray dashed chords are available but unused.], + ) + ] + ] +} + #problem-def("BoundedComponentSpanningForest")[ Given an undirected graph $G = (V, E)$ with vertex weights $w: V -> ZZ_(gt.eq 0)$, a positive integer $K <= |V|$, and a positive bound $B$, determine whether there exists a partition of $V$ into $t$ non-empty sets $V_1, dots, V_t$ with $1 <= t <= K$ such that each induced subgraph $G[V_i]$ is connected and each part satisfies $sum_(v in V_i) w(v) <= B$. diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index f10cb96b4..02df552d7 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -231,6 +231,7 @@ Flags by problem type: GeneralizedHex --graph, --source, --sink MinimumCutIntoBoundedSets --graph, --edge-weights, --source, --sink, --size-bound, --cut-bound HamiltonianCircuit, HC --graph + LongestCircuit --graph, --edge-weights, --bound BoundedComponentSpanningForest --graph, --weights, --k, --bound UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 IsomorphicSpanningTree --graph, --tree @@ -500,7 +501,7 @@ pub struct CreateArgs { /// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4") #[arg(long)] pub required_edges: Option, - /// Upper bound or length bound (for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, LongestCommonSubsequence, MultipleCopyFileAllocation, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, ShortestCommonSupersequence, or StringToStringCorrection) + /// Bound parameter (lower bound for LongestCircuit; upper or length bound for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, LongestCommonSubsequence, MultipleCopyFileAllocation, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, ShortestCommonSupersequence, or StringToStringCorrection) #[arg(long, allow_hyphen_values = true)] pub bound: Option, /// Upper bound on total path length diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 955aa441f..d03de0341 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,8 +13,9 @@ use problemreductions::models::algebraic::{ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman, - MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, + LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumMultiwayCut, + MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, + StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, @@ -527,6 +528,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2", "KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3", + "LongestCircuit" => { + "--graph 0-1,1-2,2-3,3-4,4-5,5-0,0-3,1-4,2-5,3-5 --edge-weights 3,2,4,1,5,2,3,2,1,2 --bound 17" + } "BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } @@ -651,6 +655,7 @@ fn uses_edge_weights_flag(canonical: &str) -> bool { canonical, "BottleneckTravelingSalesman" | "KthBestSpanningTree" + | "LongestCircuit" | "MaxCut" | "MaximumMatching" | "MixedChinesePostman" @@ -966,6 +971,24 @@ fn validate_length_bounded_disjoint_paths_args( Ok(max_length) } +fn validate_longest_circuit_bound(bound: i64, usage: Option<&str>) -> Result { + let bound = i32::try_from(bound).map_err(|_| { + let msg = format!("LongestCircuit --bound must fit in i32 (got {bound})"); + match usage { + Some(u) => anyhow::anyhow!("{msg}\n\n{u}"), + None => anyhow::anyhow!("{msg}"), + } + })?; + if bound <= 0 { + let msg = "LongestCircuit --bound must be positive (> 0)"; + return Err(match usage { + Some(u) => anyhow::anyhow!("{msg}\n\n{u}"), + None => anyhow::anyhow!("{msg}"), + }); + } + Ok(bound) +} + /// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph"). fn resolved_graph_type(variant: &BTreeMap) -> &str { variant @@ -1469,7 +1492,9 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { })?; let edge_weights = parse_edge_weights(args, graph.num_edges())?; let data = match canonical { - "BottleneckTravelingSalesman" => ser(BottleneckTravelingSalesman::new(graph, edge_weights))?, + "BottleneckTravelingSalesman" => { + ser(BottleneckTravelingSalesman::new(graph, edge_weights))? + } "MaxCut" => ser(MaxCut::new(graph, edge_weights))?, "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?, "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?, @@ -1526,6 +1551,26 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // LongestCircuit + "LongestCircuit" => { + reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?; + let usage = "pred create LongestCircuit --graph 0-1,1-2,2-3,3-4,4-5,5-0,0-3,1-4,2-5,3-5 --edge-weights 3,2,4,1,5,2,3,2,1,2 --bound 17"; + let (graph, _) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; + let edge_lengths = parse_edge_weights(args, graph.num_edges())?; + if edge_lengths.iter().any(|&length| length <= 0) { + bail!("LongestCircuit --edge-weights must be positive (> 0)"); + } + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("LongestCircuit requires --bound\n\nUsage: {usage}") + })?; + let bound = validate_longest_circuit_bound(bound, Some(usage))?; + ( + ser(LongestCircuit::new(graph, edge_lengths, bound))?, + resolved_variant.clone(), + ) + } + // StackerCrane "StackerCrane" => { let usage = "Usage: pred create StackerCrane --arcs \"0>4,2>5,5>1,3>0,4>3\" --graph \"0-1,1-2,2-3,3-5,4-5,0-3,1-5\" --arc-costs 3,4,2,5,3 --edge-lengths 2,1,3,2,1,4,3 --bound 20 --num-vertices 6"; @@ -5126,6 +5171,23 @@ fn create_random( (ser(HamiltonianPath::new(graph))?, variant) } + // LongestCircuit (graph + unit edge lengths + positive bound) + "LongestCircuit" => { + 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 edge_lengths = vec![1i32; graph.num_edges()]; + let usage = "Usage: pred create LongestCircuit --random --num-vertices 6 [--edge-prob 0.5] [--seed 42] --bound 4"; + let bound = validate_longest_circuit_bound( + args.bound.unwrap_or(num_vertices.max(3) as i64), + Some(usage), + )?; + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + (ser(LongestCircuit::new(graph, edge_lengths, bound))?, variant) + } + // GeneralizedHex (graph only, with source/sink defaults) "GeneralizedHex" => { let num_vertices = num_vertices.max(2); @@ -5312,7 +5374,7 @@ fn create_random( Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, TravelingSalesman, \ BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, \ - OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)" + OptimalLinearArrangement, HamiltonianPath, LongestCircuit, GeneralizedHex)" ), }; diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 06ae08c6c..715588dcd 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -212,6 +212,38 @@ mod tests { assert_eq!(json["type"], "MaxCut"); } + #[test] + fn test_create_problem_longest_circuit() { + let server = McpServer::new(); + let params = serde_json::json!({ + "edges": "0-1,1-2,2-0", + "edge_lengths": "2,3,4", + "bound": 3 + }); + let result = server.create_problem_inner("LongestCircuit", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "LongestCircuit"); + assert_eq!(json["data"]["edge_lengths"], serde_json::json!([2, 3, 4])); + assert_eq!(json["data"]["bound"], 3); + } + + #[test] + fn test_create_problem_longest_circuit_random() { + let server = McpServer::new(); + let params = serde_json::json!({ + "random": true, + "num_vertices": 5, + "seed": 7, + "bound": 4 + }); + let result = server.create_problem_inner("LongestCircuit", ¶ms); + assert!(result.is_ok()); + let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap(); + assert_eq!(json["type"], "LongestCircuit"); + assert_eq!(json["data"]["bound"], 4); + } + #[test] fn test_create_problem_kcoloring() { let server = McpServer::new(); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 9240123b8..1172c36cb 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -2,8 +2,8 @@ use crate::util; use problemreductions::models::algebraic::QUBO; use problemreductions::models::formula::{CNFClause, Satisfiability}; use problemreductions::models::graph::{ - KClique, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, - MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman, + KClique, LongestCircuit, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, + MinimumDominatingSet, MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman, }; use problemreductions::models::misc::Factoring; use problemreductions::registry::collect_schemas; @@ -398,6 +398,28 @@ impl McpServer { ser_edge_weight_problem(&canonical, graph, edge_weights)? } + "LongestCircuit" => { + let (graph, _) = parse_graph_from_params(params)?; + let edge_lengths = parse_edge_lengths_from_params(params, graph.num_edges())?; + if edge_lengths.iter().any(|&length| length <= 0) { + anyhow::bail!("LongestCircuit edge lengths must be positive (> 0)"); + } + let bound = params + .get("bound") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("LongestCircuit requires 'bound'"))?; + let bound = i32::try_from(bound) + .map_err(|_| anyhow::anyhow!("LongestCircuit bound must fit in i32"))?; + if bound <= 0 { + anyhow::bail!("LongestCircuit bound must be positive (> 0)"); + } + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(LongestCircuit::new(graph, edge_lengths, bound))?, + variant, + ) + } + "KColoring" => { let (graph, _) = parse_graph_from_params(params)?; let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize); @@ -591,6 +613,31 @@ impl McpServer { let edge_weights = vec![1i32; num_edges]; ser_edge_weight_problem(canonical, graph, edge_weights)? } + "LongestCircuit" => { + let edge_prob = params + .get("edge_prob") + .and_then(|v| v.as_f64()) + .unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + anyhow::bail!("edge_prob must be between 0.0 and 1.0"); + } + let graph = util::create_random_graph(num_vertices, edge_prob, seed); + let edge_lengths = vec![1i32; graph.num_edges()]; + let bound = params + .get("bound") + .and_then(|v| v.as_i64()) + .unwrap_or(num_vertices.max(3) as i64); + let bound = i32::try_from(bound) + .map_err(|_| anyhow::anyhow!("LongestCircuit bound must fit in i32"))?; + if bound <= 0 { + anyhow::bail!("LongestCircuit bound must be positive (> 0)"); + } + let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); + ( + ser(LongestCircuit::new(graph, edge_lengths, bound))?, + variant, + ) + } "SpinGlass" => { let edge_prob = params .get("edge_prob") @@ -671,7 +718,7 @@ impl McpServer { "Random generation is not supported for {}. \ Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, \ - TravelingSalesman, MinimumSumMulticenter)", + TravelingSalesman, LongestCircuit, MinimumSumMulticenter)", canonical ), }; diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 6388acdd2..5c38df249 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -4172,6 +4172,134 @@ fn test_create_random_length_bounded_disjoint_paths_rejects_negative_bound_value ); } +#[test] +fn test_create_longest_circuit_succeeds() { + let output = pred() + .args([ + "create", + "LongestCircuit", + "--graph", + "0-1,1-2,2-3,3-0", + "--edge-weights", + "2,2,2,2", + "--bound", + "8", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "LongestCircuit"); + assert_eq!( + json["data"]["edge_lengths"], + serde_json::json!([2, 2, 2, 2]) + ); + assert_eq!(json["data"]["bound"], 8); +} + +#[test] +fn test_create_longest_circuit_defaults_unit_edge_weights() { + let output = pred() + .args([ + "create", + "LongestCircuit", + "--graph", + "0-1,1-2,2-3,3-0", + "--bound", + "8", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "LongestCircuit"); + assert_eq!( + json["data"]["edge_lengths"], + serde_json::json!([1, 1, 1, 1]) + ); +} + +#[test] +fn test_create_longest_circuit_rejects_negative_bound() { + let output = pred() + .args([ + "create", + "LongestCircuit", + "--graph", + "0-1,1-2,2-3,3-0", + "--edge-weights", + "2,2,2,2", + "--bound", + "-1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("LongestCircuit --bound must be positive (> 0)"), + "stderr: {stderr}" + ); +} + +#[test] +fn test_create_longest_circuit_no_flags_shows_help() { + let output = pred().args(["create", "LongestCircuit"]).output().unwrap(); + assert!( + !output.status.success(), + "should exit non-zero when showing help without data flags" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--edge-weights"), + "expected '--edge-weights' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--bound"), + "expected '--bound' in help output, got: {stderr}" + ); + assert!( + !stderr.contains("--edge-lengths"), + "help should advertise the actual CLI flag name, got: {stderr}" + ); +} + +#[test] +fn test_create_random_longest_circuit_succeeds() { + let output = pred() + .args([ + "create", + "LongestCircuit", + "--random", + "--num-vertices", + "6", + "--seed", + "7", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "LongestCircuit"); + assert_eq!(json["data"]["graph"]["num_vertices"], 6); + assert!(json["data"]["bound"].as_i64().unwrap() > 0); +} + #[test] fn test_evaluate_wrong_config_length() { let problem_file = std::env::temp_dir().join("pred_test_eval_wrong_len.json"); diff --git a/src/lib.rs b/src/lib.rs index 5d7f10b32..f9e84dca0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,12 +56,12 @@ pub mod prelude { StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ - KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, - MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, - MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, - MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, - PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, - ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, + KColoring, LongestCircuit, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, + MaximumMatching, MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet, + MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, + MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, + OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, + RuralPostman, ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ diff --git a/src/models/graph/longest_circuit.rs b/src/models/graph/longest_circuit.rs new file mode 100644 index 000000000..189645745 --- /dev/null +++ b/src/models/graph/longest_circuit.rs @@ -0,0 +1,286 @@ +//! Longest Circuit problem implementation. +//! +//! The Longest Circuit problem asks whether a graph contains a simple circuit +//! whose total edge length is at least a given bound. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::WeightElement; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "LongestCircuit", + display_name: "Longest Circuit", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Determine whether a graph contains a simple circuit with total length at least K", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "edge_lengths", type_name: "Vec", description: "Positive edge lengths l: E -> Z_(> 0)" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Lower bound K on the total circuit length" }, + ], + } +} + +/// The Longest Circuit problem. +/// +/// Given an undirected graph `G = (V, E)` with positive edge lengths `l(e)` and +/// a positive bound `K`, determine whether there exists a simple circuit in `G` +/// whose total edge-length sum is at least `K`. +/// +/// # Representation +/// +/// Each edge is assigned a binary variable: +/// - `0`: edge is not in the circuit +/// - `1`: edge is in the circuit +/// +/// A valid configuration must select edges that: +/// - form exactly one connected simple circuit +/// - use only edges from `graph` +/// - have total selected length at least `bound` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LongestCircuit { + graph: G, + edge_lengths: Vec, + bound: W::Sum, +} + +impl LongestCircuit { + /// Create a new LongestCircuit instance. + /// + /// # Panics + /// + /// Panics if the number of edge lengths does not match the graph's edge + /// count, if any edge length is non-positive, or if `bound` is non-positive. + pub fn new(graph: G, edge_lengths: Vec, bound: W::Sum) -> Self { + assert_eq!( + edge_lengths.len(), + graph.num_edges(), + "edge_lengths length must match num_edges" + ); + let zero = W::Sum::zero(); + assert!( + edge_lengths + .iter() + .all(|length| length.to_sum() > zero.clone()), + "All edge lengths must be positive (> 0)" + ); + assert!(bound > zero, "bound must be positive (> 0)"); + Self { + graph, + edge_lengths, + bound, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the edge lengths. + pub fn edge_lengths(&self) -> &[W] { + &self.edge_lengths + } + + /// Replace the edge lengths. + pub fn set_lengths(&mut self, edge_lengths: Vec) { + assert_eq!( + edge_lengths.len(), + self.graph.num_edges(), + "edge_lengths length must match num_edges" + ); + let zero = W::Sum::zero(); + assert!( + edge_lengths + .iter() + .all(|length| length.to_sum() > zero.clone()), + "All edge lengths must be positive (> 0)" + ); + self.edge_lengths = edge_lengths; + } + + /// Replace the edge lengths via the generic weight-management naming. + pub fn set_weights(&mut self, weights: Vec) { + self.set_lengths(weights); + } + + /// Get the edge lengths as a cloned vector. + pub fn weights(&self) -> Vec { + self.edge_lengths.clone() + } + + /// Get the lower bound K. + pub fn bound(&self) -> &W::Sum { + &self.bound + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Check if the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Check whether a configuration is a valid satisfying simple circuit. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + if !is_simple_circuit(&self.graph, config) { + return false; + } + + let mut total = W::Sum::zero(); + for (idx, &selected) in config.iter().enumerate() { + if selected == 1 { + total += self.edge_lengths[idx].to_sum(); + } + } + + total >= self.bound + } +} + +impl Problem for LongestCircuit +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "LongestCircuit"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +/// Check whether a binary edge-selection encodes exactly one simple circuit. +pub(crate) fn is_simple_circuit(graph: &G, config: &[usize]) -> bool { + if config.len() != graph.num_edges() || config.iter().any(|&value| value > 1) { + return false; + } + + let edges = graph.edges(); + let n = graph.num_vertices(); + let mut degree = vec![0usize; n]; + let mut adjacency = vec![Vec::new(); n]; + let mut selected_count = 0usize; + let mut start = None; + + for (idx, &selected) in config.iter().enumerate() { + if selected == 0 { + continue; + } + let (u, v) = edges[idx]; + degree[u] += 1; + degree[v] += 1; + adjacency[u].push(v); + adjacency[v].push(u); + selected_count += 1; + if start.is_none() { + start = Some(u); + } + } + + if selected_count < 3 { + return false; + } + + let selected_vertices: Vec = degree + .iter() + .enumerate() + .filter_map(|(vertex, °)| (deg > 0).then_some(vertex)) + .collect(); + + if selected_vertices.is_empty() || selected_vertices.iter().any(|&vertex| degree[vertex] != 2) { + return false; + } + + let start = match start { + Some(vertex) => vertex, + None => return false, + }; + + let mut visited = vec![false; n]; + let mut queue = VecDeque::new(); + visited[start] = true; + queue.push_back(start); + let mut visited_selected_vertices = 0usize; + + while let Some(vertex) = queue.pop_front() { + visited_selected_vertices += 1; + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + visited_selected_vertices == selected_vertices.len() +} + +impl SatisfactionProblem for LongestCircuit +where + G: Graph + crate::variant::VariantParam, + W: WeightElement + crate::variant::VariantParam, +{ +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "longest_circuit_simplegraph_i32", + instance: Box::new(LongestCircuit::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + (2, 5), + (3, 5), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 1, 2], + 17, + )), + optimal_config: vec![1, 1, 1, 1, 1, 1, 0, 0, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +crate::declare_variants! { + default sat LongestCircuit => "2^num_vertices * num_vertices^2", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/longest_circuit.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index dbdb632f8..06627fb70 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -61,6 +61,7 @@ pub(crate) mod kclique; pub(crate) mod kcoloring; pub(crate) mod kth_best_spanning_tree; pub(crate) mod length_bounded_disjoint_paths; +pub(crate) mod longest_circuit; pub(crate) mod max_cut; pub(crate) mod maximal_is; pub(crate) mod maximum_clique; @@ -106,6 +107,7 @@ pub use kclique::KClique; pub use kcoloring::KColoring; pub use kth_best_spanning_tree::KthBestSpanningTree; pub use length_bounded_disjoint_paths::LengthBoundedDisjointPaths; +pub use longest_circuit::LongestCircuit; pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; pub use maximum_clique::MaximumClique; @@ -150,6 +152,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec LongestCircuit { + LongestCircuit::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + (2, 5), + (3, 5), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 1, 2], + bound, + ) +} + +fn issue_problem() -> LongestCircuit { + issue_problem_with_bound(17) +} + +#[test] +fn test_longest_circuit_creation() { + let problem = issue_problem(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 10); + assert_eq!(problem.edge_lengths(), &[3, 2, 4, 1, 5, 2, 3, 2, 1, 2]); + assert_eq!(problem.bound(), &17); + assert_eq!(problem.dims(), vec![2; 10]); + assert!(problem.is_weighted()); +} + +#[test] +fn test_longest_circuit_evaluate_valid_and_invalid() { + let problem = issue_problem(); + + assert!(problem.evaluate(&[1, 1, 1, 1, 1, 1, 0, 0, 0, 0])); + assert!(!problem.evaluate(&[1, 1, 1, 0, 0, 0, 0, 0, 0, 0])); + assert!(!problem.evaluate(&[0, 0, 0, 0, 0, 0, 1, 1, 1, 0])); +} + +#[test] +fn test_longest_circuit_rejects_disconnected_cycles() { + let problem = LongestCircuit::new( + SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3)]), + vec![1, 1, 1, 1, 1, 1], + 3, + ); + assert!(!problem.evaluate(&[1, 1, 1, 1, 1, 1])); +} + +#[test] +fn test_longest_circuit_rejects_non_binary_and_below_bound_configs() { + let problem = issue_problem(); + assert!(!problem.is_valid_solution(&[1, 1, 1, 1, 1, 1, 0, 0, 0, 2])); + + let tighter_problem = issue_problem_with_bound(18); + assert!(!tighter_problem.evaluate(&[1, 1, 1, 1, 1, 1, 0, 0, 0, 0])); +} + +#[test] +fn test_longest_circuit_bruteforce_yes_and_no() { + let yes_problem = issue_problem(); + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&yes_problem).is_some()); + + let no_problem = LongestCircuit::new( + SimpleGraph::new( + 6, + vec![ + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + (0, 3), + (1, 4), + (2, 5), + (3, 5), + ], + ), + vec![3, 2, 4, 1, 5, 2, 3, 2, 1, 2], + 19, + ); + assert!(solver.find_satisfying(&no_problem).is_none()); +} + +#[test] +fn test_longest_circuit_serialization() { + let problem = issue_problem(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: LongestCircuit = serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_vertices(), problem.num_vertices()); + assert_eq!(restored.num_edges(), problem.num_edges()); + assert_eq!(restored.edge_lengths(), problem.edge_lengths()); + assert_eq!(restored.bound(), problem.bound()); +} + +#[test] +fn test_longest_circuit_paper_example() { + let problem = issue_problem(); + let config = vec![1, 1, 1, 1, 1, 1, 0, 0, 0, 0]; + assert!(problem.evaluate(&config)); + + let all = BruteForce::new().find_all_satisfying(&problem); + assert!(all.contains(&config)); +} + +#[test] +#[should_panic(expected = "All edge lengths must be positive (> 0)")] +fn test_longest_circuit_rejects_non_positive_edge_lengths() { + LongestCircuit::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1, 0, 1], + 3, + ); +} + +#[test] +#[should_panic(expected = "bound must be positive (> 0)")] +fn test_longest_circuit_rejects_non_positive_bound() { + LongestCircuit::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1, 1, 1], + 0, + ); +} + +#[test] +#[should_panic(expected = "All edge lengths must be positive (> 0)")] +fn test_longest_circuit_set_lengths_rejects_non_positive_values() { + let mut problem = LongestCircuit::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1, 1, 1], + 3, + ); + problem.set_lengths(vec![1, -2, 1]); +}