From 3a01e5710f19612985fe6eca34e32d3639ff6505 Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Sat, 6 Jun 2026 03:28:29 +0300 Subject: [PATCH 1/3] Add algebraic __str__ and detailed __repr__ to Python LP API classes Adds __str__ and __repr__ to Variable, LinearExpression, QuadraticExpression, Constraint, and Problem. Printing these objects now shows their algebraic form (e.g. '2.0 * x + 3.0 * y <= 10.0') and the REPL shows a detailed summary, improving debuggability in notebooks and interactive sessions. The change is purely additive. Signed-off-by: jackthepunished --- .../cuopt/cuopt/linear_programming/problem.py | 195 ++++++++++++++++++ .../linear_programming/test_python_API.py | 126 +++++++++++ 2 files changed, 321 insertions(+) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index 10600a543b..4ddbd56254 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -16,6 +16,100 @@ import warnings +# ---- Display helpers for __str__/__repr__ ---- + +_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="} +_TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"} + + +def _var_display_name(var): + """Return the display name of a Variable.""" + name = var.VariableName + if name: + return name + if getattr(var, "index", -1) >= 0: + return f"C{var.index}" + return f"V{id(var)}" + + +def _type_display(type_val): + """Return a human-readable name for a variable type.""" + if isinstance(type_val, VType): + return type_val.name + if isinstance(type_val, (bytes, bytearray)): + type_val = type_val.decode() + return _TYPE_NAMES.get(type_val, str(type_val)) + + +class _ExprBuilder: + """Build an algebraic string from a sequence of terms. + + The first term is emitted without a sign; subsequent terms are joined + with ' + ' or ' - ' separators. A coefficient of 1.0 or -1.0 is + elided, so '1.0 * x' becomes 'x' and '-1.0 * x' becomes '-x'. + """ + + def __init__(self): + self.parts = [] + + def add_linear(self, coef, var): + """Add a linear term ``coef * var``.""" + if coef == 0.0: + return + var_str = _var_display_name(var) + if coef == 1.0: + self._append(var_str, negative=False) + elif coef == -1.0: + self._append(var_str, negative=True) + else: + self._append(f"{abs(coef)} * {var_str}", negative=coef < 0) + + def add_quadratic(self, coef, var1, var2): + """Add a quadratic term ``coef * var1 * var2``.""" + if coef == 0.0: + return + v1_str = _var_display_name(var1) + v2_str = _var_display_name(var2) + if v1_str == v2_str: + term_str = f"{v1_str}^2" + elif v1_str <= v2_str: + term_str = f"{v1_str} * {v2_str}" + else: + term_str = f"{v2_str} * {v1_str}" + if coef == 1.0: + self._append(term_str, negative=False) + elif coef == -1.0: + self._append(term_str, negative=True) + else: + self._append(f"{abs(coef)} * {term_str}", negative=coef < 0) + + def add_constant(self, value): + """Add a constant term.""" + if value == 0.0: + return + self._append(f"{abs(value)}", negative=value < 0) + + def _append(self, term, negative): + if not self.parts: + self.parts.append(f"-{term}" if negative else term) + else: + self.parts.append(f" - {term}" if negative else f" + {term}") + + def build(self): + if not self.parts: + return "0.0" + return "".join(self.parts) + + +def _format_linear(vars, coeffs, constant): + """Format a linear expression as an algebraic string.""" + builder = _ExprBuilder() + for var, coef in zip(vars, coeffs): + builder.add_linear(coef, var) + builder.add_constant(constant) + return builder.build() + + class VType(str, Enum): """ The type of a variable is continuous, integer, or semi-continuous. @@ -335,6 +429,19 @@ def __eq__(self, other): case _: raise ValueError("Unsupported operation") + def __str__(self): + return _var_display_name(self) + + def __repr__(self): + name = _var_display_name(self) + idx = getattr(self, "index", -1) + type_str = _type_display(self.VariableType) + return ( + f"" + ) + class QuadraticExpression: """ @@ -889,6 +996,25 @@ def __ge__(self, other): def __eq__(self, other): raise ValueError("Equality constraints are not supported.") + def __str__(self): + builder = _ExprBuilder() + if self.qmatrix is not None: + for row, col, val in zip( + self.qmatrix.row, self.qmatrix.col, self.qmatrix.data + ): + if val == 0.0: + continue + builder.add_quadratic(val, self.qvars[row], self.qvars[col]) + for v1, v2, coef in zip(self.qvars1, self.qvars2, self.qcoefficients): + builder.add_quadratic(coef, v1, v2) + for var, coef in zip(self.vars, self.coefficients): + builder.add_linear(coef, var) + builder.add_constant(self.constant) + return builder.build() + + def __repr__(self): + return f"" + def _quadratic_expression_to_qcmatrix(expr, rhs): """Build QCMATRIX COO data for a quadratic row ``expr`` sense ``rhs``. @@ -1280,6 +1406,12 @@ def __eq__(self, other): expr = self - other return Constraint(expr, EQ, 0.0) + def __str__(self): + return _format_linear(self.vars, self.coefficients, self.constant) + + def __repr__(self): + return f"" + class Constraint: """ @@ -1322,6 +1454,7 @@ def __init__(self, expr, sense, rhs, name=""): self.ConstraintName = name self.DualValue = float("nan") self.Slack = float("nan") + self._expr = expr if isinstance(expr, QuadraticExpression): self.is_quadratic = True @@ -1397,6 +1530,17 @@ def compute_slack(self): return self.RHS - lhs + def __str__(self): + sense_str = _SENSE_SYMBOLS.get(self.Sense, str(self.Sense)) + lhs = str(self._expr) if self._expr is not None else "0.0" + expr_constant = getattr(self._expr, "constant", 0.0) or 0.0 + user_rhs = self.RHS + expr_constant + return f"{lhs} {sense_str} {user_rhs}" + + def __repr__(self): + name = self.ConstraintName if self.ConstraintName else "" + return f"" + class Problem: """ @@ -2219,3 +2363,54 @@ def solve(self, settings=solver_settings.SolverSettings()): # Post Solve self.populate_solution(solution) return solution + + def __repr__(self): + name = self.Name if self.Name else "" + return ( + f"" + ) + + def __str__(self): + lines = [] + name = self.Name if self.Name else "" + lines.append(f"Problem: {name}") + sense_str = "MINIMIZE" if self.ObjSense == MINIMIZE else "MAXIMIZE" + lines.append(f" Objective: {sense_str}") + + n_cont = 0 + n_int = 0 + n_semi = 0 + for v in self.vars: + t = v.VariableType + if isinstance(t, (bytes, bytearray)): + t = t.decode() + if t in ("I", VType.INTEGER): + n_int += 1 + elif t in ("S", VType.SEMI_CONTINUOUS): + n_semi += 1 + else: + n_cont += 1 + lines.append( + f" Variables: {len(self.vars)} " + f"(continuous={n_cont}, integer={n_int}, " + f"semi-continuous={n_semi})" + ) + + n_linear = sum(1 for c in self.constrs if not c.is_quadratic) + n_quad = sum(1 for c in self.constrs if c.is_quadratic) + lines.append( + f" Constraints: {len(self.constrs)} " + f"(linear={n_linear}, quadratic={n_quad})" + ) + lines.append(f" Non-zeros: {self.NumNZs}") + + if self.solved: + status = self.Status + if hasattr(status, "name"): + status = status.name + lines.append(f" Status: {status}") + lines.append(f" Objective value: {self.ObjValue}") + + return "\n".join(lines) diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 860b7aef2a..14b17d938a 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -826,3 +826,129 @@ def test_quadratic_matrix_2(): assert x2.getValue() == pytest.approx(0.0000000, abs=1e-3) assert x3.getValue() == pytest.approx(0.1092896, abs=1e-3) assert problem.ObjValue == pytest.approx(3.715847, abs=1e-3) + + +def test_str_and_repr(): + """Verify algebraic __str__ and detailed __repr__ for LP API classes.""" + prob = Problem("str_repr_test") + + # === Variable === + x = prob.addVariable(lb=0.0, ub=10.0, vtype=VType.CONTINUOUS, name="x") + y = prob.addVariable(lb=0.0, ub=5.0, vtype=VType.INTEGER, name="y") + z = prob.addVariable() + + # __str__: with name returns the name + assert str(x) == "x" + assert str(y) == "y" + # __str__: without name falls back to C{index} + assert str(z) == "C2" + + # __repr__: detailed summary + r = repr(x) + assert "cuopt.Variable" in r + assert "'x'" in r + assert "index=0" in r + assert "type=CONTINUOUS" in r + assert "bounds=[0.0, 10.0]" in r + assert "value=nan" in r + + r = repr(y) + assert "type=INTEGER" in r + assert "bounds=[0.0, 5.0]" in r + + r = repr(z) + assert "'C2'" in r + assert "index=2" in r + + # === LinearExpression === + expr1 = 2 * x + 3 * y + expr2 = expr1 - 5 + expr3 = -x + 2.5 + + # __str__ + assert str(expr1) == "2.0 * x + 3.0 * y" + assert str(expr2) == "2.0 * x + 3.0 * y - 5.0" + assert str(expr3) == "-x + 2.5" + # Empty expression collapses to 0.0 + assert str(LinearExpression([], [], 0.0)) == "0.0" + # Constant-only expression + assert str(LinearExpression([], [], 3.0)) == "3.0" + assert str(LinearExpression([], [], -3.0)) == "-3.0" + + # __repr__ + assert repr(expr1) == "" + + # === QuadraticExpression === + qexpr1 = x * x + qexpr2 = qexpr1 + 2 * x * y + 3 * x + qexpr3 = -x * x + 0.5 * y * y + x * y + + # __str__ + assert str(qexpr1) == "x^2" + assert str(qexpr2) == "x^2 + 2.0 * x * y + 3.0 * x" + assert str(qexpr3) == "-x^2 + 0.5 * y^2 + x * y" + # Empty quadratic expression + assert str(QuadraticExpression()) == "0.0" + + # __repr__ + assert repr(qexpr1) == "" + + # === Constraint === + c1 = 2 * x + 3 * y <= 10 + c2 = x - y >= 0 + c3 = x + 1 == 5 + prob.addConstraint(c1, name="c1") + prob.addConstraint(c2, name="c2") + prob.addConstraint(c3, name="c3") + + # __str__: shows the original (un-moved) form + assert str(c1) == "2.0 * x + 3.0 * y <= 10.0" + assert str(c2) == "x - y >= 0.0" + assert str(c3) == "x + 1.0 == 5.0" + + # __str__: unnamed constraint + c_anon = 2 * x + 3 * y <= 10 + assert "2.0 * x + 3.0 * y <= 10.0" in str(c_anon) + + # __repr__ + assert repr(c1) == "" + assert repr(c2) == "= 0.0>" + assert repr(c3) == "" + + # === Problem === + # __repr__ + r = repr(prob) + assert "cuopt.Problem" in r + assert "str_repr_test" in r + assert "3 vars" in r + assert "3 constrs" in r + assert "IsMIP=True" in r # y is integer + + # __str__: before solve + s = str(prob) + assert "str_repr_test" in s + assert "MINIMIZE" in s + assert "Variables: 3" in s + assert "continuous=2" in s + assert "integer=1" in s + assert "semi-continuous=0" in s + assert "Constraints: 3" in s + assert "linear=3" in s + assert "quadratic=0" in s + assert "Non-zeros: 5" in s + # No status before solve + assert "Status:" not in s + assert "Objective value:" not in s + + # __str__: after solve includes status and objective value + settings = SolverSettings() + settings.set_parameter("time_limit", 5) + prob.solve(settings) + s = str(prob) + assert "Status: Optimal" in s + assert "Objective value:" in s + + # Unnamed problem + empty_prob = Problem() + assert "Problem: " in str(empty_prob) + assert "'" in repr(empty_prob) From cd6c5ca72375c3635b7536915e4db62fb1b7d172 Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Sat, 27 Jun 2026 02:49:51 +0300 Subject: [PATCH 2/3] Truncate large expressions in LP API __str__/__repr__ Linear and quadratic expressions now render only the first _MAX_DISPLAY_TERMS (10) terms followed by a "... (N more terms)" marker, so printing a model with thousands of terms stays readable in a REPL or notebook instead of flooding the output. Applies to both __str__ and __repr__ (and therefore Constraint, whose LHS is the expression); the cap is a module constant and can be set to None to disable. Also fix an import-time NameError: the module-level _SENSE_SYMBOLS table referenced LE/GE/EQ before they were defined. Re-key it by the underlying CType char codes ("L"/"G"/"E"), which CType members compare and hash equal to, so the module imports regardless of definition order. Add test_str_truncation_large_expression covering the linear/quadratic head + marker, the exactly-at-cap (no marker) and one-over (singular) boundaries, and the truncation-disabled case. Signed-off-by: jackthepunished --- .../cuopt/cuopt/linear_programming/problem.py | 54 ++++++++++++++++--- .../linear_programming/test_python_API.py | 44 +++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index 4ddbd56254..8609e09c92 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -18,9 +18,21 @@ # ---- Display helpers for __str__/__repr__ ---- -_SENSE_SYMBOLS = {LE: "<=", GE: ">=", EQ: "=="} +# Keyed by the underlying CType char codes ("L"/"G"/"E"). CType is a +# ``(str, Enum)`` whose members compare and hash equal to these codes, so the +# lookup works whether ``Constraint.Sense`` holds a CType member or a raw +# string. Using the codes (rather than the LE/GE/EQ aliases) also keeps this +# module-level table independent of definition order. +_SENSE_SYMBOLS = {"L": "<=", "G": ">=", "E": "=="} _TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"} +# Maximum number of terms rendered when stringifying a linear or quadratic +# expression. Beyond this, the head is shown followed by a ``... (N more +# terms)`` marker so that printing a model with thousands of terms stays +# readable in a REPL or notebook instead of flooding the output. Set to +# ``None`` to disable truncation entirely. +_MAX_DISPLAY_TERMS = 10 + def _var_display_name(var): """Return the display name of a Variable.""" @@ -47,10 +59,21 @@ class _ExprBuilder: The first term is emitted without a sign; subsequent terms are joined with ' + ' or ' - ' separators. A coefficient of 1.0 or -1.0 is elided, so '1.0 * x' becomes 'x' and '-1.0 * x' becomes '-x'. + + When ``max_terms`` is set, only the first ``max_terms`` non-zero terms + are rendered; any remaining terms are counted and summarized as a + trailing ``... (N more terms)`` marker. This keeps the output bounded + for expressions with very many terms. ``max_terms=None`` (the default) + renders every term. """ - def __init__(self): + def __init__(self, max_terms=None): self.parts = [] + self.max_terms = max_terms + # Non-zero terms seen so far (rendered + hidden). + self.n_terms = 0 + # Non-zero terms omitted because the cap was reached. + self.n_hidden = 0 def add_linear(self, coef, var): """Add a linear term ``coef * var``.""" @@ -90,20 +113,30 @@ def add_constant(self, value): self._append(f"{abs(value)}", negative=value < 0) def _append(self, term, negative): + self.n_terms += 1 + if self.max_terms is not None and self.n_terms > self.max_terms: + # Past the cap: count the term but don't render it. + self.n_hidden += 1 + return if not self.parts: self.parts.append(f"-{term}" if negative else term) else: self.parts.append(f" - {term}" if negative else f" + {term}") def build(self): - if not self.parts: + if not self.parts and not self.n_hidden: return "0.0" - return "".join(self.parts) + result = "".join(self.parts) + if self.n_hidden: + plural = "term" if self.n_hidden == 1 else "terms" + marker = f"... ({self.n_hidden} more {plural})" + result = f"{result} + {marker}" if result else marker + return result -def _format_linear(vars, coeffs, constant): +def _format_linear(vars, coeffs, constant, max_terms=None): """Format a linear expression as an algebraic string.""" - builder = _ExprBuilder() + builder = _ExprBuilder(max_terms=max_terms) for var, coef in zip(vars, coeffs): builder.add_linear(coef, var) builder.add_constant(constant) @@ -997,7 +1030,7 @@ def __eq__(self, other): raise ValueError("Equality constraints are not supported.") def __str__(self): - builder = _ExprBuilder() + builder = _ExprBuilder(max_terms=_MAX_DISPLAY_TERMS) if self.qmatrix is not None: for row, col, val in zip( self.qmatrix.row, self.qmatrix.col, self.qmatrix.data @@ -1407,7 +1440,12 @@ def __eq__(self, other): return Constraint(expr, EQ, 0.0) def __str__(self): - return _format_linear(self.vars, self.coefficients, self.constant) + return _format_linear( + self.vars, + self.coefficients, + self.constant, + max_terms=_MAX_DISPLAY_TERMS, + ) def __repr__(self): return f"" diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index 14b17d938a..e718dfcb49 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -952,3 +952,47 @@ def test_str_and_repr(): empty_prob = Problem() assert "Problem: " in str(empty_prob) assert "'" in repr(empty_prob) + + +def test_str_truncation_large_expression(): + """Large expressions truncate so a model with thousands of terms stays + readable in a REPL or notebook instead of flooding the output.""" + from cuopt.linear_programming.problem import _MAX_DISPLAY_TERMS + + cap = _MAX_DISPLAY_TERMS + n = cap * 3 # comfortably over the cap + + prob = Problem("trunc_test") + xs = [prob.addVariable(name=f"x{i}") for i in range(n)] + + # === LinearExpression: head is rendered, tail is summarized === + expr = 1 * xs[0] + for i in range(1, n): + expr = expr + (i + 1) * xs[i] + s = str(expr) + # Exactly `cap` terms rendered (all positive -> all " + " separators), + # plus the trailing marker joined with one more " + ". + assert s.count(" + ") == cap + assert s.startswith("x0 + ") + assert s.endswith(f"... ({n - cap} more terms)") + # repr wraps the same (truncated) string. + assert repr(expr) == f"" + + # === Exactly at the cap: no truncation === + at_cap = 1 * xs[0] + for i in range(1, cap): + at_cap = at_cap + xs[i] + assert "more terms" not in str(at_cap) + + # === One over the cap: singular "term" wording === + over = at_cap + xs[cap] + assert str(over).endswith("... (1 more term)") + + # === QuadraticExpression also truncates === + qexpr = xs[0] * xs[0] + for i in range(1, n): + qexpr = qexpr + xs[i] * xs[i] + qs = str(qexpr) + assert qs.count("^2") <= cap + assert qs.endswith(f"... ({n - cap} more terms)") + assert repr(qexpr) == f"" From 26fc82033680ae83e1ccdac88ee9db14d2d5f59f Mon Sep 17 00:00:00 2001 From: jackthepunished Date: Sat, 1 Aug 2026 16:41:21 +0300 Subject: [PATCH 3/3] Address review: move display helpers, use enum members, drop stored expr - Replace _SENSE_SYMBOLS with a CType.symbol property and _TYPE_NAMES with VType(...).name lookups. - Fold _var_display_name into Variable.__str__ and move the remaining display helpers (_MAX_DISPLAY_TERMS, _ExprBuilder, _format_linear) next to the expression classes that use them. - Use direct .index access instead of getattr; the attribute is always set in Variable.__init__ (-1 until addVariable assigns it). - Stop storing the expression on Constraint; __str__ now renders lazily from the solver data the constraint already holds, so constraints print in normalized form (duplicates merged, constants folded into the RHS) and stay in sync after updateConstraint. Quadratic rows now record all participating variables so QCMATRIX indices map to names. - Tests: add missing LinearExpression import, tighten the quadratic truncation assertion, and cover quadratic/duplicate/updateConstraint constraint display. --- .../cuopt/cuopt/linear_programming/problem.py | 279 +++++++++--------- .../linear_programming/test_python_API.py | 27 +- 2 files changed, 162 insertions(+), 144 deletions(-) diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index 8609e09c92..b617051255 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -16,133 +16,6 @@ import warnings -# ---- Display helpers for __str__/__repr__ ---- - -# Keyed by the underlying CType char codes ("L"/"G"/"E"). CType is a -# ``(str, Enum)`` whose members compare and hash equal to these codes, so the -# lookup works whether ``Constraint.Sense`` holds a CType member or a raw -# string. Using the codes (rather than the LE/GE/EQ aliases) also keeps this -# module-level table independent of definition order. -_SENSE_SYMBOLS = {"L": "<=", "G": ">=", "E": "=="} -_TYPE_NAMES = {"C": "CONTINUOUS", "I": "INTEGER", "S": "SEMI_CONTINUOUS"} - -# Maximum number of terms rendered when stringifying a linear or quadratic -# expression. Beyond this, the head is shown followed by a ``... (N more -# terms)`` marker so that printing a model with thousands of terms stays -# readable in a REPL or notebook instead of flooding the output. Set to -# ``None`` to disable truncation entirely. -_MAX_DISPLAY_TERMS = 10 - - -def _var_display_name(var): - """Return the display name of a Variable.""" - name = var.VariableName - if name: - return name - if getattr(var, "index", -1) >= 0: - return f"C{var.index}" - return f"V{id(var)}" - - -def _type_display(type_val): - """Return a human-readable name for a variable type.""" - if isinstance(type_val, VType): - return type_val.name - if isinstance(type_val, (bytes, bytearray)): - type_val = type_val.decode() - return _TYPE_NAMES.get(type_val, str(type_val)) - - -class _ExprBuilder: - """Build an algebraic string from a sequence of terms. - - The first term is emitted without a sign; subsequent terms are joined - with ' + ' or ' - ' separators. A coefficient of 1.0 or -1.0 is - elided, so '1.0 * x' becomes 'x' and '-1.0 * x' becomes '-x'. - - When ``max_terms`` is set, only the first ``max_terms`` non-zero terms - are rendered; any remaining terms are counted and summarized as a - trailing ``... (N more terms)`` marker. This keeps the output bounded - for expressions with very many terms. ``max_terms=None`` (the default) - renders every term. - """ - - def __init__(self, max_terms=None): - self.parts = [] - self.max_terms = max_terms - # Non-zero terms seen so far (rendered + hidden). - self.n_terms = 0 - # Non-zero terms omitted because the cap was reached. - self.n_hidden = 0 - - def add_linear(self, coef, var): - """Add a linear term ``coef * var``.""" - if coef == 0.0: - return - var_str = _var_display_name(var) - if coef == 1.0: - self._append(var_str, negative=False) - elif coef == -1.0: - self._append(var_str, negative=True) - else: - self._append(f"{abs(coef)} * {var_str}", negative=coef < 0) - - def add_quadratic(self, coef, var1, var2): - """Add a quadratic term ``coef * var1 * var2``.""" - if coef == 0.0: - return - v1_str = _var_display_name(var1) - v2_str = _var_display_name(var2) - if v1_str == v2_str: - term_str = f"{v1_str}^2" - elif v1_str <= v2_str: - term_str = f"{v1_str} * {v2_str}" - else: - term_str = f"{v2_str} * {v1_str}" - if coef == 1.0: - self._append(term_str, negative=False) - elif coef == -1.0: - self._append(term_str, negative=True) - else: - self._append(f"{abs(coef)} * {term_str}", negative=coef < 0) - - def add_constant(self, value): - """Add a constant term.""" - if value == 0.0: - return - self._append(f"{abs(value)}", negative=value < 0) - - def _append(self, term, negative): - self.n_terms += 1 - if self.max_terms is not None and self.n_terms > self.max_terms: - # Past the cap: count the term but don't render it. - self.n_hidden += 1 - return - if not self.parts: - self.parts.append(f"-{term}" if negative else term) - else: - self.parts.append(f" - {term}" if negative else f" + {term}") - - def build(self): - if not self.parts and not self.n_hidden: - return "0.0" - result = "".join(self.parts) - if self.n_hidden: - plural = "term" if self.n_hidden == 1 else "terms" - marker = f"... ({self.n_hidden} more {plural})" - result = f"{result} + {marker}" if result else marker - return result - - -def _format_linear(vars, coeffs, constant, max_terms=None): - """Format a linear expression as an algebraic string.""" - builder = _ExprBuilder(max_terms=max_terms) - for var, coef in zip(vars, coeffs): - builder.add_linear(coef, var) - builder.add_constant(constant) - return builder.build() - - class VType(str, Enum): """ The type of a variable is continuous, integer, or semi-continuous. @@ -175,6 +48,11 @@ class CType(str, Enum): GE = "G" EQ = "E" + @property + def symbol(self): + """Algebraic symbol used when printing constraints.""" + return {CType.LE: "<=", CType.GE: ">=", CType.EQ: "=="}[self] + LE = CType.LE GE = CType.GE @@ -463,19 +341,123 @@ def __eq__(self, other): raise ValueError("Unsupported operation") def __str__(self): - return _var_display_name(self) + if self.VariableName: + return self.VariableName + if self.index >= 0: + return f"C{self.index}" + # Not yet added to a problem: no name and no index to show. + return f"V{id(self)}" def __repr__(self): - name = _var_display_name(self) - idx = getattr(self, "index", -1) - type_str = _type_display(self.VariableType) + vtype = self.VariableType + if isinstance(vtype, (bytes, bytearray)): + # The MPS data model yields variable types as byte codes. + vtype = vtype.decode() return ( - f"" ) +# Maximum number of terms rendered when stringifying a linear or quadratic +# expression. Beyond this, the head is shown followed by a ``... (N more +# terms)`` marker so that printing a model with thousands of terms stays +# readable in a REPL or notebook instead of flooding the output. Set to +# ``None`` to disable truncation entirely. +_MAX_DISPLAY_TERMS = 10 + + +class _ExprBuilder: + """Build an algebraic string from a sequence of terms. + + The first term is emitted without a sign; subsequent terms are joined + with ' + ' or ' - ' separators. A coefficient of 1.0 or -1.0 is + elided, so '1.0 * x' becomes 'x' and '-1.0 * x' becomes '-x'. + + When ``max_terms`` is set, only the first ``max_terms`` non-zero terms + are rendered; any remaining terms are counted and summarized as a + trailing ``... (N more terms)`` marker. This keeps the output bounded + for expressions with very many terms. ``max_terms=None`` (the default) + renders every term. + """ + + def __init__(self, max_terms=None): + self.parts = [] + self.max_terms = max_terms + # Non-zero terms seen so far (rendered + hidden). + self.n_terms = 0 + # Non-zero terms omitted because the cap was reached. + self.n_hidden = 0 + + def add_linear(self, coef, var): + """Add a linear term ``coef * var``.""" + if coef == 0.0: + return + var_str = str(var) + if coef == 1.0: + self._append(var_str, negative=False) + elif coef == -1.0: + self._append(var_str, negative=True) + else: + self._append(f"{abs(coef)} * {var_str}", negative=coef < 0) + + def add_quadratic(self, coef, var1, var2): + """Add a quadratic term ``coef * var1 * var2``.""" + if coef == 0.0: + return + v1_str = str(var1) + v2_str = str(var2) + if v1_str == v2_str: + term_str = f"{v1_str}^2" + elif v1_str <= v2_str: + term_str = f"{v1_str} * {v2_str}" + else: + term_str = f"{v2_str} * {v1_str}" + if coef == 1.0: + self._append(term_str, negative=False) + elif coef == -1.0: + self._append(term_str, negative=True) + else: + self._append(f"{abs(coef)} * {term_str}", negative=coef < 0) + + def add_constant(self, value): + """Add a constant term.""" + if value == 0.0: + return + self._append(f"{abs(value)}", negative=value < 0) + + def _append(self, term, negative): + self.n_terms += 1 + if self.max_terms is not None and self.n_terms > self.max_terms: + # Past the cap: count the term but don't render it. + self.n_hidden += 1 + return + if not self.parts: + self.parts.append(f"-{term}" if negative else term) + else: + self.parts.append(f" - {term}" if negative else f" + {term}") + + def build(self): + if not self.parts and not self.n_hidden: + return "0.0" + result = "".join(self.parts) + if self.n_hidden: + plural = "term" if self.n_hidden == 1 else "terms" + marker = f"... ({self.n_hidden} more {plural})" + result = f"{result} + {marker}" if result else marker + return result + + +def _format_linear(vars, coeffs, constant, max_terms=None): + """Format a linear expression as an algebraic string.""" + builder = _ExprBuilder(max_terms=max_terms) + for var, coef in zip(vars, coeffs): + builder.add_linear(coef, var) + builder.add_constant(constant) + return builder.build() + + class QuadraticExpression: """ QuadraticExpressions contain quadratic terms, linear terms, and a constant. @@ -1492,7 +1474,6 @@ def __init__(self, expr, sense, rhs, name=""): self.ConstraintName = name self.DualValue = float("nan") self.Slack = float("nan") - self._expr = expr if isinstance(expr, QuadraticExpression): self.is_quadratic = True @@ -1512,7 +1493,14 @@ def __init__(self, expr, sense, rhs, name=""): self.rhs_value = rhs_value self.RHS = rhs_value self.vindex_coeff_dict = {} - self.vars = expr.vars + # All participating variables (linear and quadratic terms), so + # that QCMATRIX indices can be mapped back to variables. + # Deduplicated by identity: Variable overloads __eq__ to build + # constraints, so instances are unhashable. + seen = {} + for var in (*expr.vars, *expr.qvars1, *expr.qvars2, *expr.qvars): + seen.setdefault(id(var), var) + self.vars = list(seen.values()) return self.is_quadratic = False @@ -1569,11 +1557,22 @@ def compute_slack(self): return self.RHS - lhs def __str__(self): - sense_str = _SENSE_SYMBOLS.get(self.Sense, str(self.Sense)) - lhs = str(self._expr) if self._expr is not None else "0.0" - expr_constant = getattr(self._expr, "constant", 0.0) or 0.0 - user_rhs = self.RHS + expr_constant - return f"{lhs} {sense_str} {user_rhs}" + # Rendered from the data the constraint stores for the solver, so + # the output is normalized: duplicate terms are merged and any + # expression constant is folded into the right-hand side. + builder = _ExprBuilder(max_terms=_MAX_DISPLAY_TERMS) + index_to_var = {v.index: v for v in self.vars} + if self.is_quadratic: + for row, col, val in zip(self.rows, self.cols, self.vals): + builder.add_quadratic( + val, index_to_var[row], index_to_var[col] + ) + for idx, val in zip(self.linear_indices, self.linear_values): + builder.add_linear(val, index_to_var[idx]) + else: + for idx, coeff in self.vindex_coeff_dict.items(): + builder.add_linear(coeff, index_to_var[idx]) + return f"{builder.build()} {CType(self.Sense).symbol} {self.RHS}" def __repr__(self): name = self.ConstraintName if self.ConstraintName else "" diff --git a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py index e718dfcb49..c50f20c574 100644 --- a/python/cuopt/cuopt/tests/linear_programming/test_python_API.py +++ b/python/cuopt/cuopt/tests/linear_programming/test_python_API.py @@ -18,6 +18,7 @@ MINIMIZE, SEMI_CONTINUOUS, CType, + LinearExpression, Problem, VType, sense, @@ -901,19 +902,36 @@ def test_str_and_repr(): prob.addConstraint(c2, name="c2") prob.addConstraint(c3, name="c3") - # __str__: shows the original (un-moved) form + # __str__: shows the normalized form the solver holds (duplicate terms + # merged, expression constants folded into the right-hand side) assert str(c1) == "2.0 * x + 3.0 * y <= 10.0" assert str(c2) == "x - y >= 0.0" - assert str(c3) == "x + 1.0 == 5.0" + assert str(c3) == "x == 4.0" # __str__: unnamed constraint c_anon = 2 * x + 3 * y <= 10 assert "2.0 * x + 3.0 * y <= 10.0" in str(c_anon) + # __str__: duplicate terms are merged + c_dup = 2 * x + 3 * x <= 5 + assert str(c_dup) == "5.0 * x <= 5.0" + + # __str__: quadratic constraint rendered from its QCMATRIX data + c_quad = x * x + 2 * x * y <= 4 + assert str(c_quad) == "x^2 + 2.0 * x * y <= 4.0" + + # __str__: reflects updateConstraint (no stale expression data) + prob_upd = Problem("upd_test") + a = prob_upd.addVariable(name="a") + b = prob_upd.addVariable(name="b") + c_upd = prob_upd.addConstraint(2 * a + 3 * b <= 10, name="c_upd") + prob_upd.updateConstraint(c_upd, coeffs=[(a, 7.0)], rhs=20.0) + assert str(c_upd) == "7.0 * a + 3.0 * b <= 20.0" + # __repr__ assert repr(c1) == "" assert repr(c2) == "= 0.0>" - assert repr(c3) == "" + assert repr(c3) == "" # === Problem === # __repr__ @@ -993,6 +1011,7 @@ def test_str_truncation_large_expression(): for i in range(1, n): qexpr = qexpr + xs[i] * xs[i] qs = str(qexpr) - assert qs.count("^2") <= cap + assert qs.count("^2") == cap + assert qs.startswith("x0^2 + ") assert qs.endswith(f"... ({n - cap} more terms)") assert repr(qexpr) == f""