Skip to content

Commit 679d7ab

Browse files
authored
fix(socp): canonicalize QC Q COO for rotated SOC detection (#1439)
Quadratic-constraint `Q` is now stored in **canonical COO** internally: one coefficient per variable pair (e.g. a single `-2` for `2·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). MPS `QCMATRIX` still 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·u` for `||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, #1354 Authors: - Yuwen Chen (https://github.com/yuwenchen95) Approvers: - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv) - Miles Lubin (https://github.com/mlubin) - Chris Maes (https://github.com/chris-maes) URL: #1439
1 parent cb0a2b6 commit 679d7ab

32 files changed

Lines changed: 860 additions & 267 deletions

cpp/include/cuopt/mathematical_optimization/io/data_model_view.hpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,10 +417,14 @@ class data_model_view_t {
417417
bool is_Q_symmetrized() const noexcept;
418418

419419
/**
420-
* @brief Quadratic constraints (MPS QCMATRIX); owned copy for writers when not using spans.
420+
* @brief Set quadratic constraints (linear part + Q in COO) on the view.
421+
*
422+
* Stores an owned copy retrievable via get_quadratic_constraints().
421423
*/
422424
void set_quadratic_constraints(
423425
std::vector<typename mps_data_model_t<i_t, f_t>::quadratic_constraint_t> constraints);
426+
/** @copydoc set_quadratic_constraints(std::vector<typename mps_data_model_t<i_t,
427+
* f_t>::quadratic_constraint_t>) */
424428
template <typename qc_t>
425429
void set_quadratic_constraints(const std::vector<qc_t>& constraints)
426430
{

cpp/include/cuopt/mathematical_optimization/io/mps_data_model.hpp

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,9 @@ class mps_data_model_t {
239239
* - row identity and type (from ROWS),
240240
* - sparse linear coefficients (from COLUMNS),
241241
* - RHS value (from RHS),
242-
* - quadratic matrix Q in COO (SoA: row, col, value) from QCMATRIX — one triplet per nonzero.
242+
* - quadratic matrix Q in upper-triangular COO format.
243+
* Off-diagonal cross terms store the full coefficient on x_i*x_j
244+
* (e.g. -2*d for rotated SOC), not as symmetric halves.
243245
*/
244246
struct quadratic_constraint_t {
245247
/** ROWS declaration index (among all constraint rows), not an index into the linear CSR. */
@@ -251,7 +253,7 @@ class mps_data_model_t {
251253
std::vector<f_t> linear_values{};
252254
std::vector<i_t> linear_indices{};
253255
f_t rhs_value{f_t(0)};
254-
/** Q nonzeros: parallel arrays, same length (COO / SoA). Sorted by (row, col) in append. */
256+
255257
std::vector<i_t> rows{};
256258
std::vector<i_t> cols{};
257259
std::vector<f_t> vals{};
@@ -262,7 +264,8 @@ class mps_data_model_t {
262264
* @note All span inputs are host memory; the model copies this data.
263265
* @param linear_values, linear_indices Same nnz; can be empty for a purely quadratic row (rare).
264266
* @param vals, rows, cols COO triplets for Q; same length; may all be empty if Q is empty.
265-
* Stored sorted by (row, col).
267+
* Canonicalized on ingest to one triplet per variable pair (full x^T Q x coefficient),
268+
* stored in upper-triangular (min row, max col) form.
266269
* @param constraint_row_type MPS ROWS type: 'L' (<=) or 'G' (>=). Stored as given; 'G' rows are
267270
* converted to '<=' form when building the SOCP for the barrier solver. Equality ('E') is
268271
* not supported.
@@ -385,4 +388,17 @@ class mps_data_model_t {
385388

386389
}; // class mps_data_model_t
387390

391+
/**
392+
* @brief Canonicalize each quadratic constraint's Q matrix.
393+
*
394+
* Merge duplicate COO entries and off-diagonal variable pairs, then store Q in
395+
* upper-triangular form with one coefficient per variable pair (the full x^T Q x
396+
* contribution for that pair).
397+
*
398+
* @param constraints Quadratic constraints whose rows, cols, and vals are updated.
399+
*/
400+
template <typename i_t, typename f_t>
401+
void canonicalize_quadratic_constraints(
402+
std::vector<typename mps_data_model_t<i_t, f_t>::quadratic_constraint_t>& constraints);
403+
388404
} // namespace cuopt::mathematical_optimization::io

cpp/include/cuopt/mathematical_optimization/io/parser.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ mps_data_model_t<i_t, f_t> read_mps_from_string(std::string_view mps_contents,
7575
* between objective and constraints:
7676
* - Objective bracket: MUST be followed by `/ 2` (the LP file states
7777
* coefficients in the `0.5 x^T Q x` convention).
78-
* - Constraint bracket: MUST NOT be followed by `/ 2`; coefficients are
79-
* taken at face value (`x^T Q x`).
78+
* - Constraint bracket: MUST NOT be followed by `/ 2`; Q coefficients are
79+
* stored as upper triangular form.
8080
*
8181
* SOS constraints, PWL objectives, general constraints, and user cuts cause
8282
* a ValidationError when encountered.

cpp/include/cuopt/mathematical_optimization/optimization_problem_interface.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class optimization_problem_interface_t {
8484
* Quadratic matrix Q is COO (row_index, col_index, coeff spans). Linear term d uses parallel
8585
* linear_values and linear_indices (empty allowed). constraint_row_index is assigned
8686
* automatically as n_linear_constraints + n_existing_quadratic_constraints.
87+
* Q is canonicalized on ingest (merge duplicates, collapse matching symmetric pairs).
8788
*/
8889
virtual void add_quadratic_constraint(char constraint_row_type,
8990
f_t rhs_value,

cpp/include/cuopt/mathematical_optimization/optimization_problem_utils.hpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ void populate_from_mps_data_model(optimization_problem_interface_t<i_t, f_t>* pr
130130
q_offsets.data(),
131131
n_vars + 1);
132132
}
133-
// Handle quadratic constraints if present
133+
// Quadratic constraints from mps_data_model are already canonical (append_quadratic_constraint).
134134
if (data_model.has_quadratic_constraints()) {
135135
problem->set_quadratic_constraints(data_model.get_quadratic_constraints());
136136
}
@@ -291,8 +291,11 @@ void populate_from_data_model_view(
291291
problem->set_row_names(data_model->get_row_names());
292292
}
293293

294+
// Raw Q COO from data_model_view is canonicalized here before solver storage.
294295
if (data_model->has_quadratic_constraints()) {
295-
problem->set_quadratic_constraints(data_model->get_quadratic_constraints());
296+
auto qcs = data_model->get_quadratic_constraints();
297+
io::canonicalize_quadratic_constraints<i_t, f_t>(qcs);
298+
problem->set_quadratic_constraints(std::move(qcs));
296299
}
297300
}
298301

cpp/src/barrier/translate_soc.hpp

Lines changed: 30 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,7 @@ void convert_quadratic_constraints_to_second_order_cones(
9292
// -s*x_head^2 + sum_i s*x_tail_i^2 <= 0 (any common s > 0; divide by s to normalize)
9393
// 2) rotated SOC rows:
9494
// -2*d*x_head0*x_head1 + sum_i s*x_tail_i^2 <= 0 (d>0, s>0; canonical d=s)
95-
// symmetric Q off-diagonals (-d,-d) give x^T Q x cross term -2*d*x0*x1, i.e. a*x0*x1
96-
// in the inequality 2*d*x0*x1 >= s*||tail||^2 with a = 2*d. Lift uses sqrt(d/s) on heads.
95+
// stored as one Q cross term (head0, head1, -2*d). Lift uses sqrt(d/s) on heads.
9796
// 3) quadratic rows with linear part:
9897
// sum_i s*x_tail_i^2 + a^T x <= 0
9998
// represented as diagonal +s QCMATRIX entries plus linear terms in COLUMNS.
@@ -108,7 +107,8 @@ void convert_quadratic_constraints_to_second_order_cones(
108107
i_t head1{};
109108
std::vector<i_t> tails{};
110109
bool head1_is_constant_half{false};
111-
/// For two-head rotated SOC: sqrt(d/s) where Q_off = -d and tail diagonals +s (canonical 1).
110+
/// For two-head rotated SOC: sqrt(d/s) where Q cross = -2*d and tail diagonals +s (canonical
111+
/// 1).
112112
f_t head_lift_sqrt_ratio{1};
113113
};
114114
// This is the index of the auxiliary variable for the linear part of the quadratic constraint.
@@ -188,7 +188,7 @@ void convert_quadratic_constraints_to_second_order_cones(
188188

189189
// Verify Q as either:
190190
// - standard SOC: one diagonal -s (head), tail diagonals +s for a common s > 0,
191-
// - rotated SOC: symmetric (-s,-s) off-diagonal pair on the two heads, tails +s,
191+
// - rotated SOC: one off-diagonal cross term (-2*d) on the two heads, tails +s,
192192
// - affine SOC: tail diagonals +s and linear terms (no Q off-diagonals).
193193
// Feasibility is unchanged after dividing the quadratic row by s; affine rows also scale
194194
// linear coefficients when forming the auxiliary t = -(1/s) a^T x.
@@ -274,11 +274,21 @@ void convert_quadratic_constraints_to_second_order_cones(
274274
}
275275
}
276276
}
277-
const bool use_general_path = has_duplicate_rows || has_near_zero_diag || has_nonzero_rhs ||
278-
has_nonuniform_diag || offdiag_entries.size() > 2 ||
279-
(offdiag_entries.size() == 1) || (neg_diag_rows.size() > 1) ||
280-
(!neg_diag_rows.empty() && has_linear_part) ||
281-
(!neg_diag_rows.empty() && !offdiag_entries.empty());
277+
const bool rotated_soc_cross_eligible = [&]() {
278+
if (offdiag_entries.size() != 1 || has_linear_part || !neg_diag_rows.empty()) {
279+
return false;
280+
}
281+
const i_t a = std::get<0>(offdiag_entries[0]);
282+
const i_t b = std::get<1>(offdiag_entries[0]);
283+
const f_t v = std::get<2>(offdiag_entries[0]);
284+
// Match cross_d = -v/2 > tol validated on the rotated SOC fast path below.
285+
return a != b && (-v / f_t(2)) > tol;
286+
}();
287+
const bool use_general_path =
288+
has_duplicate_rows || has_near_zero_diag || has_nonzero_rhs || has_nonuniform_diag ||
289+
offdiag_entries.size() > 1 || (offdiag_entries.size() == 1 && !rotated_soc_cross_eligible) ||
290+
(neg_diag_rows.size() > 1) || (!neg_diag_rows.empty() && has_linear_part) ||
291+
(!neg_diag_rows.empty() && !offdiag_entries.empty());
282292

283293
f_t uniform_s = 0;
284294
bool have_uniform_s = false;
@@ -456,52 +466,28 @@ void convert_quadratic_constraints_to_second_order_cones(
456466
"Quadratic constraint '%s' rotated SOC Q: could not infer uniform scale s",
457467
qc.constraint_row_name.c_str());
458468
cuopt_expects(
459-
offdiag_entries.size() == 2,
469+
offdiag_entries.size() == 1,
460470
error_type_t::ValidationError,
461-
"Quadratic constraint '%s' rotated SOC Q must contain exactly one symmetric off-diagonal "
462-
"pair (-d,-d); found %zu off-diagonal entries",
471+
"Quadratic constraint '%s' rotated SOC Q must contain exactly one cross term with "
472+
"coefficient -2*d on the head variable pair; found %zu off-diagonal entries",
463473
qc.constraint_row_name.c_str(),
464474
offdiag_entries.size());
465475

466476
const i_t a = std::get<0>(offdiag_entries[0]);
467477
const i_t b = std::get<1>(offdiag_entries[0]);
468478
const f_t v0 = std::get<2>(offdiag_entries[0]);
469-
cuopt_expects(
470-
v0 < -tol,
471-
error_type_t::ValidationError,
472-
"Quadratic constraint '%s' rotated SOC Q off-diagonal must be negative; got %.17g",
473-
qc.constraint_row_name.c_str(),
474-
static_cast<double>(v0));
479+
475480
cuopt_expects(a != b,
476481
error_type_t::ValidationError,
477-
"Quadratic constraint '%s' rotated SOC Q off-diagonal pair must use distinct "
482+
"Quadratic constraint '%s' rotated SOC Q cross term must use distinct head "
478483
"variables",
479484
qc.constraint_row_name.c_str());
480-
cuopt_expects(std::get<0>(offdiag_entries[1]) == b && std::get<1>(offdiag_entries[1]) == a,
481-
error_type_t::ValidationError,
482-
"Quadratic constraint '%s' rotated SOC Q must have symmetric entries (a,b) "
483-
"and (b,a) with the same value",
484-
qc.constraint_row_name.c_str());
485-
const f_t v1 = std::get<2>(offdiag_entries[1]);
486-
cuopt_expects(
487-
v1 < -tol,
488-
error_type_t::ValidationError,
489-
"Quadratic constraint '%s' rotated SOC Q off-diagonal must be negative; got %.17g",
490-
qc.constraint_row_name.c_str(),
491-
static_cast<double>(v1));
492-
cuopt_expects(
493-
approx_eq_scaled(v0, v1),
494-
error_type_t::ValidationError,
495-
"Quadratic constraint '%s' rotated SOC Q symmetric off-diagonals must match; got %.17g "
496-
"and %.17g",
497-
qc.constraint_row_name.c_str(),
498-
static_cast<double>(v0),
499-
static_cast<double>(v1));
500-
const f_t cross_d = -v0;
485+
const f_t cross_d = -v0 / f_t(2);
501486
cuopt_expects(
502487
cross_d > tol,
503488
error_type_t::ValidationError,
504-
"Quadratic constraint '%s' rotated SOC Q cross coefficient d = -Q_off must be positive",
489+
"Quadratic constraint '%s' rotated SOC Q cross coefficient d = -Q_cross/2 must be "
490+
"positive",
505491
qc.constraint_row_name.c_str());
506492
const f_t head_lift_sqrt_ratio = std::sqrt(cross_d / uniform_s);
507493
cuopt_expects(std::isfinite(static_cast<double>(head_lift_sqrt_ratio)),
@@ -511,14 +497,14 @@ void convert_quadratic_constraints_to_second_order_cones(
511497
qc.constraint_row_name.c_str(),
512498
static_cast<double>(cross_d),
513499
static_cast<double>(uniform_s));
514-
cuopt_expects(static_cast<i_t>(tail_vars.size()) == q_nnz - 2,
500+
cuopt_expects(static_cast<i_t>(tail_vars.size()) == q_nnz - 1,
515501
error_type_t::ValidationError,
516502
"Quadratic constraint '%s' rotated SOC Q: expected %d diagonal +s entries "
517503
"(tails), found %zu",
518504
qc.constraint_row_name.c_str(),
519-
static_cast<int>(q_nnz - 2),
505+
static_cast<int>(q_nnz - 1),
520506
tail_vars.size());
521-
cuopt_expects(q_nnz >= 3,
507+
cuopt_expects(q_nnz >= 2,
522508
error_type_t::ValidationError,
523509
"Quadratic constraint '%s' rotated SOC Q must have at least 1 tail entry",
524510
qc.constraint_row_name.c_str());

cpp/src/grpc/codegen/generate_conversions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2215,6 +2215,10 @@ def _gen_proto_to_problem(registry, indent=" "):
22152215
f'{b["name"]} size mismatch");'
22162216
)
22172217
lines.append(f"{ind} }}")
2218+
if name == "quadratic_constraints":
2219+
lines.append(
2220+
f"{ind} io::canonicalize_coo_matrix(_entry.rows, _entry.cols, _entry.vals);"
2221+
)
22182222
lines.append(f"{ind} _entries.push_back(std::move(_entry));")
22192223
lines.append(f"{ind} }}")
22202224
lines.append(f"{ind} cpu_problem.{setter}(std::move(_entries));")
@@ -2811,6 +2815,10 @@ def _gen_chunked_arrays_to_problem(registry, indent=" "):
28112815
f'{b["name"]} size mismatch");'
28122816
)
28132817
lines.append(f"{ind} }}")
2818+
if name == "quadratic_constraints":
2819+
lines.append(
2820+
f"{ind} io::canonicalize_coo_matrix(_entry.rows, _entry.cols, _entry.vals);"
2821+
)
28142822
lines.append(f"{ind} _entries.push_back(std::move(_entry));")
28152823
lines.append(f"{ind} }}")
28162824
lines.append(f"{ind} cpu_problem.{setter}(std::move(_entries));")

cpp/src/grpc/codegen/generated/generated_chunked_arrays_to_problem.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@
194194
if (_entry.cols.size() != _entry.vals.size()) {
195195
throw std::invalid_argument("set_quadratic_constraints: cols/vals size mismatch");
196196
}
197+
io::canonicalize_coo_matrix(_entry.rows, _entry.cols, _entry.vals);
197198
_entries.push_back(std::move(_entry));
198199
}
199200
cpu_problem.set_quadratic_constraints(std::move(_entries));

cpp/src/grpc/codegen/generated/generated_proto_to_problem.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
if (_entry.cols.size() != _entry.vals.size()) {
9797
throw std::invalid_argument("set_quadratic_constraints: cols/vals size mismatch");
9898
}
99+
io::canonicalize_coo_matrix(_entry.rows, _entry.cols, _entry.vals);
99100
_entries.push_back(std::move(_entry));
100101
}
101102
cpu_problem.set_quadratic_constraints(std::move(_entries));

cpp/src/grpc/grpc_problem_mapper.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <cuopt/mathematical_optimization/cpu_optimization_problem.hpp>
1212
#include <cuopt/mathematical_optimization/mip/solver_settings.hpp>
1313
#include <cuopt/mathematical_optimization/pdlp/solver_settings.hpp>
14+
#include <mps_parser_internal.hpp>
1415
#include "grpc_settings_mapper.hpp"
1516

1617
#include <algorithm>

0 commit comments

Comments
 (0)