Polishing

An epsilon-continuation solve returns a point that approximately satisfies the lower optimality conditions. Polishing holds the result’s upper variables fixed and solves the canonical lower problem once more, without the continuation relaxation. This produces a fresh lower response and a compact summary that helps you decide whether to adopt or discard the candidate.

Polishing is non-mutating: it restores every affected CVXPY variable, parameter, and lifted value before returning or raising, and it does not change the supplied BilevelResult.

Basic usage

Call blvpy.BilevelProblem.polish() with a result from the same problem:

import cvxpy as cp

polished = problem.polish(
    result,
    solver=cp.CLARABEL,
    solver_options=None,
    verbose=True,
    solver_verbose=False,
)

print(polished.feasible)
print(polished.residuals.max_violation)
print(polished.feasibility_tolerance)
print(polished.objective)
print(polished.objective_improvement_ratio)

Successful results and complete continuation_failed results can be polished. The source-variable snapshots in the result, rather than the variables’ current .value attributes, define the point to polish.

With the default verbose=True, the call immediately writes a summary like this to standard error:

-------------------------------------------------------------------------------
                                   Polishing
-------------------------------------------------------------------------------
(BLVPY) Result: feasible=true | objective=8.000e+00
(BLVPY)   improvement_ratio=2.000e-01
(BLVPY) Residuals: max_violation=5.000e-08 | feasibility_tolerance=1.000e-07

When the candidate is infeasible, the summary instead prints every residual used by the feasibility decision and marks the largest checked value:

(BLVPY) Result: feasible=false | objective=8.000e+00
(BLVPY)   improvement_ratio=2.000e-01
(BLVPY) Residuals: feasibility_tolerance=1.000e-07
(BLVPY)   primal_equality=1.000e-08
(BLVPY)   dual_equality=2.000e-08
(BLVPY)   recovery=3.000e-08
(BLVPY)   upper_constraints=4.000e-04 | largest=true
(BLVPY)   primal_cone=5.000e-08
(BLVPY)   dual_cone=6.000e-08
(BLVPY)   gap_violation=7.000e-08

largest=true marks every exact tie; max_violation is omitted because it would repeat the marked value.

Set verbose=False to suppress BLVPY’s summary. This is independent of solver_verbose: the latter controls CVXPY and native conic-solver output on a best-effort basis.

Interpreting the result

blvpy.PolishResult is an immutable snapshot of the complete candidate, its diagnostics, and its upper-objective comparison; see API reference for individual field contracts. Its feasible property is derived rather than stored:

polished.feasible == polished.residuals.is_feasible(polished.feasibility_tolerance)

Both the original and polished upper objectives are reevaluated under the same fixed-parameter state. For original objective \(F_{\rm original}\) and polished objective \(F_{\rm polished}\), the reported ratio is

\[ \frac{F_{\rm original}-F_{\rm polished}} {|F_{\rm original}|} \quad\text{for minimization}, \]

and

\[ \frac{F_{\rm polished}-F_{\rm original}} {|F_{\rm original}|} \quad\text{for maximization}. \]

A positive ratio is an improvement, zero is unchanged, and a negative ratio is worse. When the original objective is exactly zero, division would be undefined, so objective_improvement_ratio is None and the terminal output shows n/a. A nonzero value that is merely close to zero is still used as the denominator; the resulting ratio can therefore be large and should be read alongside the absolute objective. If its magnitude exceeds floating-point range, the ratio is +inf or -inf, with the usual better-or-worse sign.

Feasibility

Polishing independently recomputes BLVPY’s residuals at epsilon=0 and checks them against the originating solve’s tolerance, because a successful solver status indicates that numerical stopping rules were met rather than proving exact feasibility. See Results and diagnostics for the residual definitions and the scope of polished and source-point diagnostics.

Raw complementarity remains available in polished.residuals but is omitted from the terminal: at epsilon=0, its positive part is gap_violation, while a negative value does not violate the one-sided gap check.

Warning

If the lower problem has multiple optima, the conic solver chooses one of them. That response can lose the optimistic tie-breaking achieved by the bilevel solve, worsen the upper objective, or violate an upper constraint that depends on lower variables. A completed polishing solve can therefore return feasible=False.

Adopting the candidate

To adopt the complete candidate, project each immutable snapshot through the original variable’s attributes:

if polished.feasible:
    for variable, value in polished.variable_values.items():
        variable.project_and_assign(value)

Solver options and errors

Clarabel is the default fixed-upper conic solver. solver_options is copied and forwarded to CVXPY, so backend-specific settings can be supplied without being modified:

polished = problem.polish(
    result,
    solver=cp.SCS,
    solver_options={"eps": 1e-7},
    solver_verbose=False,
)

Polishing raises rather than encoding solver-status details in PolishResult. An unavailable backend raises blvpy.SolverUnavailableError; a lower solve that fails or returns an incomplete certificate raises blvpy.SolveError. Invalid, incomplete, or foreign results raise the corresponding argument error.