Skip to content

Commit d870c74

Browse files
GiggleLiuisPANNclaude
authored
Fix #287: [Model] LongestCircuit (#731)
* Add plan for #287: [Model] LongestCircuit * Add LongestCircuit model * Implement #287: integrate LongestCircuit CLI, MCP, and paper * chore: remove plan file after implementation * cargo fmt * fix: deduplicate bound validation, revert unrelated MCP changes - Use validate_longest_circuit_bound() in both explicit and random create paths instead of duplicating inline validation - Revert available_solvers -> supports_ilp_solver change (unrelated to LongestCircuit) - Revert edge_lengths array parsing extension (unrelated scope creep) - Remove MCP test for array edge_lengths format Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: align validate_longest_circuit_bound error messages with CLI tests The shared validation function now uses the same error message format ("LongestCircuit --bound must be positive (> 0)") as expected by the CLI integration tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Xiwei Pan <xiwei.pan@connect.hkust-gz.edu.cn> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 62dd9f4 commit d870c74

12 files changed

Lines changed: 808 additions & 25 deletions

File tree

docs/paper/reductions.typ

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"HamiltonianCircuit": [Hamiltonian Circuit],
7373
"BiconnectivityAugmentation": [Biconnectivity Augmentation],
7474
"HamiltonianPath": [Hamiltonian Path],
75+
"LongestCircuit": [Longest Circuit],
7576
"ShortestWeightConstrainedPath": [Shortest Weight-Constrained Path],
7677
"UndirectedTwoCommodityIntegralFlow": [Undirected Two-Commodity Integral Flow],
7778
"LengthBoundedDisjointPaths": [Length-Bounded Disjoint Paths],
@@ -764,6 +765,80 @@ Biconnectivity augmentation is a classical network-design problem: add backup li
764765
]
765766
}
766767

768+
#{
769+
let x = load-model-example("LongestCircuit")
770+
let nv = x.instance.graph.num_vertices
771+
let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1)))
772+
let ne = edges.len()
773+
let edge-lengths = x.instance.edge_lengths
774+
let K = x.instance.bound
775+
let config = x.optimal_config
776+
let selected = range(ne).filter(i => config.at(i) == 1)
777+
let total-length = selected.map(i => edge-lengths.at(i)).sum()
778+
let cycle-order = (0, 1, 2, 3, 4, 5)
779+
[
780+
#problem-def("LongestCircuit")[
781+
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$.
782+
][
783+
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.
784+
785+
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$.
786+
787+
*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.
788+
789+
#pred-commands(
790+
"pred create --example " + problem-spec(x) + " -o longest-circuit.json",
791+
"pred solve longest-circuit.json",
792+
"pred evaluate longest-circuit.json --config " + x.optimal_config.map(str).join(","),
793+
)
794+
795+
#figure(
796+
canvas(length: 1cm, {
797+
import draw: *
798+
let colors = (
799+
selected: graph-colors.at(0),
800+
unused: luma(200),
801+
)
802+
let r = 1.5
803+
let positions = range(nv).map(i => {
804+
let angle = 90deg - i * 360deg / nv
805+
(calc.cos(angle) * r, calc.sin(angle) * r)
806+
})
807+
808+
for (ei, (u, v)) in edges.enumerate() {
809+
let is-selected = config.at(ei) == 1
810+
let col = if is-selected { colors.selected } else { colors.unused }
811+
let thickness = if is-selected { 1.3pt } else { 0.5pt }
812+
let dash = if is-selected { "solid" } else { "dashed" }
813+
line(positions.at(u), positions.at(v), stroke: (paint: col, thickness: thickness, dash: dash))
814+
815+
let mid = (
816+
(positions.at(u).at(0) + positions.at(v).at(0)) / 2,
817+
(positions.at(u).at(1) + positions.at(v).at(1)) / 2,
818+
)
819+
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 }
820+
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 }
821+
content(
822+
(mid.at(0) + dx, mid.at(1) + dy),
823+
text(6pt, fill: col)[#edge-lengths.at(ei)],
824+
fill: white,
825+
frame: "rect",
826+
padding: 0.05,
827+
stroke: none,
828+
)
829+
}
830+
831+
for (i, pos) in positions.enumerate() {
832+
circle(pos, radius: 0.18, fill: white, stroke: 0.7pt + black)
833+
content(pos, text(7pt)[$v_#i$])
834+
}
835+
}),
836+
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.],
837+
) <fig:longest-circuit>
838+
]
839+
]
840+
}
841+
767842

768843
#problem-def("BoundedComponentSpanningForest")[
769844
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$.

problemreductions-cli/src/cli.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ Flags by problem type:
231231
GeneralizedHex --graph, --source, --sink
232232
MinimumCutIntoBoundedSets --graph, --edge-weights, --source, --sink, --size-bound, --cut-bound
233233
HamiltonianCircuit, HC --graph
234+
LongestCircuit --graph, --edge-weights, --bound
234235
BoundedComponentSpanningForest --graph, --weights, --k, --bound
235236
UndirectedTwoCommodityIntegralFlow --graph, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2
236237
IsomorphicSpanningTree --graph, --tree
@@ -500,7 +501,7 @@ pub struct CreateArgs {
500501
/// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4")
501502
#[arg(long)]
502503
pub required_edges: Option<String>,
503-
/// Upper bound or length bound (for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, LongestCommonSubsequence, MultipleCopyFileAllocation, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, ShortestCommonSupersequence, or StringToStringCorrection)
504+
/// Bound parameter (lower bound for LongestCircuit; upper or length bound for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, LongestCommonSubsequence, MultipleCopyFileAllocation, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, ShortestCommonSupersequence, or StringToStringCorrection)
504505
#[arg(long, allow_hyphen_values = true)]
505506
pub bound: Option<i64>,
506507
/// Upper bound on total path length

problemreductions-cli/src/commands/create.rs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ use problemreductions::models::algebraic::{
1313
use problemreductions::models::formula::Quantifier;
1414
use problemreductions::models::graph::{
1515
GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath,
16-
LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman,
17-
MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation,
16+
LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumMultiwayCut,
17+
MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs,
18+
StrongConnectivityAugmentation,
1819
};
1920
use problemreductions::models::misc::{
2021
AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery,
@@ -527,6 +528,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
527528
}
528529
"IsomorphicSpanningTree" => "--graph 0-1,1-2,0-2 --tree 0-1,1-2",
529530
"KthBestSpanningTree" => "--graph 0-1,0-2,1-2 --edge-weights 2,3,1 --k 1 --bound 3",
531+
"LongestCircuit" => {
532+
"--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"
533+
}
530534
"BottleneckTravelingSalesman" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
531535
"--graph 0-1,1-2,2-3 --edge-weights 1,1,1"
532536
}
@@ -651,6 +655,7 @@ fn uses_edge_weights_flag(canonical: &str) -> bool {
651655
canonical,
652656
"BottleneckTravelingSalesman"
653657
| "KthBestSpanningTree"
658+
| "LongestCircuit"
654659
| "MaxCut"
655660
| "MaximumMatching"
656661
| "MixedChinesePostman"
@@ -966,6 +971,24 @@ fn validate_length_bounded_disjoint_paths_args(
966971
Ok(max_length)
967972
}
968973

974+
fn validate_longest_circuit_bound(bound: i64, usage: Option<&str>) -> Result<i32> {
975+
let bound = i32::try_from(bound).map_err(|_| {
976+
let msg = format!("LongestCircuit --bound must fit in i32 (got {bound})");
977+
match usage {
978+
Some(u) => anyhow::anyhow!("{msg}\n\n{u}"),
979+
None => anyhow::anyhow!("{msg}"),
980+
}
981+
})?;
982+
if bound <= 0 {
983+
let msg = "LongestCircuit --bound must be positive (> 0)";
984+
return Err(match usage {
985+
Some(u) => anyhow::anyhow!("{msg}\n\n{u}"),
986+
None => anyhow::anyhow!("{msg}"),
987+
});
988+
}
989+
Ok(bound)
990+
}
991+
969992
/// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph").
970993
fn resolved_graph_type(variant: &BTreeMap<String, String>) -> &str {
971994
variant
@@ -1469,7 +1492,9 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
14691492
})?;
14701493
let edge_weights = parse_edge_weights(args, graph.num_edges())?;
14711494
let data = match canonical {
1472-
"BottleneckTravelingSalesman" => ser(BottleneckTravelingSalesman::new(graph, edge_weights))?,
1495+
"BottleneckTravelingSalesman" => {
1496+
ser(BottleneckTravelingSalesman::new(graph, edge_weights))?
1497+
}
14731498
"MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
14741499
"MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
14751500
"TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
@@ -1526,6 +1551,26 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
15261551
)
15271552
}
15281553

1554+
// LongestCircuit
1555+
"LongestCircuit" => {
1556+
reject_vertex_weights_for_edge_weight_problem(args, canonical, None)?;
1557+
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";
1558+
let (graph, _) =
1559+
parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?;
1560+
let edge_lengths = parse_edge_weights(args, graph.num_edges())?;
1561+
if edge_lengths.iter().any(|&length| length <= 0) {
1562+
bail!("LongestCircuit --edge-weights must be positive (> 0)");
1563+
}
1564+
let bound = args.bound.ok_or_else(|| {
1565+
anyhow::anyhow!("LongestCircuit requires --bound\n\nUsage: {usage}")
1566+
})?;
1567+
let bound = validate_longest_circuit_bound(bound, Some(usage))?;
1568+
(
1569+
ser(LongestCircuit::new(graph, edge_lengths, bound))?,
1570+
resolved_variant.clone(),
1571+
)
1572+
}
1573+
15291574
// StackerCrane
15301575
"StackerCrane" => {
15311576
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(
51265171
(ser(HamiltonianPath::new(graph))?, variant)
51275172
}
51285173

5174+
// LongestCircuit (graph + unit edge lengths + positive bound)
5175+
"LongestCircuit" => {
5176+
let edge_prob = args.edge_prob.unwrap_or(0.5);
5177+
if !(0.0..=1.0).contains(&edge_prob) {
5178+
bail!("--edge-prob must be between 0.0 and 1.0");
5179+
}
5180+
let graph = util::create_random_graph(num_vertices, edge_prob, args.seed);
5181+
let edge_lengths = vec![1i32; graph.num_edges()];
5182+
let usage = "Usage: pred create LongestCircuit --random --num-vertices 6 [--edge-prob 0.5] [--seed 42] --bound 4";
5183+
let bound = validate_longest_circuit_bound(
5184+
args.bound.unwrap_or(num_vertices.max(3) as i64),
5185+
Some(usage),
5186+
)?;
5187+
let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
5188+
(ser(LongestCircuit::new(graph, edge_lengths, bound))?, variant)
5189+
}
5190+
51295191
// GeneralizedHex (graph only, with source/sink defaults)
51305192
"GeneralizedHex" => {
51315193
let num_vertices = num_vertices.max(2);
@@ -5312,7 +5374,7 @@ fn create_random(
53125374
Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
53135375
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, TravelingSalesman, \
53145376
BottleneckTravelingSalesman, SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, \
5315-
OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)"
5377+
OptimalLinearArrangement, HamiltonianPath, LongestCircuit, GeneralizedHex)"
53165378
),
53175379
};
53185380

problemreductions-cli/src/mcp/tests.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,38 @@ mod tests {
212212
assert_eq!(json["type"], "MaxCut");
213213
}
214214

215+
#[test]
216+
fn test_create_problem_longest_circuit() {
217+
let server = McpServer::new();
218+
let params = serde_json::json!({
219+
"edges": "0-1,1-2,2-0",
220+
"edge_lengths": "2,3,4",
221+
"bound": 3
222+
});
223+
let result = server.create_problem_inner("LongestCircuit", &params);
224+
assert!(result.is_ok());
225+
let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
226+
assert_eq!(json["type"], "LongestCircuit");
227+
assert_eq!(json["data"]["edge_lengths"], serde_json::json!([2, 3, 4]));
228+
assert_eq!(json["data"]["bound"], 3);
229+
}
230+
231+
#[test]
232+
fn test_create_problem_longest_circuit_random() {
233+
let server = McpServer::new();
234+
let params = serde_json::json!({
235+
"random": true,
236+
"num_vertices": 5,
237+
"seed": 7,
238+
"bound": 4
239+
});
240+
let result = server.create_problem_inner("LongestCircuit", &params);
241+
assert!(result.is_ok());
242+
let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
243+
assert_eq!(json["type"], "LongestCircuit");
244+
assert_eq!(json["data"]["bound"], 4);
245+
}
246+
215247
#[test]
216248
fn test_create_problem_kcoloring() {
217249
let server = McpServer::new();

problemreductions-cli/src/mcp/tools.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use crate::util;
22
use problemreductions::models::algebraic::QUBO;
33
use problemreductions::models::formula::{CNFClause, Satisfiability};
44
use problemreductions::models::graph::{
5-
KClique, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet,
6-
MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman,
5+
KClique, LongestCircuit, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching,
6+
MinimumDominatingSet, MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman,
77
};
88
use problemreductions::models::misc::Factoring;
99
use problemreductions::registry::collect_schemas;
@@ -398,6 +398,28 @@ impl McpServer {
398398
ser_edge_weight_problem(&canonical, graph, edge_weights)?
399399
}
400400

401+
"LongestCircuit" => {
402+
let (graph, _) = parse_graph_from_params(params)?;
403+
let edge_lengths = parse_edge_lengths_from_params(params, graph.num_edges())?;
404+
if edge_lengths.iter().any(|&length| length <= 0) {
405+
anyhow::bail!("LongestCircuit edge lengths must be positive (> 0)");
406+
}
407+
let bound = params
408+
.get("bound")
409+
.and_then(|v| v.as_i64())
410+
.ok_or_else(|| anyhow::anyhow!("LongestCircuit requires 'bound'"))?;
411+
let bound = i32::try_from(bound)
412+
.map_err(|_| anyhow::anyhow!("LongestCircuit bound must fit in i32"))?;
413+
if bound <= 0 {
414+
anyhow::bail!("LongestCircuit bound must be positive (> 0)");
415+
}
416+
let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
417+
(
418+
ser(LongestCircuit::new(graph, edge_lengths, bound))?,
419+
variant,
420+
)
421+
}
422+
401423
"KColoring" => {
402424
let (graph, _) = parse_graph_from_params(params)?;
403425
let k_flag = params.get("k").and_then(|v| v.as_u64()).map(|v| v as usize);
@@ -591,6 +613,31 @@ impl McpServer {
591613
let edge_weights = vec![1i32; num_edges];
592614
ser_edge_weight_problem(canonical, graph, edge_weights)?
593615
}
616+
"LongestCircuit" => {
617+
let edge_prob = params
618+
.get("edge_prob")
619+
.and_then(|v| v.as_f64())
620+
.unwrap_or(0.5);
621+
if !(0.0..=1.0).contains(&edge_prob) {
622+
anyhow::bail!("edge_prob must be between 0.0 and 1.0");
623+
}
624+
let graph = util::create_random_graph(num_vertices, edge_prob, seed);
625+
let edge_lengths = vec![1i32; graph.num_edges()];
626+
let bound = params
627+
.get("bound")
628+
.and_then(|v| v.as_i64())
629+
.unwrap_or(num_vertices.max(3) as i64);
630+
let bound = i32::try_from(bound)
631+
.map_err(|_| anyhow::anyhow!("LongestCircuit bound must fit in i32"))?;
632+
if bound <= 0 {
633+
anyhow::bail!("LongestCircuit bound must be positive (> 0)");
634+
}
635+
let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
636+
(
637+
ser(LongestCircuit::new(graph, edge_lengths, bound))?,
638+
variant,
639+
)
640+
}
594641
"SpinGlass" => {
595642
let edge_prob = params
596643
.get("edge_prob")
@@ -671,7 +718,7 @@ impl McpServer {
671718
"Random generation is not supported for {}. \
672719
Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
673720
MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, \
674-
TravelingSalesman, MinimumSumMulticenter)",
721+
TravelingSalesman, LongestCircuit, MinimumSumMulticenter)",
675722
canonical
676723
),
677724
};

0 commit comments

Comments
 (0)