diff --git a/python/cuopt/cuopt/linear_programming/problem.py b/python/cuopt/cuopt/linear_programming/problem.py index 10600a543b..b617051255 100644 --- a/python/cuopt/cuopt/linear_programming/problem.py +++ b/python/cuopt/cuopt/linear_programming/problem.py @@ -48,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 @@ -335,6 +340,123 @@ def __eq__(self, other): case _: raise ValueError("Unsupported operation") + def __str__(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): + 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: """ @@ -889,6 +1011,25 @@ def __ge__(self, other): def __eq__(self, other): raise ValueError("Equality constraints are not supported.") + def __str__(self): + 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 + ): + 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 +1421,17 @@ 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, + max_terms=_MAX_DISPLAY_TERMS, + ) + + def __repr__(self): + return f"" + class Constraint: """ @@ -1341,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 @@ -1397,6 +1556,28 @@ def compute_slack(self): return self.RHS - lhs + def __str__(self): + # 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 "" + return f"" + class Problem: """ @@ -2219,3 +2400,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..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, @@ -826,3 +827,191 @@ 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 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 == 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) == "" + + # === 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) + + +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.startswith("x0^2 + ") + assert qs.endswith(f"... ({n - cap} more terms)") + assert repr(qexpr) == f""