fix(socp): canonicalize QC Q COO for rotated SOC detection (#1435)#1439
fix(socp): canonicalize QC Q COO for rotated SOC detection (#1435)#1439yuwenchen95 wants to merge 4 commits into
Conversation
Store quadratic-constraint Q in canonical COO (one cross coefficient per variable pair) and run canonicalization at ingest boundaries (MPS/LP, C API, Python→solver, gRPC, MPS export). The RSOC fast path now accepts a single cross term (e.g. -2*x0*x1 for ||tail||^2 <= 2*x0*x1) instead of requiring symmetric MPS-style halves, which previously routed natural API forms to the general QC path and produced wrong optima. MPS QCMATRIX still accepts symmetric halves; the writer expands canonical cross terms back to MPS form on export. Adds tests and doc updates for C API, parser, and examples. Signed-off-by: yuwenchen95 <yuwchen@nvidia.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughIntroduces a canonical COO representation for quadratic constraints where off-diagonal cross terms store the full coefficient rather than symmetric halves. Adds ChangesCanonical QC COO representation and rotated-SOC bug fix
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
cpp/src/barrier/translate_soc.hpp (1)
500-510:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winAllow one-tail rotated cones.
Line 507 still rejects
q_nnz == 2, but canonicalx² - 2*y*z <= 0has exactly one tail diagonal plus one cross term. The precedingtail_vars.size() == q_nnz - 1check already validates the canonical count; require at least one tail instead.🐛 Proposed fix
- cuopt_expects(q_nnz >= 3, + cuopt_expects(!tail_vars.empty(), error_type_t::ValidationError, "Quadratic constraint '%s' rotated SOC Q must have at least 1 tail entry", qc.constraint_row_name.c_str());Please also add a one-tail RSOC regression such as
x0^2 - 2*y*z <= 0.As per coding guidelines, incorrect constraint handling/objective computation for RSOC/Q canonicalization is CRITICAL.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cpp/src/barrier/translate_soc.hpp` around lines 500 - 510, The second cuopt_expects check currently requires q_nnz >= 3, but canonical rotated SOC forms like x² - 2*y*z <= 0 have exactly q_nnz == 2 (one tail diagonal and one cross term). Since the preceding tail_vars.size() == q_nnz - 1 check already validates the correct count of tail variables, change the minimum requirement from q_nnz >= 3 to q_nnz >= 2 to allow at least one tail entry. Additionally, add a regression test case with a one-tail RSOC constraint matching the canonical form x0^2 - 2*y*z <= 0.Source: Coding guidelines
🧹 Nitpick comments (2)
cpp/tests/linear_programming/parser_test.cpp (1)
2768-2777: ⚡ Quick winCover mismatched MPS symmetric halves too.
Line 2768 covers a missing reverse entry, but the strict MPS path should also reject both halves when their values differ. Add a small test for
{(0,1,2), (1,0,3)}withrequire_symmetric_offdiagonal_pairs=true.🧪 Proposed test coverage
TEST(qc_coo_canonicalize, mps_requires_matching_symmetric_half) { std::vector<int> rows = {0}; std::vector<int> cols = {1}; std::vector<double> vals = {2.0}; qc_coo_canonicalize_options_t<double> opts; opts.require_symmetric_offdiagonal_pairs = true; opts.constraint_name = "QC0"; EXPECT_THROW(canonicalize_qc_coo(rows, cols, vals, opts), std::logic_error); } + +TEST(qc_coo_canonicalize, mps_rejects_mismatched_symmetric_halves) +{ + std::vector<int> rows = {0, 1}; + std::vector<int> cols = {1, 0}; + std::vector<double> vals = {2.0, 3.0}; + qc_coo_canonicalize_options_t<double> opts; + opts.require_symmetric_offdiagonal_pairs = true; + opts.constraint_name = "QC0"; + EXPECT_THROW(canonicalize_qc_coo(rows, cols, vals, opts), std::logic_error); +}As per coding guidelines, parser/SOCP tests for RSOC-related changes should cover edge cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cpp/tests/linear_programming/parser_test.cpp` around lines 2768 - 2777, The current test mps_requires_matching_symmetric_half only covers the case where a reverse entry is missing. Add another test function to cover the case where both symmetric halves exist but with mismatched values. Create a new test that calls canonicalize_qc_coo with row indices {0, 1}, column indices {1, 0}, values {2.0, 3.0}, and require_symmetric_offdiagonal_pairs set to true, then verify it throws std::logic_error. This ensures the strict MPS path rejects both matching and mismatching symmetric pairs when the pairs have different values.Source: Coding guidelines
cpp/src/io/quadratic_constraint_coo.cpp (1)
115-117: 💤 Low valueNear-zero diagonal entries not filtered after aggregation.
Diagonal entries that aggregate to near-zero values (e.g.,
+1and-1for the same(i,i)) are still emitted to the output. The aggregation phase filters near-zero inputs (line 63), but aggregated near-zero results on the diagonal are not filtered, unlike off-diagonal entries which uselookup_coeffwith tolerance gating.This is a minor inconsistency; downstream consumers likely handle near-zero coefficients gracefully, but for cleanliness you could add a tolerance check before emitting diagonals.
♻️ Optional: filter near-zero diagonal aggregates
for (const auto& [rc, v] : agg) { - if (rc.first == rc.second) { out.emplace_back(rc.first, rc.second, v); } + if (rc.first == rc.second && std::abs(v) > opts.tol) { + out.emplace_back(rc.first, rc.second, v); + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cpp/src/io/quadratic_constraint_coo.cpp` around lines 115 - 117, The loop iterating through aggregated entries emits diagonal entries (where rc.first == rc.second) without filtering near-zero aggregated values, while the aggregation phase filters near-zero inputs at line 63 and off-diagonal entries use tolerance gating via lookup_coeff. Add a tolerance check before emitting diagonal entries in the emplace_back call to filter out near-zero aggregated diagonal values, ensuring consistency with how off-diagonal entries are handled and maintaining code cleanliness.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@cpp/src/barrier/translate_soc.hpp`:
- Around line 500-510: The second cuopt_expects check currently requires q_nnz
>= 3, but canonical rotated SOC forms like x² - 2*y*z <= 0 have exactly q_nnz ==
2 (one tail diagonal and one cross term). Since the preceding tail_vars.size()
== q_nnz - 1 check already validates the correct count of tail variables, change
the minimum requirement from q_nnz >= 3 to q_nnz >= 2 to allow at least one tail
entry. Additionally, add a regression test case with a one-tail RSOC constraint
matching the canonical form x0^2 - 2*y*z <= 0.
---
Nitpick comments:
In `@cpp/src/io/quadratic_constraint_coo.cpp`:
- Around line 115-117: The loop iterating through aggregated entries emits
diagonal entries (where rc.first == rc.second) without filtering near-zero
aggregated values, while the aggregation phase filters near-zero inputs at line
63 and off-diagonal entries use tolerance gating via lookup_coeff. Add a
tolerance check before emitting diagonal entries in the emplace_back call to
filter out near-zero aggregated diagonal values, ensuring consistency with how
off-diagonal entries are handled and maintaining code cleanliness.
In `@cpp/tests/linear_programming/parser_test.cpp`:
- Around line 2768-2777: The current test mps_requires_matching_symmetric_half
only covers the case where a reverse entry is missing. Add another test function
to cover the case where both symmetric halves exist but with mismatched values.
Create a new test that calls canonicalize_qc_coo with row indices {0, 1}, column
indices {1, 0}, values {2.0, 3.0}, and require_symmetric_offdiagonal_pairs set
to true, then verify it throws std::logic_error. This ensures the strict MPS
path rejects both matching and mismatching symmetric pairs when the pairs have
different values.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 08edaebe-2c73-4a04-be32-3d00d718d07d
⛔ Files ignored due to path filters (2)
cpp/src/grpc/codegen/generated/generated_chunked_arrays_to_problem.incis excluded by!**/generated/**cpp/src/grpc/codegen/generated/generated_proto_to_problem.incis excluded by!**/generated/**
📒 Files selected for processing (31)
cpp/include/cuopt/linear_programming/io/data_model_view.hppcpp/include/cuopt/linear_programming/io/mps_data_model.hppcpp/include/cuopt/linear_programming/optimization_problem_interface.hppcpp/include/cuopt/linear_programming/optimization_problem_utils.hppcpp/src/barrier/translate_soc.hppcpp/src/grpc/codegen/generate_conversions.pycpp/src/grpc/grpc_problem_mapper.cppcpp/src/io/CMakeLists.txtcpp/src/io/data_model_view.cppcpp/src/io/lp_parser.cppcpp/src/io/lp_parser.hppcpp/src/io/mps_data_model.cppcpp/src/io/mps_parser.cppcpp/src/io/mps_writer.cppcpp/src/io/quadratic_constraint_coo.cppcpp/src/io/quadratic_constraint_coo.hppcpp/src/pdlp/cpu_optimization_problem.cppcpp/src/pdlp/optimization_problem.cucpp/tests/linear_programming/c_api_tests/c_api_test.ccpp/tests/linear_programming/c_api_tests/c_api_tests.cppcpp/tests/linear_programming/c_api_tests/c_api_tests.hcpp/tests/linear_programming/parser_test.cppcpp/tests/socp/general_quadratic_test.cudocs/cuopt/source/convex-features.rstdocs/cuopt/source/cuopt-c/convex/convex-c-api.rstdocs/cuopt/source/cuopt-c/convex/convex-examples.rstdocs/cuopt/source/cuopt-c/convex/examples/general_quadratic_example.cdocs/cuopt/source/cuopt-c/convex/examples/rotated_socp_example.cdocs/cuopt/source/cuopt-python/convex/convex-examples.rstdocs/cuopt/source/cuopt-python/convex/examples/rotated_socp_example.pypython/cuopt/cuopt/tests/socp/test_socp.py
| * @param vals, rows, cols COO triplets for Q; same length; may all be empty if Q is empty. | ||
| * Stored sorted by (row, col). | ||
| * Stored sorted by (row, col) in canonical form (one entry per variable pair). | ||
| * @param require_symmetric_q_offdiagonal When true (MPS QCMATRIX), each off-diagonal pair must |
There was a problem hiding this comment.
What's the motivation for this extra argument? Is it an assertion or does the change the behavior of the function?
There was a problem hiding this comment.
This is the flag for additional check with MPS file input that requires symmetric halved inputs for cross terms.
| template <typename i_t, typename f_t> | ||
| void canonicalize_quadratic_constraints( | ||
| std::vector<typename mps_data_model_t<i_t, f_t>::quadratic_constraint_t>& constraints, | ||
| bool require_symmetric_q_offdiagonal = false); |
There was a problem hiding this comment.
Same comment on require_symmetric_q_offdiagonal. Is it an assertion or does the change the behavior of the function?
There was a problem hiding this comment.
This is the flag for additional check with MPS file input that requires symmetric halved inputs for cross terms.
| (supply ``Q[x_0, x_1] = -2`` in COO, or ``-x_0*x_1`` for the ``<= x_0*x_1`` cone form; see :doc:`/convex-features`) | ||
|
|
||
| For the rotated second-order cone constraints, cuOpt expects the quadratic matrix to be symmetric. | ||
| MPS QCMATRIX requires symmetric off-diagonal halves; the C API accepts a single cross coefficient per variable pair (canonicalized on ingest). |
There was a problem hiding this comment.
Be clear what's accepted. If the inputs are canonicalized then it sounds like the C API accepts more than just a single cross coefficient per variable pair.
There was a problem hiding this comment.
We allow users to give duplicate cross coefficients per variable pair and then merge them in the canonicalization step.
…im redundant canonicalization comments Signed-off-by: yuwenchen95 <yuwchen@nvidia.com>
Replace qc_coo options/wrappers with canonicalize_coo_matrix(rows,cols,vals,bool). Use machine epsilon for zero checks; sum both orientations into one (lo,hi) entry. Test via append_quadratic_constraint; update gRPC codegen call sites. Signed-off-by: yuwenchen95 <yuwchen@nvidia.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cpp/src/io/quadratic_constraint_coo.cpp`:
- Around line 22-31: The lookup_coeff function uses
std::numeric_limits<f_t>::epsilon() as a bare absolute threshold for filtering
zero coefficients, which is numerically inappropriate for coefficients with
varying magnitudes. Replace the hardcoded epsilon threshold with a fixed
tolerance value of 1e-9, which is consistent with the existing compare_equal()
function used elsewhere in the codebase. Specifically, modify the line where eps
is declared and update the condition in the if statement that checks
std::abs(it->second) <= eps to use the appropriate tolerance instead of epsilon.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: de147a41-97d7-4cb3-bee0-acb904661cd9
⛔ Files ignored due to path filters (2)
cpp/src/grpc/codegen/generated/generated_chunked_arrays_to_problem.incis excluded by!**/generated/**cpp/src/grpc/codegen/generated/generated_proto_to_problem.incis excluded by!**/generated/**
📒 Files selected for processing (19)
cpp/include/cuopt/linear_programming/io/data_model_view.hppcpp/include/cuopt/linear_programming/io/mps_data_model.hppcpp/include/cuopt/linear_programming/optimization_problem_interface.hppcpp/src/grpc/codegen/generate_conversions.pycpp/src/io/lp_parser.cppcpp/src/io/mps_data_model.cppcpp/src/io/mps_parser.cppcpp/src/io/mps_writer.cppcpp/src/io/quadratic_constraint_coo.cppcpp/src/io/quadratic_constraint_coo.hppcpp/src/pdlp/cpu_optimization_problem.cppcpp/src/pdlp/optimization_problem.cucpp/tests/linear_programming/c_api_tests/c_api_test.ccpp/tests/linear_programming/parser_test.cppdocs/cuopt/source/convex-features.rstdocs/cuopt/source/cuopt-c/convex/convex-c-api.rstdocs/cuopt/source/cuopt-c/convex/convex-examples.rstdocs/cuopt/source/cuopt-c/convex/examples/general_quadratic_example.cdocs/cuopt/source/cuopt-c/convex/examples/rotated_socp_example.c
💤 Files with no reviewable changes (2)
- cpp/include/cuopt/linear_programming/optimization_problem_interface.hpp
- cpp/tests/linear_programming/c_api_tests/c_api_test.c
✅ Files skipped from review due to trivial changes (3)
- docs/cuopt/source/cuopt-c/convex/convex-c-api.rst
- docs/cuopt/source/cuopt-c/convex/convex-examples.rst
- cpp/include/cuopt/linear_programming/io/data_model_view.hpp
🚧 Files skipped from review as they are similar to previous changes (8)
- cpp/src/pdlp/cpu_optimization_problem.cpp
- docs/cuopt/source/cuopt-c/convex/examples/rotated_socp_example.c
- cpp/src/io/mps_parser.cpp
- cpp/src/io/mps_writer.cpp
- cpp/include/cuopt/linear_programming/io/mps_data_model.hpp
- cpp/src/io/mps_data_model.cpp
- cpp/src/io/lp_parser.cpp
- cpp/src/grpc/codegen/generate_conversions.py
| template <typename i_t, typename f_t> | ||
| f_t lookup_coeff(const std::unordered_map<std::pair<i_t, i_t>, f_t, pair_hash<i_t>>& agg, | ||
| i_t r, | ||
| i_t c) | ||
| { | ||
| const f_t eps = std::numeric_limits<f_t>::epsilon(); | ||
| const auto it = agg.find({r, c}); | ||
| if (it == agg.end() || std::abs(it->second) <= eps) { return f_t(0); } | ||
| return it->second; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check test coverage for canonicalize_coo_matrix with different coefficient scales
rg -n -A5 -B5 'canonicalize_coo_matrix|canonicalize_quadratic_constraints' cpp/tests/linear_programming/parser_test.cpp
# Check if solver tolerances are defined that could inform this threshold
rg -n 'tolerance|epsilon.*threshold|zero.*tolerance' cpp/include/cuopt/linear_programming/ cpp/src/pdlp/Repository: NVIDIA/cuopt
Length of output: 17676
🏁 Script executed:
# Find where lookup_coeff is used
rg -n 'lookup_coeff' cpp/src/io/ cpp/include/
# Find the complete quadratic_constraint_coo.cpp file to see full context
head -100 cpp/src/io/quadratic_constraint_coo.cpp
# Search for quadratic constraint parsing and coefficient handling
rg -n -B3 -A3 'quadratic.*constraint|aggregat.*coeff' cpp/src/io/Repository: NVIDIA/cuopt
Length of output: 26068
🏁 Script executed:
# Check if there are any issue discussions or test cases related to coefficient scaling
rg -n 'coefficient.*scale|scale.*coefficient|magnitude.*coeff' cpp/tests/ cpp/src/
# Look for any problem scaling or normalization code
rg -n 'scale.*problem|normalize.*coeff|coefficient.*range' cpp/src/ cpp/include/Repository: NVIDIA/cuopt
Length of output: 13073
🏁 Script executed:
# Check if there's a standard tolerance used elsewhere in I/O for matrix comparisons
rg -n 'csr_matrix_utils|compare.*tol|matrix.*compar' cpp/include/cuopt/linear_programming/io/ cpp/src/io/ -A2 -B2
# Look for TODO or FIXME comments related to epsilon, tolerance, or zero handling in I/O
rg -n 'TODO|FIXME|XXX' cpp/src/io/quadratic_constraint_coo.cpp cpp/include/cuopt/linear_programming/io/quadratic_constraint_coo.hpp
# Check parser documentation or comments about input requirements
rg -n 'scale|normalize|coefficient.*range|input.*range' cpp/src/io/mps_parser.cpp cpp/src/io/lp_parser.cpp -B2 -A2 | head -80Repository: NVIDIA/cuopt
Length of output: 4368
🏁 Script executed:
# Search for how other I/O code handles near-zero values
rg -n 'abs.*<=|numeric_limits.*epsilon' cpp/src/io/ -B1 -A1Repository: NVIDIA/cuopt
Length of output: 1505
🏁 Script executed:
# Check all callers of canonicalize_coo_matrix
rg -n 'canonicalize_coo_matrix' cpp/src/io/ -B5 -A2
# Check mps_data_model append_quadratic_constraint to see if any pre-processing occurs
sed -n '145,210p' cpp/src/io/mps_data_model.cppRepository: NVIDIA/cuopt
Length of output: 7564
🏁 Script executed:
# Check if the LP/MPS parsers read coefficients and any normalization happens before canonicalize
rg -n 'parse.*coefficient|read.*value|value.*scale' cpp/src/io/mps_parser.cpp cpp/src/io/lp_parser.cpp | head -50Repository: NVIDIA/cuopt
Length of output: 1496
🏁 Script executed:
# Check if there's any documentation about input coefficient handling in comments
rg -n 'coefficient.*range|scale.*input|input.*normali|magnitude' cpp/src/io/ cpp/include/cuopt/linear_programming/io/ -B2 -A2 | head -60
# Check the header for canonicalize_coo_matrix to see if there's any contract/documentation
cat cpp/src/io/quadratic_constraint_coo.hppRepository: NVIDIA/cuopt
Length of output: 1404
Replace bare machine epsilon with appropriate I/O tolerance for coefficient thresholding.
The lookup_coeff function uses std::numeric_limits<f_t>::epsilon() (~2.2e-16 for double) as an absolute threshold to determine zero coefficients. This is numerically inappropriate for general coefficient magnitudes; epsilon-based absolute comparisons only make sense for values near 1.0. Coefficients from MPS/LP input can range widely and will be incorrectly filtered or retained.
The codebase already uses 1e-9 as the default tolerance in csr_matrix_utils.hpp::compare_equal() for matrix comparisons. For consistency and robustness, replace the epsilon threshold with a fixed tolerance (e.g., 1e-9) or scale epsilon by the maximum aggregated coefficient magnitude.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cpp/src/io/quadratic_constraint_coo.cpp` around lines 22 - 31, The
lookup_coeff function uses std::numeric_limits<f_t>::epsilon() as a bare
absolute threshold for filtering zero coefficients, which is numerically
inappropriate for coefficients with varying magnitudes. Replace the hardcoded
epsilon threshold with a fixed tolerance value of 1e-9, which is consistent with
the existing compare_equal() function used elsewhere in the codebase.
Specifically, modify the line where eps is declared and update the condition in
the if statement that checks std::abs(it->second) <= eps to use the appropriate
tolerance instead of epsilon.
Signed-off-by: yuwenchen95 <yuwchen@nvidia.com>
Description
Quadratic-constraint
Qis now stored in canonical COO internally: one coefficient per variable pair (e.g. a single-2for2·x₀·x₁), with symmetric MPS halves merged at ingest. Canonicalization runs at ingest boundaries (MPS/LP parser, C API, Python→solver, gRPC, PDLP CPU/GPU problem setup). MPSQCMATRIXstill accepts symmetric halves; the MPS writer expands canonical cross terms back to symmetric form on export.The RSOC fast path now accepts a single eligible cross term (e.g.
-2·t·ufor||tail||² ≤ 2·t·u) instead of requiring two symmetric off-diagonal entries. Previously, natural Python/API forms were misrouted to the general path or failed pattern matching, producing wrong optima (e.g. ~0 instead of √2).Issue
closes #1435
Checklist
Test plan
./build.sh libcuoptwith conda env activectest --test-dir cpp/build -R 'SOCP_TEST|MPS_PARSER_TEST|C_API_TEST'qc_cross_term_stored_canonicaland related LP/MPS QC COO testsC_API_TESTpytest -v cuopt/cuopt/tests/socp/test_socp.py -k rotated_soc-2*t*ucross term → objective ≈ √2 (not ~0)