Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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.],
) <fig:longest-circuit>
]
]
}


#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$.
Expand Down
3 changes: 2 additions & 1 deletion problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
/// 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<i64>,
/// Upper bound on total path length
Expand Down
70 changes: 66 additions & 4 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -651,6 +655,7 @@ fn uses_edge_weights_flag(canonical: &str) -> bool {
canonical,
"BottleneckTravelingSalesman"
| "KthBestSpanningTree"
| "LongestCircuit"
| "MaxCut"
| "MaximumMatching"
| "MixedChinesePostman"
Expand Down Expand Up @@ -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<i32> {
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<String, String>) -> &str {
variant
Expand Down Expand Up @@ -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))?,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)"
),
};

Expand Down
32 changes: 32 additions & 0 deletions problemreductions-cli/src/mcp/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", &params);
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", &params);
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();
Expand Down
53 changes: 50 additions & 3 deletions problemreductions-cli/src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
),
};
Expand Down
Loading
Loading