From 914ceeacddf83658915892bdc8d6665965d836dc Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 22:05:08 +0800 Subject: [PATCH 1/7] Add plan for #287: [Model] LongestCircuit --- docs/plans/2026-03-21-longest-circuit.md | 293 +++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 docs/plans/2026-03-21-longest-circuit.md diff --git a/docs/plans/2026-03-21-longest-circuit.md b/docs/plans/2026-03-21-longest-circuit.md new file mode 100644 index 000000000..07c418ff1 --- /dev/null +++ b/docs/plans/2026-03-21-longest-circuit.md @@ -0,0 +1,293 @@ +# LongestCircuit Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `[Model] LongestCircuit` graph satisfaction model from issue `#287`, including registry/example-db/CLI integration and the paper entry needed for the companion rule issue `#358` (`HamiltonianCircuit -> LongestCircuit`). + +**Architecture:** Model `LongestCircuit` as an edge-selection satisfaction problem on `SimpleGraph` with a generic edge-length type `W: WeightElement`, matching the repo's existing weighted graph satisfaction models. A configuration is valid iff the selected edges form one connected simple circuit and the selected edge-length sum is at least the positive bound `K`. Batch 1 handles code, tests, example-db, CLI, and MCP integration; Batch 2 adds the paper entry after the canonical example is stable. + +**Tech Stack:** Rust workspace (`problemreductions`, `problemreductions-cli`), serde/inventory registry, `BruteForce` solver, Typst paper, GitHub issue workflow. + +--- + +## Source-of-Truth Notes + +- Issue: `#287 [Model] LongestCircuit` +- Companion rule already exists as issue `#358 [Rule] HAMILTONIAN CIRCUIT to LONGEST CIRCUIT`, so this model is not orphaned. +- Use the corrected issue comments as design input: + - Keep the model as the decision version with positive edge lengths and positive bound `K`. + - Avoid the misleading `1.657^n` Hamiltonicity claim for longest cycle. + - Use the issue's 6-vertex, 10-edge weighted YES instance as the canonical example/paper figure. +- Canonical implementation choices: + - Category: `src/models/graph/` + - Struct: `LongestCircuit` + - Fields: `graph`, `edge_lengths`, `bound` + - Getter size fields: `num_vertices()`, `num_edges()` + - Config encoding: one binary variable per edge (`dims() == vec![2; num_edges]`) + - Validity helper: selected edges induce exactly one connected 2-regular subgraph and total selected length is `>= bound` + +## Batch 1: Model, Tests, Example-DB, CLI, MCP + +### Task 1: Write the first failing model tests + +**Files:** +- Create: `src/unit_tests/models/graph/longest_circuit.rs` +- Read for pattern: `src/unit_tests/models/graph/shortest_weight_constrained_path.rs` +- Read for pattern: `src/unit_tests/models/graph/rural_postman.rs` + +**Step 1: Write the failing tests** + +Add focused tests for: +- creation/accessors on the issue instance +- `evaluate()` on one valid circuit and a few invalid edge selections +- brute-force satisfiability on the issue YES instance + +Use the issue's canonical graph and the expected satisfying circuit `0-1-2-3-4-5-0`. + +**Step 2: Run the focused test to verify it fails** + +Run: `cargo test longest_circuit --lib` + +Expected: FAIL because `LongestCircuit` is not implemented or not exported yet. + +**Step 3: Do not add behavior here** + +Stop once the failure is the expected missing-model failure. + +### Task 2: Implement the core model and register it + +**Files:** +- Create: `src/models/graph/longest_circuit.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` +- Modify: `src/unit_tests/models/graph/longest_circuit.rs` + +**Step 1: Implement the minimal model to satisfy Task 1** + +Add `LongestCircuit` with: +- `ProblemSchemaEntry` for graph + weight dimensions +- constructor validation: + - `edge_lengths.len() == graph.num_edges()` + - all edge lengths positive + - `bound > 0` +- accessors: `graph()`, `edge_lengths()`, `bound()`, `num_vertices()`, `num_edges()`, `is_weighted()`, optional `set_lengths(...)` +- `Problem` impl with `Metric = bool` +- `SatisfactionProblem` impl +- helper that accepts only one simple circuit: + - config length matches `num_edges` + - values are binary + - selected edge count at least 3 + - each selected vertex has degree exactly 2 + - non-selected vertices have degree 0 + - selected subgraph is connected + - total selected length `>= bound` +- `declare_variants!` for `LongestCircuit` +- `#[cfg(test)]` link to the new unit test file + +Export the new model from the graph module, global model re-export, and prelude. + +**Step 2: Run the focused model tests** + +Run: `cargo test longest_circuit --lib` + +Expected: PASS for the new model tests. + +**Step 3: Refactor only if needed** + +If the circuit-validation helper is noisy, extract a small private helper for degree/connectivity counting, but keep the representation edge-based for compatibility with issue `#358`. + +### Task 3: Add canonical example-db support before CLI work + +**Files:** +- Modify: `src/models/graph/longest_circuit.rs` +- Modify: `src/models/graph/mod.rs` +- Read for pattern: `src/models/graph/hamiltonian_circuit.rs` +- Read for pattern: `src/example_db/model_builders.rs` + +**Step 1: Add the canonical model example spec** + +Inside `src/models/graph/longest_circuit.rs`, add `canonical_model_example_specs()` gated by `example-db` using the issue's YES instance: +- graph edges in issue order +- edge lengths `[3, 2, 4, 1, 5, 2, 3, 2, 1, 2]` +- bound `17` +- optimal/satisfying config selecting the 6-cycle `0-1-2-3-4-5-0` + +Register the new example chain in `src/models/graph/mod.rs`. + +**Step 2: Run the example-db tests that should cover lookup/build** + +Run: `cargo test example_db --features example-db` + +Expected: PASS, and the new model example appears in the built example DB. + +### Task 4: Add CLI create support and CLI tests + +**Files:** +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `problemreductions-cli/tests/cli_tests.rs` +- Optionally modify: `problemreductions-cli/src/problem_name.rs` only if exact-canonical-name resolution is insufficient + +**Step 1: Write failing CLI tests first** + +Add tests for: +- explicit creation: + - `pred create LongestCircuit --graph ... --edge-weights ... --bound 17` +- rejection of missing `--edge-weights` +- rejection of non-positive `--bound` +- help text / usage string mentions the right flags +- random creation: + - `pred create LongestCircuit --random --num-vertices 6 --bound 4` + +Use `--edge-weights` as the CLI surface for the single edge-length vector, following `RuralPostman` and other one-vector edge-weight graph problems. + +**Step 2: Run the new CLI tests to verify they fail** + +Run: `cargo test -p problemreductions-cli longest_circuit` + +Expected: FAIL because the create command and help text do not know `LongestCircuit` yet. + +**Step 3: Implement the minimal CLI support** + +In `problemreductions-cli/src/commands/create.rs`: +- add an explicit create arm for `LongestCircuit` +- parse `graph`, `edge_weights`, and `bound` +- validate positive edge weights and positive bound +- serialize `LongestCircuit::new(graph, edge_weights, bound)` +- add a random-create arm that generates a random graph, unit edge lengths, and a reasonable positive default bound (for example `max(3, num_vertices / 2)`) + +In `problemreductions-cli/src/cli.rs`: +- add `LongestCircuit` to the "Flags by problem type" help table +- update any help text mentioning supported `--bound` users if needed + +**Step 4: Re-run the focused CLI tests** + +Run: `cargo test -p problemreductions-cli longest_circuit` + +Expected: PASS. + +### Task 5: Add MCP creation support and focused regression checks + +**Files:** +- Modify: `problemreductions-cli/src/mcp/tools.rs` +- Read for pattern: existing `MaxCut` / `TravelingSalesman` / `MinimumSumMulticenter` MCP branches + +**Step 1: Write or reuse a focused failing regression if there is already MCP coverage** + +If an MCP test file already covers graph creation routing, add a failing test there for `LongestCircuit`. If there is no practical focused test harness, document that and use code review plus compile/test verification. + +**Step 2: Implement MCP support** + +Update the MCP creation helpers so `LongestCircuit` can be created from MCP params in both normal and random flows: +- parse graph params +- parse one edge-weight vector as the circuit lengths +- parse positive `bound` +- serialize with the `SimpleGraph/i32` variant + +**Step 3: Run the tightest relevant verification** + +Run one of: +- `cargo test -p problemreductions-cli mcp` +- or, if the test names are more granular, the narrowest matching MCP test selection + +Expected: PASS. + +### Task 6: Add final model-level tests and run Batch 1 verification + +**Files:** +- Modify: `src/unit_tests/models/graph/longest_circuit.rs` + +**Step 1: Expand the test file to cover the final model contract** + +Ensure the test file includes at least: +- creation/accessors +- `evaluate()` valid/invalid circuit selections +- brute-force satisfiable and unsatisfiable cases +- serialization round-trip +- paper/canonical example test using the issue's YES instance + +Add at least one invalid case for each structural failure mode: +- disconnected selected cycles +- degree-1 or degree-3 selection +- length below bound + +**Step 2: Run focused model tests** + +Run: `cargo test longest_circuit --lib` + +Expected: PASS. + +**Step 3: Run Batch 1 workspace checks** + +Run: +- `cargo test longest_circuit` +- `cargo test -p problemreductions-cli longest_circuit` + +Expected: PASS for all targeted checks before moving to the paper batch. + +## Batch 2: Paper Entry and End-to-End Verification + +### Task 7: Add the paper entry and align it with the canonical example + +**Files:** +- Modify: `docs/paper/reductions.typ` +- Read for pattern: `problem-def("HamiltonianCircuit")` +- Read for pattern: `problem-def("TravelingSalesman")` + +**Step 1: Write the failing paper-adjacent test first** + +If the model test file does not already assert the canonical example from the paper, add or tighten `test_longest_circuit_paper_example` before editing the Typst entry so the model-side example is pinned. + +Run: `cargo test longest_circuit_paper_example --lib` + +Expected: PASS or FAIL only for model/example mismatch. Fix the model/example first, not Typst. + +**Step 2: Implement the Typst entry** + +Add: +- display-name dictionary entry for `LongestCircuit` +- `problem-def("LongestCircuit")` with: + - formal definition using positive edge lengths and threshold `K` + - brief background and corrected algorithm notes + - a worked example using the canonical YES instance + - `pred-commands(...)` using the example-db-backed pattern, not a hand-written fragile spec +- a small circuit visualization that highlights the satisfying cycle + +Keep the algorithm discussion conservative: +- state the classical exact baseline carefully +- do not repeat the misleading Hamiltonicity-only `1.657^n` claim as the longest-cycle complexity + +**Step 3: Run paper verification** + +Run: `make paper` + +Expected: PASS. + +### Task 8: Run full verification, then prepare the implementation summary + +**Files:** +- No new files expected + +**Step 1: Run the repo verification required before closing the issue-to-pr execution** + +Run: +- `make test` +- `make clippy` +- `git status --short` + +Expected: +- tests pass +- clippy passes +- only intended tracked files are modified + +**Step 2: Prepare commit boundaries** + +Create small coherent commits, for example: +- model/tests/example-db integration +- CLI/MCP integration +- paper entry + +**Step 3: Record deviations if any** + +If implementation differs from this plan, note the difference for the PR summary comment before push. From 215164ab3e85cd9d91fa5a19f4a2c87224770f4a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 22:14:08 +0800 Subject: [PATCH 2/7] Add LongestCircuit model --- src/lib.rs | 23 +- src/models/graph/longest_circuit.rs | 286 ++++++++++++++++++ src/models/graph/mod.rs | 5 +- src/models/mod.rs | 11 +- .../models/graph/longest_circuit.rs | 138 +++++++++ 5 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 src/models/graph/longest_circuit.rs create mode 100644 src/unit_tests/models/graph/longest_circuit.rs diff --git a/src/lib.rs b/src/lib.rs index 9c8bd8664..b9a87be11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,19 +48,20 @@ pub mod prelude { Satisfiability, }; pub use crate::models::graph::{ - AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, - BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, - GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, - KthBestSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, - StrongConnectivityAugmentation, SubgraphIsomorphism, + AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, + BiconnectivityAugmentation, BoundedComponentSpanningForest, + DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, + HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, + LengthBoundedDisjointPaths, SpinGlass, SteinerTree, 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 c971b7746..dd5203266 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -43,8 +43,8 @@ //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs -pub(crate) mod balanced_complete_bipartite_subgraph; pub(crate) mod acyclic_partition; +pub(crate) mod balanced_complete_bipartite_subgraph; pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; pub(crate) mod bounded_component_spanning_forest; @@ -58,6 +58,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; @@ -101,6 +102,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; @@ -144,6 +146,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]); +} From 95b404732c207cbb5b8ecbab89abff32b0cdf3d9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 22:42:11 +0800 Subject: [PATCH 3/7] Implement #287: integrate LongestCircuit CLI, MCP, and paper --- docs/paper/reductions.typ | 75 +++++++++++ problemreductions-cli/src/cli.rs | 3 +- problemreductions-cli/src/commands/create.rs | 77 ++++++++++- problemreductions-cli/src/mcp/tests.rs | 47 +++++++ problemreductions-cli/src/mcp/tools.rs | 87 ++++++++++++- problemreductions-cli/tests/cli_tests.rs | 128 +++++++++++++++++++ 6 files changed, 407 insertions(+), 10 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 34d0f83b3..b91330b99 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], @@ -761,6 +762,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 5a26d50c5..7fac56b71 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 @@ -498,7 +499,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 73e3f6b52..e2c84f483 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,7 +13,7 @@ use problemreductions::models::algebraic::{ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, + LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ @@ -527,6 +527,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" + } "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" } @@ -643,7 +646,12 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { fn uses_edge_weights_flag(canonical: &str) -> bool { matches!( canonical, - "KthBestSpanningTree" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" | "RuralPostman" + "KthBestSpanningTree" + | "LongestCircuit" + | "MaxCut" + | "MaximumMatching" + | "TravelingSalesman" + | "RuralPostman" ) } @@ -938,6 +946,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(|_| match usage { + Some(usage) => { + anyhow::anyhow!("--bound must be a positive integer for LongestCircuit\n\n{usage}") + } + None => anyhow::anyhow!("--bound must be a positive integer for LongestCircuit"), + })?; + if bound <= 0 { + return Err(match usage { + Some(usage) => { + anyhow::anyhow!("--bound must be a positive integer for LongestCircuit\n\n{usage}") + } + None => anyhow::anyhow!("--bound must be a positive integer for LongestCircuit"), + }); + } + Ok(bound) +} + /// Resolve the graph type from the variant map (e.g., "KingsSubgraph", "UnitDiskGraph", or "SimpleGraph"). fn resolved_graph_type(variant: &BTreeMap) -> &str { variant @@ -1497,6 +1523,33 @@ 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 = i32::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "LongestCircuit --bound must fit in i32 (got {bound})\n\nUsage: {usage}" + ) + })?; + if bound <= 0 { + bail!("LongestCircuit --bound must be positive (> 0)"); + } + ( + ser(LongestCircuit::new(graph, edge_lengths, bound))?, + resolved_variant.clone(), + ) + } + // MultipleChoiceBranching "MultipleChoiceBranching" => { let usage = "Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10"; @@ -4999,6 +5052,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); @@ -5178,7 +5248,8 @@ fn create_random( "Random generation is not supported for {canonical}. \ Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \ MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, KClique, TravelingSalesman, \ - SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, HamiltonianPath, GeneralizedHex)" + SteinerTreeInGraphs, HamiltonianCircuit, SteinerTree, OptimalLinearArrangement, \ + HamiltonianPath, LongestCircuit, GeneralizedHex)" ), }; diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index 06ae08c6c..bddef2e49 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -212,6 +212,53 @@ 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_accepts_edge_lengths_array() { + 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])); + } + + #[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..d8fdc2567 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 ), }; @@ -716,7 +763,11 @@ impl McpServer { let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); targets.sort(); targets.dedup(); - let solvers = problem.available_solvers(); + let solvers = if problem.supports_ilp_solver() { + vec!["ilp", "brute-force"] + } else { + vec!["brute-force"] + }; let result = serde_json::json!({ "kind": "problem", @@ -1321,8 +1372,8 @@ fn parse_edge_lengths_from_params( params: &serde_json::Value, num_edges: usize, ) -> anyhow::Result> { - match params.get("edge_lengths").and_then(|v| v.as_str()) { - Some(w) => { + match params.get("edge_lengths") { + Some(serde_json::Value::String(w)) => { let lengths: Vec = w .split(',') .map(|s| s.trim().parse::()) @@ -1336,6 +1387,30 @@ fn parse_edge_lengths_from_params( } Ok(lengths) } + Some(serde_json::Value::Array(values)) => { + let lengths: Vec = values + .iter() + .map(|value| { + let raw = value.as_i64().ok_or_else(|| { + anyhow::anyhow!("edge_lengths array must contain only integers") + })?; + i32::try_from(raw).map_err(|_| { + anyhow::anyhow!("edge_lengths values must fit in i32 (got {raw})") + }) + }) + .collect::>>()?; + if lengths.len() != num_edges { + anyhow::bail!( + "Expected {} edge lengths but got {}", + num_edges, + lengths.len() + ); + } + Ok(lengths) + } + Some(_) => { + anyhow::bail!("edge_lengths must be a comma-separated string or array of integers") + } None => Ok(vec![1i32; num_edges]), } } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index a5e4abe85..537ec58f0 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -4033,6 +4033,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"); From 90711a34b275d48586dee7d0d7c67caea55c62c8 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 22:42:22 +0800 Subject: [PATCH 4/7] chore: remove plan file after implementation --- docs/plans/2026-03-21-longest-circuit.md | 293 ----------------------- 1 file changed, 293 deletions(-) delete mode 100644 docs/plans/2026-03-21-longest-circuit.md diff --git a/docs/plans/2026-03-21-longest-circuit.md b/docs/plans/2026-03-21-longest-circuit.md deleted file mode 100644 index 07c418ff1..000000000 --- a/docs/plans/2026-03-21-longest-circuit.md +++ /dev/null @@ -1,293 +0,0 @@ -# LongestCircuit Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `[Model] LongestCircuit` graph satisfaction model from issue `#287`, including registry/example-db/CLI integration and the paper entry needed for the companion rule issue `#358` (`HamiltonianCircuit -> LongestCircuit`). - -**Architecture:** Model `LongestCircuit` as an edge-selection satisfaction problem on `SimpleGraph` with a generic edge-length type `W: WeightElement`, matching the repo's existing weighted graph satisfaction models. A configuration is valid iff the selected edges form one connected simple circuit and the selected edge-length sum is at least the positive bound `K`. Batch 1 handles code, tests, example-db, CLI, and MCP integration; Batch 2 adds the paper entry after the canonical example is stable. - -**Tech Stack:** Rust workspace (`problemreductions`, `problemreductions-cli`), serde/inventory registry, `BruteForce` solver, Typst paper, GitHub issue workflow. - ---- - -## Source-of-Truth Notes - -- Issue: `#287 [Model] LongestCircuit` -- Companion rule already exists as issue `#358 [Rule] HAMILTONIAN CIRCUIT to LONGEST CIRCUIT`, so this model is not orphaned. -- Use the corrected issue comments as design input: - - Keep the model as the decision version with positive edge lengths and positive bound `K`. - - Avoid the misleading `1.657^n` Hamiltonicity claim for longest cycle. - - Use the issue's 6-vertex, 10-edge weighted YES instance as the canonical example/paper figure. -- Canonical implementation choices: - - Category: `src/models/graph/` - - Struct: `LongestCircuit` - - Fields: `graph`, `edge_lengths`, `bound` - - Getter size fields: `num_vertices()`, `num_edges()` - - Config encoding: one binary variable per edge (`dims() == vec![2; num_edges]`) - - Validity helper: selected edges induce exactly one connected 2-regular subgraph and total selected length is `>= bound` - -## Batch 1: Model, Tests, Example-DB, CLI, MCP - -### Task 1: Write the first failing model tests - -**Files:** -- Create: `src/unit_tests/models/graph/longest_circuit.rs` -- Read for pattern: `src/unit_tests/models/graph/shortest_weight_constrained_path.rs` -- Read for pattern: `src/unit_tests/models/graph/rural_postman.rs` - -**Step 1: Write the failing tests** - -Add focused tests for: -- creation/accessors on the issue instance -- `evaluate()` on one valid circuit and a few invalid edge selections -- brute-force satisfiability on the issue YES instance - -Use the issue's canonical graph and the expected satisfying circuit `0-1-2-3-4-5-0`. - -**Step 2: Run the focused test to verify it fails** - -Run: `cargo test longest_circuit --lib` - -Expected: FAIL because `LongestCircuit` is not implemented or not exported yet. - -**Step 3: Do not add behavior here** - -Stop once the failure is the expected missing-model failure. - -### Task 2: Implement the core model and register it - -**Files:** -- Create: `src/models/graph/longest_circuit.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` -- Modify: `src/unit_tests/models/graph/longest_circuit.rs` - -**Step 1: Implement the minimal model to satisfy Task 1** - -Add `LongestCircuit` with: -- `ProblemSchemaEntry` for graph + weight dimensions -- constructor validation: - - `edge_lengths.len() == graph.num_edges()` - - all edge lengths positive - - `bound > 0` -- accessors: `graph()`, `edge_lengths()`, `bound()`, `num_vertices()`, `num_edges()`, `is_weighted()`, optional `set_lengths(...)` -- `Problem` impl with `Metric = bool` -- `SatisfactionProblem` impl -- helper that accepts only one simple circuit: - - config length matches `num_edges` - - values are binary - - selected edge count at least 3 - - each selected vertex has degree exactly 2 - - non-selected vertices have degree 0 - - selected subgraph is connected - - total selected length `>= bound` -- `declare_variants!` for `LongestCircuit` -- `#[cfg(test)]` link to the new unit test file - -Export the new model from the graph module, global model re-export, and prelude. - -**Step 2: Run the focused model tests** - -Run: `cargo test longest_circuit --lib` - -Expected: PASS for the new model tests. - -**Step 3: Refactor only if needed** - -If the circuit-validation helper is noisy, extract a small private helper for degree/connectivity counting, but keep the representation edge-based for compatibility with issue `#358`. - -### Task 3: Add canonical example-db support before CLI work - -**Files:** -- Modify: `src/models/graph/longest_circuit.rs` -- Modify: `src/models/graph/mod.rs` -- Read for pattern: `src/models/graph/hamiltonian_circuit.rs` -- Read for pattern: `src/example_db/model_builders.rs` - -**Step 1: Add the canonical model example spec** - -Inside `src/models/graph/longest_circuit.rs`, add `canonical_model_example_specs()` gated by `example-db` using the issue's YES instance: -- graph edges in issue order -- edge lengths `[3, 2, 4, 1, 5, 2, 3, 2, 1, 2]` -- bound `17` -- optimal/satisfying config selecting the 6-cycle `0-1-2-3-4-5-0` - -Register the new example chain in `src/models/graph/mod.rs`. - -**Step 2: Run the example-db tests that should cover lookup/build** - -Run: `cargo test example_db --features example-db` - -Expected: PASS, and the new model example appears in the built example DB. - -### Task 4: Add CLI create support and CLI tests - -**Files:** -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `problemreductions-cli/tests/cli_tests.rs` -- Optionally modify: `problemreductions-cli/src/problem_name.rs` only if exact-canonical-name resolution is insufficient - -**Step 1: Write failing CLI tests first** - -Add tests for: -- explicit creation: - - `pred create LongestCircuit --graph ... --edge-weights ... --bound 17` -- rejection of missing `--edge-weights` -- rejection of non-positive `--bound` -- help text / usage string mentions the right flags -- random creation: - - `pred create LongestCircuit --random --num-vertices 6 --bound 4` - -Use `--edge-weights` as the CLI surface for the single edge-length vector, following `RuralPostman` and other one-vector edge-weight graph problems. - -**Step 2: Run the new CLI tests to verify they fail** - -Run: `cargo test -p problemreductions-cli longest_circuit` - -Expected: FAIL because the create command and help text do not know `LongestCircuit` yet. - -**Step 3: Implement the minimal CLI support** - -In `problemreductions-cli/src/commands/create.rs`: -- add an explicit create arm for `LongestCircuit` -- parse `graph`, `edge_weights`, and `bound` -- validate positive edge weights and positive bound -- serialize `LongestCircuit::new(graph, edge_weights, bound)` -- add a random-create arm that generates a random graph, unit edge lengths, and a reasonable positive default bound (for example `max(3, num_vertices / 2)`) - -In `problemreductions-cli/src/cli.rs`: -- add `LongestCircuit` to the "Flags by problem type" help table -- update any help text mentioning supported `--bound` users if needed - -**Step 4: Re-run the focused CLI tests** - -Run: `cargo test -p problemreductions-cli longest_circuit` - -Expected: PASS. - -### Task 5: Add MCP creation support and focused regression checks - -**Files:** -- Modify: `problemreductions-cli/src/mcp/tools.rs` -- Read for pattern: existing `MaxCut` / `TravelingSalesman` / `MinimumSumMulticenter` MCP branches - -**Step 1: Write or reuse a focused failing regression if there is already MCP coverage** - -If an MCP test file already covers graph creation routing, add a failing test there for `LongestCircuit`. If there is no practical focused test harness, document that and use code review plus compile/test verification. - -**Step 2: Implement MCP support** - -Update the MCP creation helpers so `LongestCircuit` can be created from MCP params in both normal and random flows: -- parse graph params -- parse one edge-weight vector as the circuit lengths -- parse positive `bound` -- serialize with the `SimpleGraph/i32` variant - -**Step 3: Run the tightest relevant verification** - -Run one of: -- `cargo test -p problemreductions-cli mcp` -- or, if the test names are more granular, the narrowest matching MCP test selection - -Expected: PASS. - -### Task 6: Add final model-level tests and run Batch 1 verification - -**Files:** -- Modify: `src/unit_tests/models/graph/longest_circuit.rs` - -**Step 1: Expand the test file to cover the final model contract** - -Ensure the test file includes at least: -- creation/accessors -- `evaluate()` valid/invalid circuit selections -- brute-force satisfiable and unsatisfiable cases -- serialization round-trip -- paper/canonical example test using the issue's YES instance - -Add at least one invalid case for each structural failure mode: -- disconnected selected cycles -- degree-1 or degree-3 selection -- length below bound - -**Step 2: Run focused model tests** - -Run: `cargo test longest_circuit --lib` - -Expected: PASS. - -**Step 3: Run Batch 1 workspace checks** - -Run: -- `cargo test longest_circuit` -- `cargo test -p problemreductions-cli longest_circuit` - -Expected: PASS for all targeted checks before moving to the paper batch. - -## Batch 2: Paper Entry and End-to-End Verification - -### Task 7: Add the paper entry and align it with the canonical example - -**Files:** -- Modify: `docs/paper/reductions.typ` -- Read for pattern: `problem-def("HamiltonianCircuit")` -- Read for pattern: `problem-def("TravelingSalesman")` - -**Step 1: Write the failing paper-adjacent test first** - -If the model test file does not already assert the canonical example from the paper, add or tighten `test_longest_circuit_paper_example` before editing the Typst entry so the model-side example is pinned. - -Run: `cargo test longest_circuit_paper_example --lib` - -Expected: PASS or FAIL only for model/example mismatch. Fix the model/example first, not Typst. - -**Step 2: Implement the Typst entry** - -Add: -- display-name dictionary entry for `LongestCircuit` -- `problem-def("LongestCircuit")` with: - - formal definition using positive edge lengths and threshold `K` - - brief background and corrected algorithm notes - - a worked example using the canonical YES instance - - `pred-commands(...)` using the example-db-backed pattern, not a hand-written fragile spec -- a small circuit visualization that highlights the satisfying cycle - -Keep the algorithm discussion conservative: -- state the classical exact baseline carefully -- do not repeat the misleading Hamiltonicity-only `1.657^n` claim as the longest-cycle complexity - -**Step 3: Run paper verification** - -Run: `make paper` - -Expected: PASS. - -### Task 8: Run full verification, then prepare the implementation summary - -**Files:** -- No new files expected - -**Step 1: Run the repo verification required before closing the issue-to-pr execution** - -Run: -- `make test` -- `make clippy` -- `git status --short` - -Expected: -- tests pass -- clippy passes -- only intended tracked files are modified - -**Step 2: Prepare commit boundaries** - -Create small coherent commits, for example: -- model/tests/example-db integration -- CLI/MCP integration -- paper entry - -**Step 3: Record deviations if any** - -If implementation differs from this plan, note the difference for the PR summary comment before push. From 2760d9570416c562aab82cc3d1630fdd3126790f Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 22 Mar 2026 00:33:52 +0800 Subject: [PATCH 5/7] cargo fmt --- problemreductions-cli/src/commands/create.rs | 8 +++++--- src/models/mod.rs | 15 +++++++-------- .../graph/bottleneck_traveling_salesman.rs | 10 +++++----- .../models/graph/longest_circuit.rs | 19 +++++++++++++++---- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 0ea1c92dd..4e4c95643 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -14,8 +14,8 @@ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, LengthBoundedDisjointPaths, LongestCircuit, MinimumCutIntoBoundedSets, MinimumMultiwayCut, - MixedChinesePostman, - MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, + MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, + StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, @@ -1492,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))?, diff --git a/src/models/mod.rs b/src/models/mod.rs index 99d5a4e4c..d15d4f5d6 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -20,14 +20,13 @@ pub use formula::{ pub use graph::{ AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, - GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - IsomorphicSpanningTree, KClique, KColoring, KthBestSpanningTree, - LengthBoundedDisjointPaths, LongestCircuit, MaxCut, MaximalIS, MaximumClique, - MaximumIndependentSet, MaximumMatching, MinMaxMulticenter, MinimumCutIntoBoundedSets, - MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, - MinimumSumMulticenter, MinimumVertexCover, MixedChinesePostman, MultipleChoiceBranching, - MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, - PartitionIntoTriangles, RuralPostman, + GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, + KClique, KColoring, KthBestSpanningTree, LengthBoundedDisjointPaths, LongestCircuit, MaxCut, + MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinMaxMulticenter, + MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, + MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, + MixedChinesePostman, MultipleChoiceBranching, MultipleCopyFileAllocation, + OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, ShortestWeightConstrainedPath, SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, diff --git a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs index a7981ad99..a82530101 100644 --- a/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs +++ b/src/unit_tests/models/graph/bottleneck_traveling_salesman.rs @@ -73,16 +73,16 @@ fn test_bottleneck_traveling_salesman_evaluate_valid_and_invalid() { #[test] fn test_bottleneck_traveling_salesman_evaluate_disconnected_subtour_invalid() { let problem = BottleneckTravelingSalesman::new( - SimpleGraph::new( - 6, - vec![(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)], - ), + SimpleGraph::new(6, vec![(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]), vec![1, 1, 1, 2, 2, 2], ); let disconnected_subtour = vec![1, 1, 1, 1, 1, 1]; assert!(!problem.is_valid_solution(&disconnected_subtour)); - assert_eq!(problem.evaluate(&disconnected_subtour), SolutionSize::Invalid); + assert_eq!( + problem.evaluate(&disconnected_subtour), + SolutionSize::Invalid + ); } #[test] diff --git a/src/unit_tests/models/graph/longest_circuit.rs b/src/unit_tests/models/graph/longest_circuit.rs index b9b7df0b5..c3407f801 100644 --- a/src/unit_tests/models/graph/longest_circuit.rs +++ b/src/unit_tests/models/graph/longest_circuit.rs @@ -120,19 +120,30 @@ fn test_longest_circuit_paper_example() { #[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); + 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); + 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); + 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]); } From 0b03fd2dc306ebbfd0bc6d9be303993e94763bad Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 22 Mar 2026 00:42:57 +0800 Subject: [PATCH 6/7] 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) --- problemreductions-cli/src/commands/create.rs | 9 +----- problemreductions-cli/src/mcp/tests.rs | 15 --------- problemreductions-cli/src/mcp/tools.rs | 34 ++------------------ 3 files changed, 4 insertions(+), 54 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 4e4c95643..3714d2441 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -1564,14 +1564,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let bound = args.bound.ok_or_else(|| { anyhow::anyhow!("LongestCircuit requires --bound\n\nUsage: {usage}") })?; - let bound = i32::try_from(bound).map_err(|_| { - anyhow::anyhow!( - "LongestCircuit --bound must fit in i32 (got {bound})\n\nUsage: {usage}" - ) - })?; - if bound <= 0 { - bail!("LongestCircuit --bound must be positive (> 0)"); - } + let bound = validate_longest_circuit_bound(bound, Some(usage))?; ( ser(LongestCircuit::new(graph, edge_lengths, bound))?, resolved_variant.clone(), diff --git a/problemreductions-cli/src/mcp/tests.rs b/problemreductions-cli/src/mcp/tests.rs index bddef2e49..715588dcd 100644 --- a/problemreductions-cli/src/mcp/tests.rs +++ b/problemreductions-cli/src/mcp/tests.rs @@ -228,21 +228,6 @@ mod tests { assert_eq!(json["data"]["bound"], 3); } - #[test] - fn test_create_problem_longest_circuit_accepts_edge_lengths_array() { - 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])); - } - #[test] fn test_create_problem_longest_circuit_random() { let server = McpServer::new(); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index d8fdc2567..1172c36cb 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -763,11 +763,7 @@ impl McpServer { let mut targets: Vec = outgoing.iter().map(|e| e.target_name.to_string()).collect(); targets.sort(); targets.dedup(); - let solvers = if problem.supports_ilp_solver() { - vec!["ilp", "brute-force"] - } else { - vec!["brute-force"] - }; + let solvers = problem.available_solvers(); let result = serde_json::json!({ "kind": "problem", @@ -1372,8 +1368,8 @@ fn parse_edge_lengths_from_params( params: &serde_json::Value, num_edges: usize, ) -> anyhow::Result> { - match params.get("edge_lengths") { - Some(serde_json::Value::String(w)) => { + match params.get("edge_lengths").and_then(|v| v.as_str()) { + Some(w) => { let lengths: Vec = w .split(',') .map(|s| s.trim().parse::()) @@ -1387,30 +1383,6 @@ fn parse_edge_lengths_from_params( } Ok(lengths) } - Some(serde_json::Value::Array(values)) => { - let lengths: Vec = values - .iter() - .map(|value| { - let raw = value.as_i64().ok_or_else(|| { - anyhow::anyhow!("edge_lengths array must contain only integers") - })?; - i32::try_from(raw).map_err(|_| { - anyhow::anyhow!("edge_lengths values must fit in i32 (got {raw})") - }) - }) - .collect::>>()?; - if lengths.len() != num_edges { - anyhow::bail!( - "Expected {} edge lengths but got {}", - num_edges, - lengths.len() - ); - } - Ok(lengths) - } - Some(_) => { - anyhow::bail!("edge_lengths must be a comma-separated string or array of integers") - } None => Ok(vec![1i32; num_edges]), } } From f046940c50bc6316fec0ecdaabc5a59903e54339 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 22 Mar 2026 02:05:49 +0800 Subject: [PATCH 7/7] 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) --- problemreductions-cli/src/commands/create.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 3714d2441..d03de0341 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -972,18 +972,18 @@ fn validate_length_bounded_disjoint_paths_args( } fn validate_longest_circuit_bound(bound: i64, usage: Option<&str>) -> Result { - let bound = i32::try_from(bound).map_err(|_| match usage { - Some(usage) => { - anyhow::anyhow!("--bound must be a positive integer for LongestCircuit\n\n{usage}") + 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}"), } - None => anyhow::anyhow!("--bound must be a positive integer for LongestCircuit"), })?; if bound <= 0 { + let msg = "LongestCircuit --bound must be positive (> 0)"; return Err(match usage { - Some(usage) => { - anyhow::anyhow!("--bound must be a positive integer for LongestCircuit\n\n{usage}") - } - None => anyhow::anyhow!("--bound must be a positive integer for LongestCircuit"), + Some(u) => anyhow::anyhow!("{msg}\n\n{u}"), + None => anyhow::anyhow!("{msg}"), }); } Ok(bound)